Files
SRCmail/lib/account-state-manager.ts
Bernd Rodler cfdd091d22 feat: Phase 3+4 — security hardening + polish + offline + Electron push
Phase 3 (security):
- P3.1: Feature gate server-side enforcement (403 on disabled features)
- P3.2: Unified auth error interceptor (401→logout)
- P3.3: Store-level state isolation via StoreSnapshot contract
  (added message-list-tabs + task stores to snapshot/restore cycle)
- P3.4: Push event bus extraction — email-store no longer imports
  calendar/contact/filter/file stores directly
- P1.3: Auth localStorage AES-GCM encryption via custom Zustand adapter

Phase 4 (polish):
- P4.1: Offline write queue — pending operations in localStorage,
  auto-retry on reconnect, offline-queue-indicator banner
- P4.2: Identity spoofing — fromOverrideEmail domain validation
- P4.3: WebSocket push for Electron via main-process IPC bridge
  (ws package with Authorization headers)
2026-08-07 22:10:26 +02:00

167 lines
5.4 KiB
TypeScript

/**
* Manages per-account state snapshots for fast switching.
* When user switches from Account A → B, we snapshot A's store state
* into memory, clear stores, then restore B's cached state.
*/
import { useEmailStore } from '@/stores/email-store';
import { useContactStore } from '@/stores/contact-store';
import { useCalendarStore } from '@/stores/calendar-store';
import { useFilterStore } from '@/stores/filter-store';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
import { useIdentityStore } from '@/stores/identity-store';
import { useVacationStore } from '@/stores/vacation-store';
import { useMessageListTabsStore } from '@/stores/message-list-tabs-store';
import { useTaskStore } from '@/stores/task-store';
export interface StoreSnapshot<S> {
snapshot: () => Partial<S>;
clear: () => Partial<S>;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type StoreData = Record<string, any>;
interface AccountSnapshot {
email: StoreData;
contact: StoreData;
calendar: StoreData;
filter: StoreData;
identity: StoreData;
vacation: StoreData;
messageListTabs: StoreData;
tasks: StoreData;
}
const cache = new Map<string, AccountSnapshot>();
/** Capture current store states for the given account */
export function snapshotAccount(accountId: string): void {
const emailState = useEmailStore.getState();
const contactState = useContactStore.getState();
const calendarState = useCalendarStore.getState();
const filterState = useFilterStore.getState();
const identityState = useIdentityStore.getState();
const vacationState = useVacationStore.getState();
const messageListTabsState = useMessageListTabsStore.getState();
const taskState = useTaskStore.getState();
cache.set(accountId, {
email: {
emails: [...emailState.emails],
mailboxes: [...emailState.mailboxes],
selectedEmail: emailState.selectedEmail,
selectedMailbox: emailState.selectedMailbox,
searchQuery: emailState.searchQuery,
quota: emailState.quota ? { ...emailState.quota } : emailState.quota,
},
contact: {
contacts: [...contactState.contacts],
addressBooks: [...contactState.addressBooks],
supportsSync: contactState.supportsSync,
},
calendar: {
calendars: [...calendarState.calendars],
events: [...calendarState.events],
selectedCalendarIds: [...calendarState.selectedCalendarIds],
viewMode: calendarState.viewMode,
supportsCalendar: calendarState.supportsCalendar,
},
filter: {
rules: [...filterState.rules],
isSupported: filterState.isSupported,
},
identity: {
identities: [...identityState.identities],
preferredPrimaryId: identityState.preferredPrimaryId,
},
vacation: {
isEnabled: vacationState.isEnabled,
isSupported: vacationState.isSupported,
},
messageListTabs: {
registrations: { ...messageListTabsState.registrations },
tabs: [...messageListTabsState.tabs],
activeTabId: messageListTabsState.activeTabId,
},
tasks: {
tasks: [...taskState.tasks],
selectedTaskId: taskState.selectedTaskId,
filter: taskState.filter,
showCompleted: taskState.showCompleted,
},
});
}
/**
* Restore cached store states for the given account. Returns false if no cache
* exists.
*
* The snapshot only captures a subset of each store's fields (the loaded data),
* so we reset every store to its baseline first. Without this, fields outside
* the captured subset (e.g. email selection, loading flags, tag counts) would
* carry over from whatever account was active, leaking state across accounts.
* `setState` merges, so the captured fields are then layered back on top.
*/
export function restoreAccount(accountId: string): boolean {
const snapshot = cache.get(accountId);
if (!snapshot) return false;
clearAllStores();
useEmailStore.setState(snapshot.email);
useContactStore.setState(snapshot.contact);
useCalendarStore.setState(snapshot.calendar);
useFilterStore.setState(snapshot.filter);
useIdentityStore.setState(snapshot.identity);
useVacationStore.setState(snapshot.vacation);
useMessageListTabsStore.setState(snapshot.messageListTabs);
useTaskStore.setState(snapshot.tasks);
return true;
}
/** Clear all stores (used before restoring a different account) */
export function clearAllStores(): void {
useEmailStore.setState({
emails: [],
mailboxes: [],
selectedEmail: null,
selectedMailbox: '',
isLoading: false,
error: null,
searchQuery: '',
quota: null,
isPushConnected: false,
lastPushUpdate: null,
newEmailNotification: null,
selectedEmailIds: new Set<string>(),
hasMoreEmails: false,
totalEmails: 0,
expandedThreadIds: new Set<string>(),
threadEmailsCache: new Map(),
isLoadingThread: null,
selectedKeyword: null,
tagCounts: {},
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
isAdvancedSearchOpen: false,
});
useIdentityStore.getState().clearIdentities();
useContactStore.getState().clearContacts();
useVacationStore.getState().clearState();
useCalendarStore.getState().clearState();
useFilterStore.getState().clearState();
useMessageListTabsStore.getState().clearState();
useTaskStore.getState().clearTasks();
}
/** Evict cached state for one account */
export function evictAccount(accountId: string): void {
cache.delete(accountId);
}
/** Evict all cached states */
export function evictAll(): void {
cache.clear();
}