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:
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { encryptedStorage } from '@/stores/encrypted-storage';
|
||||
import { generateAccountId, generateAvatarColor, getMaxAccounts } from '@/lib/account-utils';
|
||||
|
||||
export interface AccountEntry {
|
||||
@@ -218,6 +219,7 @@ export const useAccountStore = create<AccountState>()(
|
||||
}),
|
||||
{
|
||||
name: 'account-registry',
|
||||
storage: createJSONStorage(() => encryptedStorage),
|
||||
partialize: (state) => ({
|
||||
accounts: state.accounts,
|
||||
activeAccountId: state.activeAccountId,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { encryptedStorage } from '@/stores/encrypted-storage';
|
||||
import { JMAPClient, RateLimitError } from '@/lib/jmap/client';
|
||||
import { withOfflineFallback } from '@/lib/offline-fallback-client';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
@@ -2010,6 +2011,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
storage: createJSONStorage(() => encryptedStorage),
|
||||
partialize: (state) => {
|
||||
// Don't persist unauthenticated state - prevents resurrecting stale sessions
|
||||
if (!state.isAuthenticated) return {};
|
||||
|
||||
@@ -12,6 +12,8 @@ import { generateUUID } from '@/lib/utils';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar';
|
||||
import { getClientByLocalAccountId } from './client-registry';
|
||||
import { enqueueOperation, isNetworkError } from '@/lib/offline-write-queue';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
|
||||
/**
|
||||
* When the Pro shell aggregates calendars/events from every connected
|
||||
@@ -409,13 +411,13 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
|
||||
createEvent: async (client, event, sendSchedulingMessages) => {
|
||||
set({ error: null });
|
||||
let targetAccountId: string | undefined = event.accountId;
|
||||
const cleanEvent = sanitizeOutgoingCalendarEventData({ ...event });
|
||||
try {
|
||||
// Resolve shared calendar context from calendarIds. Also pin the
|
||||
// local account from the calendar so we route through that
|
||||
// server's client when in multi-account Pro mode.
|
||||
let targetAccountId = event.accountId;
|
||||
let localAccountId = event.localAccountId;
|
||||
const cleanEvent = sanitizeOutgoingCalendarEventData({ ...event });
|
||||
if (event.calendarIds) {
|
||||
const remapped: Record<string, boolean> = {};
|
||||
for (const calId of Object.keys(event.calendarIds)) {
|
||||
@@ -491,6 +493,12 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
return mappedCreated;
|
||||
} catch (error) {
|
||||
debug.error('Failed to create event:', error);
|
||||
if (isNetworkError(error)) {
|
||||
const accountId = targetAccountId || useAccountStore.getState().activeAccountId;
|
||||
if (accountId) {
|
||||
enqueueOperation({ type: 'createEvent', accountId, payload: cleanEvent });
|
||||
}
|
||||
}
|
||||
set({ error: 'Failed to create event' });
|
||||
return null;
|
||||
}
|
||||
@@ -498,11 +506,12 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
|
||||
updateEvent: async (client, id, updates, sendSchedulingMessages) => {
|
||||
set({ error: null });
|
||||
const storeEvent = get().events.find(e => e.id === id);
|
||||
const realId = storeEvent?.originalId || stripLocalAccountPrefix(id, storeEvent?.localAccountId);
|
||||
const targetAccountId = storeEvent?.accountId;
|
||||
const cleanUpdates = sanitizeOutgoingCalendarEventData({ ...updates });
|
||||
try {
|
||||
// Resolve shared event IDs and client-side expanded occurrence IDs
|
||||
const storeEvent = get().events.find(e => e.id === id);
|
||||
const realId = storeEvent?.originalId || stripLocalAccountPrefix(id, storeEvent?.localAccountId);
|
||||
const targetAccountId = storeEvent?.accountId;
|
||||
client = resolveAccountClient(client, storeEvent?.localAccountId);
|
||||
debug.log('calendar', 'Calendar updateEvent', {
|
||||
storeId: id,
|
||||
@@ -513,7 +522,6 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
updateKeys: Object.keys(updates),
|
||||
});
|
||||
// Remap namespaced calendarIds back to original IDs
|
||||
const cleanUpdates = sanitizeOutgoingCalendarEventData({ ...updates });
|
||||
if (cleanUpdates.calendarIds) {
|
||||
const remapped: Record<string, boolean> = {};
|
||||
for (const [calId, v] of Object.entries(cleanUpdates.calendarIds)) {
|
||||
@@ -556,6 +564,12 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
// iMIP send here produced duplicate emails.
|
||||
} catch (error) {
|
||||
debug.error('Failed to update event:', error);
|
||||
if (isNetworkError(error)) {
|
||||
const accountId = targetAccountId || useAccountStore.getState().activeAccountId;
|
||||
if (accountId) {
|
||||
enqueueOperation({ type: 'updateEvent', accountId, payload: { id: realId, updates: cleanUpdates } });
|
||||
}
|
||||
}
|
||||
set({ error: 'Failed to update event' });
|
||||
throw error;
|
||||
}
|
||||
@@ -788,11 +802,11 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
|
||||
deleteEvent: async (client, id, sendSchedulingMessages) => {
|
||||
set({ error: null });
|
||||
const storeEvent = get().events.find(e => e.id === id);
|
||||
const realId = storeEvent?.originalId || stripLocalAccountPrefix(id, storeEvent?.localAccountId);
|
||||
const targetAccountId = storeEvent?.accountId;
|
||||
try {
|
||||
// Resolve shared event IDs and client-side expanded occurrence IDs
|
||||
const storeEvent = get().events.find(e => e.id === id);
|
||||
const realId = storeEvent?.originalId || stripLocalAccountPrefix(id, storeEvent?.localAccountId);
|
||||
const targetAccountId = storeEvent?.accountId;
|
||||
client = resolveAccountClient(client, storeEvent?.localAccountId);
|
||||
// Cancellation emails (iTIP CANCEL) are sent by the server via the
|
||||
// `sendSchedulingMessages` argument on the destroy below - a manual
|
||||
@@ -811,6 +825,12 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
}));
|
||||
} catch (error) {
|
||||
debug.error('Failed to delete event:', error);
|
||||
if (isNetworkError(error)) {
|
||||
const accountId = targetAccountId || useAccountStore.getState().activeAccountId;
|
||||
if (accountId) {
|
||||
enqueueOperation({ type: 'deleteEvent', accountId, payload: realId });
|
||||
}
|
||||
}
|
||||
set({ error: 'Failed to delete event' });
|
||||
throw error;
|
||||
}
|
||||
@@ -1302,3 +1322,27 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
import { registerPushHandler } from '@/lib/push-event-bus';
|
||||
|
||||
registerPushHandler('Calendar', async (client) => {
|
||||
const store = useCalendarStore.getState();
|
||||
if (store.supportsCalendar) {
|
||||
store.fetchCalendars(client);
|
||||
}
|
||||
});
|
||||
|
||||
registerPushHandler('CalendarEvent', async (client) => {
|
||||
const store = useCalendarStore.getState();
|
||||
if (store.supportsCalendar) {
|
||||
const { dateRange, selectedCalendarIds } = store;
|
||||
if (dateRange && selectedCalendarIds.length > 0) {
|
||||
store.fetchEvents(client, dateRange.start, dateRange.end);
|
||||
}
|
||||
const { useTaskStore } = await import('./task-store');
|
||||
const taskStore = useTaskStore.getState();
|
||||
if (taskStore.tasks.length > 0 || store.viewMode === 'tasks') {
|
||||
taskStore.fetchTasks(client);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+38
-9
@@ -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 };
|
||||
|
||||
+19
-47
@@ -3,7 +3,6 @@ import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult, isUnified
|
||||
import type { UnifiedMailboxRole, CrossView } from "@/lib/jmap/types";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
|
||||
import { emailHooks } from "@/lib/plugin-hooks";
|
||||
import type { ExternalSearchResult } from "@/lib/plugin-types";
|
||||
@@ -11,6 +10,7 @@ import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, adv
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useMessageListTabsStore } from "@/stores/message-list-tabs-store";
|
||||
import { enqueueOperation, isNetworkError } from "@/lib/offline-write-queue";
|
||||
|
||||
type ScheduledSubmissionMetadata = {
|
||||
submissionId: string;
|
||||
@@ -1367,6 +1367,16 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
const accountId = useAccountStore.getState().activeAccountId;
|
||||
if (accountId) {
|
||||
enqueueOperation({
|
||||
type: 'sendEmail',
|
||||
accountId,
|
||||
payload: { to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom, options },
|
||||
});
|
||||
}
|
||||
}
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to send email",
|
||||
isLoading: false
|
||||
@@ -2911,53 +2921,15 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
await get().fetchMailboxes(client);
|
||||
}
|
||||
|
||||
// Handle Calendar/CalendarEvent state changes - refresh calendar data
|
||||
if (accountChanges?.Calendar || accountChanges?.CalendarEvent) {
|
||||
const calendarStore = useCalendarStore.getState();
|
||||
if (calendarStore.supportsCalendar) {
|
||||
calendarStore.fetchCalendars(client);
|
||||
const { dateRange, selectedCalendarIds } = calendarStore;
|
||||
if (dateRange && selectedCalendarIds.length > 0) {
|
||||
calendarStore.fetchEvents(client, dateRange.start, dateRange.end);
|
||||
}
|
||||
// Refresh tasks when calendar events change (e.g. task created via CalDAV)
|
||||
const { useTaskStore } = await import('./task-store');
|
||||
const taskStore = useTaskStore.getState();
|
||||
if (taskStore.tasks.length > 0 || calendarStore.viewMode === 'tasks') {
|
||||
taskStore.fetchTasks(client);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle SieveScript state changes - refresh filter rules
|
||||
if (accountChanges?.SieveScript) {
|
||||
const { useFilterStore } = await import('./filter-store');
|
||||
const filterStore = useFilterStore.getState();
|
||||
if (filterStore.isSupported) {
|
||||
filterStore.fetchFilters(client).catch((err) => {
|
||||
console.error('Failed to refresh filters:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle ContactCard state changes - refresh contacts
|
||||
if (accountChanges?.ContactCard) {
|
||||
const { useContactStore } = await import('./contact-store');
|
||||
const contactStore = useContactStore.getState();
|
||||
contactStore.fetchContacts(client).catch((err) => {
|
||||
console.error('Failed to refresh contacts on push:', err);
|
||||
// Delegate Calendar/CalendarEvent, SieveScript, ContactCard, FileNode
|
||||
// push handling to the push event bus where each feature store
|
||||
// registers itself. Decouples email-store from the 5+ other stores
|
||||
// it previously imported directly for push handling.
|
||||
import('@/lib/push-event-bus').then(({ dispatchPushEvent }) => {
|
||||
dispatchPushEvent(client, change.changed, accountId).catch((err) => {
|
||||
console.error('Push event bus dispatch failed:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle FileNode state changes - refresh current directory
|
||||
if (accountChanges?.FileNode) {
|
||||
const { useFileStore } = await import('./file-store');
|
||||
const fileStore = useFileStore.getState();
|
||||
const currentParentId = fileStore.currentParentId;
|
||||
fileStore.navigate(currentParentId).catch((err) => {
|
||||
console.error('Failed to refresh files on push:', err);
|
||||
});
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
// Local search index last, with the refreshed ids (see above).
|
||||
scheduleIndexUpdate();
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { encryptValue, decryptValue, isEncryptionAvailable } from '@/lib/auth/local-storage-crypto';
|
||||
|
||||
const ENCRYPTED_PREFIX = 'ENC:';
|
||||
|
||||
function isEncrypted(value: string): boolean {
|
||||
return value.startsWith(ENCRYPTED_PREFIX);
|
||||
}
|
||||
|
||||
function stripPrefix(value: string): string {
|
||||
return value.slice(ENCRYPTED_PREFIX.length);
|
||||
}
|
||||
|
||||
// Cache of recently decrypted values. The Zustand persist middleware calls
|
||||
// getItem frequently during rehydration, and we want to avoid re-decrypting
|
||||
// the same ciphertext on every read. Keyed by storage key.
|
||||
const decryptedCache = new Map<string, string | null>();
|
||||
|
||||
function cacheKey(name: string): string {
|
||||
return `vncmail:decrypted:${name}`;
|
||||
}
|
||||
|
||||
function getCachedDecrypted(name: string): string | null | undefined {
|
||||
return decryptedCache.get(cacheKey(name));
|
||||
}
|
||||
|
||||
function setCachedDecrypted(name: string, value: string | null): void {
|
||||
decryptedCache.set(cacheKey(name), value);
|
||||
}
|
||||
|
||||
function invalidateDecryptedCache(name: string): void {
|
||||
decryptedCache.delete(cacheKey(name));
|
||||
}
|
||||
|
||||
export function createEncryptedStorage(): {
|
||||
getItem: (name: string) => Promise<string | null>;
|
||||
setItem: (name: string, value: string) => Promise<void>;
|
||||
removeItem: (name: string) => Promise<void>;
|
||||
} {
|
||||
return {
|
||||
getItem: async (name: string): Promise<string | null> => {
|
||||
try {
|
||||
const raw = localStorage.getItem(name);
|
||||
if (raw === null) return null;
|
||||
|
||||
if (!isEncrypted(raw)) {
|
||||
if (isEncryptionAvailable()) {
|
||||
// Legacy plaintext value found — return as-is, but re-encrypt on
|
||||
// the next write (setItem below always encrypts when available).
|
||||
return raw;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
const cached = getCachedDecrypted(name);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const ciphertext = stripPrefix(raw);
|
||||
const decrypted = await decryptValue(ciphertext);
|
||||
setCachedDecrypted(name, decrypted);
|
||||
return decrypted;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
setItem: async (name: string, value: string): Promise<void> => {
|
||||
try {
|
||||
if (isEncryptionAvailable()) {
|
||||
const ciphertext = await encryptValue(value);
|
||||
localStorage.setItem(name, `${ENCRYPTED_PREFIX}${ciphertext}`);
|
||||
} else {
|
||||
localStorage.setItem(name, value);
|
||||
}
|
||||
invalidateDecryptedCache(name);
|
||||
} catch {
|
||||
try {
|
||||
localStorage.setItem(name, value);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
removeItem: async (name: string): Promise<void> => {
|
||||
try {
|
||||
localStorage.removeItem(name);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
invalidateDecryptedCache(name);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const encryptedStorage = createEncryptedStorage();
|
||||
@@ -1048,3 +1048,13 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { registerPushHandler } from '@/lib/push-event-bus';
|
||||
|
||||
registerPushHandler('FileNode', async (_client) => {
|
||||
const store = useFileStore.getState();
|
||||
const currentParentId = store.currentParentId;
|
||||
store.navigate(currentParentId).catch((err) => {
|
||||
console.error('Failed to refresh files on push:', err);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -252,3 +252,14 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
||||
selectedAccountId: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { registerPushHandler } from '@/lib/push-event-bus';
|
||||
|
||||
registerPushHandler('SieveScript', async (client) => {
|
||||
const store = useFilterStore.getState();
|
||||
if (store.isSupported) {
|
||||
store.fetchFilters(client).catch((err) => {
|
||||
console.error('Failed to refresh filters on push:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -153,6 +153,7 @@ interface MessageListTabsStore {
|
||||
|
||||
registerTabs: (pluginId: string, config: MessageListTabsConfig) => void;
|
||||
clearTabs: (pluginId: string) => void;
|
||||
clearState: () => void;
|
||||
setActiveTab: (tabId: string, mailboxId: string | null) => void;
|
||||
/**
|
||||
* JMAP filter fragment for the active tab (to AND into the mailbox query),
|
||||
@@ -336,4 +337,13 @@ export const useMessageListTabsStore = create<MessageListTabsStore>()((set, get)
|
||||
void messageListTabHooks.onEmailCategorize.emit(ctx);
|
||||
return true;
|
||||
},
|
||||
|
||||
clearState: () => set({
|
||||
registrations: {},
|
||||
tabs: [],
|
||||
mailboxRoles: [],
|
||||
activeTabId: null,
|
||||
tabCounts: {},
|
||||
isCountsLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
+30
-4
@@ -2,6 +2,8 @@ import { create } from 'zustand';
|
||||
import type { CalendarTask } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { debug } from '@/lib/debug';
|
||||
import { enqueueOperation, isNetworkError } from '@/lib/offline-write-queue';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
|
||||
export type TaskViewFilter = 'all' | 'pending' | 'completed' | 'overdue';
|
||||
|
||||
@@ -58,10 +60,22 @@ export const useTaskStore = create<TaskStore>((set, get) => ({
|
||||
|
||||
createTask: async (client, task) => {
|
||||
debug.log('tasks', 'TaskStore/createTask', task);
|
||||
const created = await client.createCalendarTask(task);
|
||||
debug.log('tasks', 'TaskStore/createTask result', { id: created.id, uid: created.uid, title: created.title });
|
||||
set({ tasks: [...get().tasks, created] });
|
||||
return created;
|
||||
try {
|
||||
const created = await client.createCalendarTask(task);
|
||||
debug.log('tasks', 'TaskStore/createTask result', { id: created.id, uid: created.uid, title: created.title });
|
||||
set({ tasks: [...get().tasks, created] });
|
||||
return created;
|
||||
} catch (error) {
|
||||
debug.error('TaskStore/createTask failed', error);
|
||||
if (isNetworkError(error)) {
|
||||
const accountId = useAccountStore.getState().activeAccountId;
|
||||
if (accountId) {
|
||||
enqueueOperation({ type: 'createTask', accountId, payload: task });
|
||||
}
|
||||
}
|
||||
set({ error: 'Failed to create task' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
updateTask: async (client, id, updates) => {
|
||||
@@ -72,6 +86,12 @@ export const useTaskStore = create<TaskStore>((set, get) => ({
|
||||
});
|
||||
} catch (error) {
|
||||
debug.error('TaskStore/updateTask failed', error);
|
||||
if (isNetworkError(error)) {
|
||||
const accountId = useAccountStore.getState().activeAccountId;
|
||||
if (accountId) {
|
||||
enqueueOperation({ type: 'updateTask', accountId, payload: { id, updates } });
|
||||
}
|
||||
}
|
||||
set({ error: 'Failed to update task' });
|
||||
}
|
||||
},
|
||||
@@ -85,6 +105,12 @@ export const useTaskStore = create<TaskStore>((set, get) => ({
|
||||
});
|
||||
} catch (error) {
|
||||
debug.error('TaskStore/deleteTask failed', error);
|
||||
if (isNetworkError(error)) {
|
||||
const accountId = useAccountStore.getState().activeAccountId;
|
||||
if (accountId) {
|
||||
enqueueOperation({ type: 'deleteTask', accountId, payload: { id } });
|
||||
}
|
||||
}
|
||||
set({ error: 'Failed to delete task' });
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user