Files
webmail/stores/settings-store.ts
T
Jacob ZeitzandClaude Opus 5.5 372fc9ec71 feat: let plugins update and remove the tags they added
keywords.add now records the plugin on each definition it adds
(addedBy). keywords.update changes the label, colour or visibility of
those definitions, never their id; keywords.remove deletes them, as
Settings > Tags does, and leaves the keywords on messages alone. Both
need settings:write and skip every other definition, so a plugin cannot
rename or delete the user's own tags or another plugin's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 08:54:23 +02:00

2070 lines
93 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';
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;
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;
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;
// "@" 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,
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,
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,
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,
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,
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<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);
}