Previously tags where very much focused on color coding email and less about adding additional information. They were also visualized in different ways in different locations. This commit gets rid of all "Color-coding" references, aligns visualization of the tags across the whole project and tries to improve user experience of using tags in general. A search box is shown in the tagging control so the user can quickly search for a tag if they have a huge (more than 10) amount of tags.
1211 lines
52 KiB
TypeScript
1211 lines
52 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';
|
|
|
|
/**
|
|
* 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';
|
|
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' },
|
|
];
|
|
|
|
/** 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
|
|
}
|
|
|
|
/** 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' },
|
|
];
|
|
|
|
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;
|
|
messageSpacing: MessageSpacing; // Gutter around the message body 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
|
|
|
|
// 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
|
|
|
|
// 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;
|
|
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;
|
|
// 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
|
|
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[];
|
|
nestedTags: boolean; // Treat "/" in a tag id as a parent/child separator
|
|
|
|
// 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,
|
|
messageSpacing: 'auto' as MessageSpacing,
|
|
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,
|
|
|
|
// Privacy & Security
|
|
sessionTimeout: 0, // Never
|
|
trustedSenders: [] as string[],
|
|
trustedSendersAddressBook: null as boolean | null,
|
|
|
|
// 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: 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,
|
|
showFolderTotalCount: true,
|
|
|
|
// Folders
|
|
folderIcons: {} as Record<string, string>,
|
|
|
|
// Keywords
|
|
emailKeywords: DEFAULT_KEYWORDS,
|
|
nestedTags: false,
|
|
|
|
// 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,
|
|
messageSpacing: state.messageSpacing,
|
|
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,
|
|
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,
|
|
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,
|
|
showFolderTotalCount: state.showFolderTotalCount,
|
|
folderIcons: state.folderIcons,
|
|
emailKeywords: state.emailKeywords,
|
|
nestedTags: state.nestedTags,
|
|
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;
|
|
}
|
|
// 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 (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');
|
|
// 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 false;
|
|
}
|
|
},
|
|
}),
|
|
{
|
|
name: 'settings-storage',
|
|
version: 7,
|
|
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 = {};
|
|
}
|
|
applyFontSize(state.fontSize);
|
|
applyDensity(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 = {};
|
|
}
|
|
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]);
|
|
}
|
|
|
|
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);
|
|
}
|