Files
SRCmail/stores/settings-store.ts
T
Hamed FallahandGitHub e10fced28a feat: add Jalali (Persian/Shamsi) calendar support with Saturday as week start (#490)
* feat: add Jalali (Persian/Shamsi) calendar support with Saturday as week start

- Add jalaali-js library for Gregorian ↔ Jalali date conversion
- Create lib/jalali-utils.ts with Jalali calendar utilities
- Create hooks/use-calendar-locale.ts for unified calendar locale handling
- Expand FirstDayOfWeek type to include 6 (Saturday)
- Update all calendar views (month, week, day, mini, toolbar) to support
  Jalali calendar display and Saturday-first week ordering
- Add Jalali month names (Farvardin … Esfand) to all locale files
- Add Persian (fa) locale with full translations
- Update settings UI to include Saturday as first day of week option
- Update useFormatEventDate to show Jalali dates when locale is fa
- Auto-detect Jalali calendar when fa locale is active

The calendar system automatically switches to Jalali when the locale is
set to Persian (fa). All internal date handling remains Gregorian (ISO
8601) for JMAP protocol compatibility; Jalali conversion is purely at
the display layer.

* Add PR template for Jalali calendar feature

* chore: remove accidentally added PR template

* fix: add image_too_large key to fa locale for PR #462 compatibility
2026-07-08 15:42:42 +02:00

1084 lines
39 KiB
TypeScript

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { useThemeStore } from './theme-store';
import { useLocaleStore } from './locale-store';
import type { NotificationSoundChoice } from '@/lib/notification-sound';
import { apiFetch } from '@/lib/browser-navigation';
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 isLoadingFromServer = false;
const SYNC_DEBOUNCE_MS = 2000;
export type FontSize = 'small' | 'medium' | 'large';
export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
/** @deprecated Use Density instead */
export type ListDensity = Density;
export type DeleteAction = 'trash' | 'trash-and-read' | 'permanent';
export type ReplyMode = 'reply' | 'replyAll';
export type SignaturePosition = 'above_quote' | 'below_quote';
/** How to handle an incoming Disposition-Notification-To (read-receipt) request. */
export type ReadReceiptResponse = 'ask' | 'always' | 'never';
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
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';
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']);
export type HoverAction = 'delete' | 'star' | 'markRead' | 'archive' | 'tag' | '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' },
];
export interface KeywordDefinition {
id: string; // Used as JMAP keyword suffix: $label:<id>
label: string; // Display name
color: string; // Key from KEYWORD_PALETTE
}
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;
}
// Available color palette for keywords
export const KEYWORD_PALETTE: Record<string, { dot: string; bg: string }> = {
red: { dot: 'bg-red-500', bg: 'bg-red-50 dark:bg-red-950/30' },
orange: { dot: 'bg-orange-500', bg: 'bg-orange-50 dark:bg-orange-950/30' },
yellow: { dot: 'bg-yellow-500', bg: 'bg-yellow-50 dark:bg-yellow-950/30' },
green: { dot: 'bg-green-500', bg: 'bg-green-50 dark:bg-green-950/30' },
blue: { dot: 'bg-blue-500', bg: 'bg-blue-50 dark:bg-blue-950/30' },
purple: { dot: 'bg-purple-500', bg: 'bg-purple-50 dark:bg-purple-950/30' },
pink: { dot: 'bg-pink-500', bg: 'bg-pink-50 dark:bg-pink-950/30' },
teal: { dot: 'bg-teal-500', bg: 'bg-teal-50 dark:bg-teal-950/30' },
cyan: { dot: 'bg-cyan-500', bg: 'bg-cyan-50 dark:bg-cyan-950/30' },
indigo: { dot: 'bg-indigo-500', bg: 'bg-indigo-50 dark:bg-indigo-950/30' },
amber: { dot: 'bg-amber-500', bg: 'bg-amber-50 dark:bg-amber-950/30' },
lime: { dot: 'bg-lime-500', bg: 'bg-lime-50 dark:bg-lime-950/30' },
gray: { dot: 'bg-gray-500', bg: 'bg-gray-50 dark:bg-gray-950/30' },
} as const;
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' },
];
interface SettingsState {
// Appearance
fontSize: FontSize;
density: Density;
animationsEnabled: boolean;
// Language & Region
dateFormat: DateFormat;
dateLocale: DateLocale;
timeFormat: TimeFormat;
firstDayOfWeek: FirstDayOfWeek;
// Email Behavior
markAsReadDelay: number; // milliseconds (0 = instant, -1 = never)
deleteAction: DeleteAction;
permanentlyDeleteJunk: boolean; // Permanently delete emails from junk/spam instead of moving to trash
returnToListAfterAction: boolean; // After delete / mark-unread in an open message, return to the list instead of opening the next message
showPreview: boolean;
mailLayout: MailLayout;
emailsPerPage: number;
externalContentPolicy: ExternalContentPolicy;
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
// Composer
autoSaveDraftInterval: number; // milliseconds
sendConfirmation: boolean;
defaultReplyMode: ReplyMode;
autoSelectReplyIdentity: boolean;
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
// Identities
// Per-account default ("preferred primary") sender identity, keyed by
// username (the same key settings sync uses). A JMAP identity id is only
// meaningful within its own account, so this must be account-scoped. Synced
// so the choice survives a new browser / cleared site data (#507).
preferredIdentityIds: Record<string, string | null>;
// Privacy & Security
sessionTimeout: number; // minutes (0 = never)
trustedSenders: string[]; // Email addresses that can load external content
trustedSendersAddressBook: boolean; // Store trusted senders in a dedicated JMAP address book
// Filters
expandedFilterView: boolean;
// Calendar
showTimeInMonthView: boolean;
showWeekNumbers: boolean;
calendarHoverPreview: CalendarHoverPreview;
// 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;
// Email Notifications
emailNotificationsEnabled: boolean;
emailNotificationSound: boolean;
notificationSoundChoice: NotificationSoundChoice;
// Protocol Handlers
protocolOpenMode: ProtocolOpenMode;
// Calendar Notifications
calendarNotificationsEnabled: boolean;
calendarNotificationSound: boolean;
calendarInvitationParsingEnabled: boolean;
// Layout
toolbarPosition: ToolbarPosition;
showToolbarLabels: boolean;
hideAccountSwitcher: boolean;
showRailAccountList: boolean;
proInterface: boolean;
// Unified Mailbox
enableUnifiedMailbox: boolean;
includeGroupInUnified: boolean;
// All Mail view (gated): user toggle (like the unified mailbox) plus the set
// of folder ids merged into the virtual "All Mail" mailbox. `null` = never
// configured, in which case the view defaults to all non-special (no-role)
// folders of the active account.
enableAllMailView: boolean;
// Cross-account "All accounts" views (gated per-view by the admin policy)
enableCrossUnreadView: boolean;
enableCrossStarredView: boolean;
enableCrossAllView: boolean;
// Per-account "All Mail" folder selection, keyed by AccountEntry.id. A
// missing entry = "not configured" -> defaults to every no-role folder; an
// explicit [] = "no folders". (Replaced the legacy global string[] | null.)
allMailFolderIds: 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
// Sidebar
colorfulSidebarIcons: boolean; // Tint folder icons by role (inbox blue, junk red, etc.)
tintListRowsByTag: boolean; // Tint mail-list rows by the first tag color
showFolderTotalCount: boolean; // Show total message count next to folders/tags (alongside unread)
// Folders
folderIcons: Record<string, string>; // mailboxId -> icon name
// Keywords (labels/tags)
emailKeywords: KeywordDefinition[];
// Attachment Reminder
attachmentReminderEnabled: boolean;
attachmentReminderKeywords: string[];
// 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;
// Actions
updateSetting: <K extends keyof SettingsState>(
key: K,
value: SettingsState[K]
) => void;
resetToDefaults: () => void;
exportSettings: () => string;
importSettings: (json: 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;
disableSync: () => void;
loadFromServer: (username: string, serverUrl: string) => Promise<boolean>;
}
const DEFAULT_SETTINGS = {
// Appearance
fontSize: 'medium' as FontSize,
density: 'regular' as Density,
animationsEnabled: true,
// Language & Region
dateFormat: 'smart' as DateFormat,
dateLocale: 'auto' as DateLocale,
timeFormat: '24h' as TimeFormat,
firstDayOfWeek: 1 as FirstDayOfWeek, // Monday
// Email Behavior
markAsReadDelay: 0, // Instant
deleteAction: 'trash' as DeleteAction,
permanentlyDeleteJunk: false,
returnToListAfterAction: true,
showPreview: true,
mailLayout: 'split' as MailLayout,
emailsPerPage: 50,
externalContentPolicy: 'ask' as ExternalContentPolicy,
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,
// Composer
autoSaveDraftInterval: 60000, // 1 minute
sendConfirmation: false,
defaultReplyMode: 'reply' as ReplyMode,
autoSelectReplyIdentity: false,
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,
// Identities
preferredIdentityIds: {} as Record<string, string | null>,
// Privacy & Security
sessionTimeout: 0, // Never
trustedSenders: [] as string[],
trustedSendersAddressBook: false,
// Filters
expandedFilterView: false,
// Calendar
showTimeInMonthView: false,
showWeekNumbers: false,
calendarHoverPreview: 'delay-500ms' as CalendarHoverPreview,
// Calendar Tasks
enableCalendarTasks: false,
showTasksOnCalendar: true,
// Contact Birthday Calendar
showBirthdayCalendar: false,
birthdayCalendarColor: '#eab308',
sharedCalendarColors: {} as Record<string, string>,
// Contacts Display
groupContactsByLetter: true,
// Email Notifications
emailNotificationsEnabled: true,
emailNotificationSound: true,
notificationSoundChoice: 'default' as NotificationSoundChoice,
// 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,
// Unified Mailbox
enableUnifiedMailbox: false,
includeGroupInUnified: false,
// All Mail view (gated)
enableAllMailView: false,
allMailFolderIds: {} as Record<string, string[]>,
enableCrossUnreadView: false,
enableCrossStarredView: false,
enableCrossAllView: false,
// Email Display
disableThreading: false,
senderFavicons: true,
showAvatarsInJunk: false,
// Sidebar
colorfulSidebarIcons: true,
tintListRowsByTag: true,
showFolderTotalCount: true,
// Folders
folderIcons: {} as Record<string, string>,
// Keywords
emailKeywords: DEFAULT_KEYWORDS,
// 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[],
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,
};
export const useSettingsStore = create<SettingsState>()(
persist(
(set, get) => ({
...DEFAULT_SETTINGS,
updateSetting: (key, value) => {
set({ [key]: value });
// 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);
}
// Apply animations to document root
if (key === 'animationsEnabled') {
applyAnimations(value as boolean);
}
},
resetToDefaults: () => {
set(DEFAULT_SETTINGS);
applyFontSize(DEFAULT_SETTINGS.fontSize);
applyDensity(DEFAULT_SETTINGS.density);
applyAnimations(DEFAULT_SETTINGS.animationsEnabled);
},
exportSettings: () => {
const state = get();
const settings = {
fontSize: state.fontSize,
density: state.density,
animationsEnabled: state.animationsEnabled,
dateFormat: state.dateFormat,
dateLocale: state.dateLocale,
timeFormat: state.timeFormat,
firstDayOfWeek: state.firstDayOfWeek,
markAsReadDelay: state.markAsReadDelay,
deleteAction: state.deleteAction,
returnToListAfterAction: state.returnToListAfterAction,
showPreview: state.showPreview,
mailLayout: state.mailLayout,
emailsPerPage: state.emailsPerPage,
externalContentPolicy: state.externalContentPolicy,
mailAttachmentAction: state.mailAttachmentAction,
attachmentPosition: state.attachmentPosition,
archiveMode: state.archiveMode,
hoverActions: state.hoverActions,
hoverActionsMode: state.hoverActionsMode,
hoverActionsCorner: state.hoverActionsCorner,
disableThreading: state.disableThreading,
trustedSenders: state.trustedSenders,
autoSaveDraftInterval: state.autoSaveDraftInterval,
sendConfirmation: state.sendConfirmation,
defaultReplyMode: state.defaultReplyMode,
autoSelectReplyIdentity: state.autoSelectReplyIdentity,
plainTextMode: state.plainTextMode,
rtlEditingSupport: state.rtlEditingSupport,
subAddressDelimiter: state.subAddressDelimiter,
sendDelaySeconds: state.sendDelaySeconds,
signaturePosition: state.signaturePosition,
signatureSeparatorEnabled: state.signatureSeparatorEnabled,
requestReadReceiptDefault: state.requestReadReceiptDefault,
readReceiptResponse: state.readReceiptResponse,
preferredIdentityIds: state.preferredIdentityIds,
sessionTimeout: state.sessionTimeout,
emailNotificationsEnabled: state.emailNotificationsEnabled,
emailNotificationSound: state.emailNotificationSound,
notificationSoundChoice: state.notificationSoundChoice,
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,
expandedFilterView: state.expandedFilterView,
showTimeInMonthView: state.showTimeInMonthView,
showWeekNumbers: state.showWeekNumbers,
calendarHoverPreview: state.calendarHoverPreview,
toolbarPosition: state.toolbarPosition,
hideAccountSwitcher: state.hideAccountSwitcher,
showRailAccountList: state.showRailAccountList,
// 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,
enableAllMailView: state.enableAllMailView,
allMailFolderIds: state.allMailFolderIds,
enableCrossUnreadView: state.enableCrossUnreadView,
enableCrossStarredView: state.enableCrossStarredView,
enableCrossAllView: state.enableCrossAllView,
senderFavicons: state.senderFavicons,
showAvatarsInJunk: state.showAvatarsInJunk,
colorfulSidebarIcons: state.colorfulSidebarIcons,
tintListRowsByTag: state.tintListRowsByTag,
showFolderTotalCount: state.showFolderTotalCount,
folderIcons: state.folderIcons,
emailKeywords: state.emailKeywords,
attachmentReminderEnabled: state.attachmentReminderEnabled,
attachmentReminderKeywords: state.attachmentReminderKeywords,
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,
// Cross-store settings
theme: useThemeStore.getState().theme,
locale: useLocaleStore.getState().locale,
};
return JSON.stringify(settings, null, 2);
},
importSettings: (json: 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
Object.keys(settings).forEach((key) => {
if (key in DEFAULT_SETTINGS) {
if (key === 'subAddressDelimiter' && !isValidSubAddressDelimiter(settings[key])) {
return;
}
if (key === 'sendDelaySeconds' && ![0, 10, 30, 60].includes(settings[key])) {
set({ sendDelaySeconds: 0 });
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;
}
// Defensive: a non-record (e.g. a legacy scalar) would break the
// per-account map lookups - ignore it.
if (key === 'preferredIdentityIds' && !isPlainRecord(settings[key])) {
return;
}
if (DEVICE_LOCAL_SETTING_KEYS.has(key)) {
return;
}
set({ [key]: settings[key] });
}
});
// Apply visual settings
applyFontSize(get().fontSize);
applyDensity(get().density);
applyAnimations(get().animationsEnabled);
// Apply cross-store settings
if (settings.theme) {
useThemeStore.getState().setTheme(settings.theme);
}
if (settings.locale) {
useLocaleStore.getState().setLocale(settings.locale);
}
return true;
} catch (error) {
console.error('Failed to import settings:', error);
return false;
}
},
// Folder icon methods
setFolderIcon: (mailboxId: string, icon: string) => {
set({ folderIcons: { ...get().folderIcons, [mailboxId]: icon } });
},
removeFolderIcon: (mailboxId: string) => {
const { [mailboxId]: _, ...rest } = get().folderIcons;
set({ folderIcons: rest });
},
// Shared-calendar color override methods
setSharedCalendarColor: (key: string, color: string) => {
set({ sharedCalendarColors: { ...get().sharedCalendarColors, [key]: color } });
},
removeSharedCalendarColor: (key: string) => {
const { [key]: _, ...rest } = get().sharedCalendarColors;
set({ 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] });
}
},
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();
set({
trustedSenders: get().trustedSenders.filter(e => e !== emailAddress)
});
},
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;
set({ sidebarApps: [...current, app] });
},
updateSidebarApp: (id: string, updates: Partial<Omit<SidebarApp, 'id'>>) => {
set({
sidebarApps: get().sidebarApps.map(a =>
a.id === id ? { ...a, ...updates } : a
),
});
},
removeSidebarApp: (id: string) => {
set({ sidebarApps: get().sidebarApps.filter(a => a.id !== id) });
},
reorderSidebarApps: (apps: SidebarApp[]) => {
set({ sidebarApps: apps });
},
// Settings sync methods
enableSync: (username: string, serverUrl: string) => {
syncUsername = username;
syncServerUrl = serverUrl;
syncEnabled = true;
syncLog('Settings sync enabled for', username);
},
disableSync: () => {
syncEnabled = false;
syncUsername = null;
syncServerUrl = null;
if (syncTimeout) {
clearTimeout(syncTimeout);
syncTimeout = null;
}
syncLog('Settings sync disabled');
},
loadFromServer: async (username: string, serverUrl: string) => {
try {
syncLog('Loading settings from server for', username);
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 false;
}
const { settings } = await res.json();
if (!settings) {
syncLog('No server settings found yet');
return false;
}
if (settings && typeof settings === 'object') {
isLoadingFromServer = true;
get().importSettings(JSON.stringify(settings));
isLoadingFromServer = false;
syncLog('Settings loaded from server successfully');
// Re-apply the (possibly server-updated) per-account preferred
// sender identity to the already-loaded identities, so a fresh
// browser reflects the synced default without waiting for the next
// identity refresh. Dynamic import avoids a static import cycle
// (auth-store imports this store). (#507)
import('./auth-store')
.then(({ useAuthStore }) => useAuthStore.getState().applyPreferredIdentityOrdering())
.catch(() => {});
return true;
}
return false;
} catch (error) {
syncError('Failed to load settings from server:', error);
isLoadingFromServer = false;
return false;
}
},
}),
{
name: 'settings-storage',
version: 5,
migrate: (persisted, version) => {
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 = {};
}
return state as unknown as SettingsState;
},
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 = {};
}
applyFontSize(state.fontSize);
applyDensity(state.density);
applyAnimations(state.animationsEnabled);
}
};
},
}
)
);
// 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]);
}
function applyDensity(density: Density) {
if (typeof document === 'undefined') return;
const root = document.documentElement;
const densityValues = {
'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',
},
};
const values = densityValues[density];
for (const [prop, val] of Object.entries(values)) {
root.style.setProperty(prop, val);
}
}
function applyAnimations(enabled: boolean) {
if (typeof document === 'undefined') return;
const root = document.documentElement;
if (enabled) {
root.style.removeProperty('--transition-duration');
} else {
root.style.setProperty('--transition-duration', '0s');
}
}
// Initialize settings on load
if (typeof window !== 'undefined') {
const store = useSettingsStore.getState();
applyFontSize(store.fontSize);
applyDensity(store.density);
applyAnimations(store.animationsEnabled);
// Shared sync function used by all store subscribers
const syncToServer = async (retries = 1): Promise<void> => {
const settings = JSON.parse(useSettingsStore.getState().exportSettings());
syncLog('Syncing settings to server...');
const res = await apiFetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: syncUsername, serverUrl: syncServerUrl, settings }),
});
if (res.status === 404) {
syncWarn('Settings sync endpoint returned 404, disabling sync');
syncEnabled = false;
} else if (res.status === 403) {
// Identity mismatch - current session cookies don't match the
// username/serverUrl we're syncing for (common in dev mock mode where
// no stalwart-context cookie is written, or when rememberMe is off).
// Retrying won't help for this session; disable to stop the noise.
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((r) => setTimeout(r, 2000));
return syncToServer(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');
}
};
const triggerSync = () => {
if (!syncEnabled || !syncUsername || !syncServerUrl || isLoadingFromServer) return;
if (syncTimeout) clearTimeout(syncTimeout);
syncTimeout = setTimeout(async () => {
try {
await syncToServer();
} catch (error) {
syncError('Settings sync error:', error);
}
}, SYNC_DEBOUNCE_MS);
};
// 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);
}