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)
This commit is contained in:
Bernd Rodler
2026-08-07 22:10:26 +02:00
parent 0ac429fe36
commit cfdd091d22
29 changed files with 1068 additions and 93 deletions
+33 -12
View File
@@ -11,18 +11,26 @@ 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>;
}
// Minimal snapshot shapes - we only capture what we need
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type StoreSnapshot = Record<string, any>;
type StoreData = Record<string, any>;
interface AccountSnapshot {
email: StoreSnapshot;
contact: StoreSnapshot;
calendar: StoreSnapshot;
filter: StoreSnapshot;
identity: StoreSnapshot;
vacation: StoreSnapshot;
email: StoreData;
contact: StoreData;
calendar: StoreData;
filter: StoreData;
identity: StoreData;
vacation: StoreData;
messageListTabs: StoreData;
tasks: StoreData;
}
const cache = new Map<string, AccountSnapshot>();
@@ -35,11 +43,9 @@ export function snapshotAccount(accountId: string): void {
const filterState = useFilterStore.getState();
const identityState = useIdentityStore.getState();
const vacationState = useVacationStore.getState();
const messageListTabsState = useMessageListTabsStore.getState();
const taskState = useTaskStore.getState();
// Copy the captured collections so the snapshot is decoupled from the live
// store: a later in-place mutation (e.g. an array push/splice, or stamping
// fields onto a shared email object) must not retroactively corrupt a
// snapshot taken earlier.
cache.set(accountId, {
email: {
emails: [...emailState.emails],
@@ -73,6 +79,17 @@ export function snapshotAccount(accountId: string): void {
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,
},
});
}
@@ -98,6 +115,8 @@ export function restoreAccount(accountId: string): boolean {
useFilterStore.setState(snapshot.filter);
useIdentityStore.setState(snapshot.identity);
useVacationStore.setState(snapshot.vacation);
useMessageListTabsStore.setState(snapshot.messageListTabs);
useTaskStore.setState(snapshot.tasks);
return true;
}
@@ -132,6 +151,8 @@ export function clearAllStores(): void {
useVacationStore.getState().clearState();
useCalendarStore.getState().clearState();
useFilterStore.getState().clearState();
useMessageListTabsStore.getState().clearState();
useTaskStore.getState().clearTasks();
}
/** Evict cached state for one account */