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
+38 -9
View File
@@ -5,6 +5,8 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { generateUUID } from '@/lib/utils';
import { debug } from '@/lib/debug';
import { getClientByLocalAccountId } from './client-registry';
import { enqueueOperation, isNetworkError } from '@/lib/offline-write-queue';
import { useAccountStore } from '@/stores/account-store';
/** One connected JMAP account for contact multi-account aggregation. */
export interface ContactAccountClient {
@@ -375,12 +377,12 @@ export const useContactStore = create<ContactStore>()(
createContact: async (client, contact) => {
set({ isLoading: true, error: null });
let accountId: string | undefined = contact.isShared ? contact.accountId : undefined;
let cleanedContact = contact;
try {
// Determine target account from the selected address book. Also
// pin the local account so we route through the right server's
// client in multi-account Pro mode.
let accountId = contact.isShared ? contact.accountId : undefined;
let cleanedContact = contact;
let localAccountId = contact.localAccountId;
// De-namespace addressBookIds if they reference a shared address book
@@ -424,6 +426,12 @@ export const useContactStore = create<ContactStore>()(
}));
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to create contact';
if (isNetworkError(error)) {
const queueAccountId = accountId || useAccountStore.getState().activeAccountId;
if (queueAccountId) {
enqueueOperation({ type: 'createContact', accountId: queueAccountId, payload: cleanedContact });
}
}
set({ error: msg, isLoading: false });
throw error;
}
@@ -431,14 +439,14 @@ export const useContactStore = create<ContactStore>()(
updateContact: async (client, id, updates) => {
set({ error: null });
const contact = get().contacts.find(c => c.id === id);
const originalId = contact?.originalId || stripLocalAccountPrefix(id, contact?.localAccountId);
const accountId = contact?.isShared ? contact.accountId : undefined;
let cleanedUpdates = updates;
try {
const contact = get().contacts.find(c => c.id === id);
const originalId = contact?.originalId || stripLocalAccountPrefix(id, contact?.localAccountId);
const accountId = contact?.isShared ? contact.accountId : undefined;
client = resolveAccountClient(client, contact?.localAccountId);
// De-namespace addressBookIds for shared contacts before sending to JMAP server
let cleanedUpdates = updates;
if (contact?.isShared && contact?.accountId && updates.addressBookIds) {
const prefix = `${contact.accountId}:`;
const deNamespaced = Object.fromEntries(
@@ -458,6 +466,12 @@ export const useContactStore = create<ContactStore>()(
}));
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to update contact';
if (isNetworkError(error)) {
const queueAccountId = accountId || useAccountStore.getState().activeAccountId;
if (queueAccountId) {
enqueueOperation({ type: 'updateContact', accountId: queueAccountId, payload: { id: originalId, updates: cleanedUpdates } });
}
}
set({ error: msg });
throw error;
}
@@ -465,10 +479,10 @@ export const useContactStore = create<ContactStore>()(
deleteContact: async (client, id) => {
set({ error: null });
const contact = get().contacts.find(c => c.id === id);
const originalId = contact?.originalId || stripLocalAccountPrefix(id, contact?.localAccountId);
const accountId = contact?.isShared ? contact.accountId : undefined;
try {
const contact = get().contacts.find(c => c.id === id);
const originalId = contact?.originalId || stripLocalAccountPrefix(id, contact?.localAccountId);
const accountId = contact?.isShared ? contact.accountId : undefined;
client = resolveAccountClient(client, contact?.localAccountId);
await client.deleteContact(originalId, accountId);
set((state) => {
@@ -481,6 +495,12 @@ export const useContactStore = create<ContactStore>()(
});
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to delete contact';
if (isNetworkError(error)) {
const queueAccountId = accountId || useAccountStore.getState().activeAccountId;
if (queueAccountId) {
enqueueOperation({ type: 'deleteContact', accountId: queueAccountId, payload: { id: originalId, targetAccountId: accountId } });
}
}
set({ error: msg });
throw error;
}
@@ -1143,4 +1163,13 @@ export const useContactStore = create<ContactStore>()(
)
);
import { registerPushHandler } from '@/lib/push-event-bus';
registerPushHandler('ContactCard', async (client) => {
const store = useContactStore.getState();
store.fetchContacts(client).catch((err) => {
console.error('Failed to refresh contacts on push:', err);
});
});
export type { ContactName };