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:
@@ -6,6 +6,7 @@ import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization';
|
|||||||
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
||||||
import { parseISO } from 'date-fns';
|
import { parseISO } from 'date-fns';
|
||||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||||
|
import { isFeatureEnabledServer } from '@/lib/admin/feature-gate';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/calendar-agenda
|
* POST /api/calendar-agenda
|
||||||
@@ -100,6 +101,10 @@ function firstCalendarId(event: Partial<CalendarEvent>): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
if (!isFeatureEnabledServer('calendarEnabled')) {
|
||||||
|
return NextResponse.json({ error: 'Feature disabled' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const creds = await getStalwartCredentials(request);
|
const creds = await getStalwartCredentials(request);
|
||||||
if (!creds) {
|
if (!creds) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from '@/lib/mail-index/reindex';
|
} from '@/lib/mail-index/reindex';
|
||||||
import { CONTENT_TYPES, isContentType, type ContentType } from '@/lib/mail-index/store';
|
import { CONTENT_TYPES, isContentType, type ContentType } from '@/lib/mail-index/store';
|
||||||
import { JmapIndexError } from '@/lib/mail-index/jmap';
|
import { JmapIndexError } from '@/lib/mail-index/jmap';
|
||||||
|
import { isFeatureEnabledServer } from '@/lib/admin/feature-gate';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -40,6 +41,10 @@ function parseIdMap(raw: unknown): Partial<Record<ContentType, string[]>> | unde
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
if (!isFeatureEnabledServer('aiAssistantEnabled')) {
|
||||||
|
return NextResponse.json({ error: 'Feature disabled' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
if (!getStoreDir()) {
|
if (!getStoreDir()) {
|
||||||
return new NextResponse(null, { status: 404 });
|
return new NextResponse(null, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry
|
|||||||
import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
import { isFeatureEnabledServer } from '@/lib/admin/feature-gate';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes
|
* GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes
|
||||||
@@ -11,6 +12,10 @@ import { logger } from '@/lib/logger';
|
|||||||
* No admin auth required - this is how regular users receive plugins/themes.
|
* No admin auth required - this is how regular users receive plugins/themes.
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
if (!isFeatureEnabledServer('pluginsEnabled')) {
|
||||||
|
return NextResponse.json({ error: 'Feature disabled' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await configManager.ensureLoaded();
|
await configManager.ensureLoaded();
|
||||||
const policy = configManager.getPolicy();
|
const policy = configManager.getPolicy();
|
||||||
|
|||||||
@@ -15,12 +15,17 @@ import { NextResponse } from 'next/server';
|
|||||||
import { readStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
import { readStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||||
import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
|
import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
|
||||||
import { CaError, getCaProvider } from '@/lib/smime-ca';
|
import { CaError, getCaProvider } from '@/lib/smime-ca';
|
||||||
|
import { isFeatureEnabledServer } from '@/lib/admin/feature-gate';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
const MAX_CSR_BYTES = 8 * 1024;
|
const MAX_CSR_BYTES = 8 * 1024;
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
if (!isFeatureEnabledServer('smimeEnabled')) {
|
||||||
|
return NextResponse.json({ error: 'Feature disabled' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
const provider = getCaProvider();
|
const provider = getCaProvider();
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -569,6 +569,34 @@ export function EmailComposer({
|
|||||||
const [fromOverrideEnabled, setFromOverrideEnabled] = useState<boolean>(initialData?.fromOverrideEnabled ?? false);
|
const [fromOverrideEnabled, setFromOverrideEnabled] = useState<boolean>(initialData?.fromOverrideEnabled ?? false);
|
||||||
const [fromOverrideEmail, setFromOverrideEmail] = useState<string>(initialData?.fromOverrideEmail ?? '');
|
const [fromOverrideEmail, setFromOverrideEmail] = useState<string>(initialData?.fromOverrideEmail ?? '');
|
||||||
const [fromOverrideName, setFromOverrideName] = useState<string>(initialData?.fromOverrideName ?? '');
|
const [fromOverrideName, setFromOverrideName] = useState<string>(initialData?.fromOverrideName ?? '');
|
||||||
|
const [fromOverrideWarning, setFromOverrideWarning] = useState<string>('');
|
||||||
|
|
||||||
|
// Validate that from override domain matches at least one of the user's identities
|
||||||
|
const ownIdentityDomains = useMemo(() => new Set(
|
||||||
|
identities.map(i => i.email).filter(Boolean).map(email => {
|
||||||
|
const atPos = email.indexOf('@');
|
||||||
|
return atPos >= 0 ? email.slice(atPos + 1).toLowerCase() : '';
|
||||||
|
}).filter(d => d.length > 0),
|
||||||
|
), [identities]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fromOverrideEnabled || !fromOverrideEmail.trim()) {
|
||||||
|
setFromOverrideWarning('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const email = fromOverrideEmail.trim();
|
||||||
|
const atPos = email.indexOf('@');
|
||||||
|
if (atPos < 0) {
|
||||||
|
setFromOverrideWarning('Invalid email address');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const domain = email.slice(atPos + 1).toLowerCase();
|
||||||
|
if (!ownIdentityDomains.has(domain)) {
|
||||||
|
setFromOverrideWarning(`This email's domain (${domain}) does not match any of your verified identities`);
|
||||||
|
} else {
|
||||||
|
setFromOverrideWarning('');
|
||||||
|
}
|
||||||
|
}, [fromOverrideEnabled, fromOverrideEmail, ownIdentityDomains]);
|
||||||
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
|
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
|
||||||
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
||||||
const [showCloseDialog, setShowCloseDialog] = useState(false);
|
const [showCloseDialog, setShowCloseDialog] = useState(false);
|
||||||
@@ -2358,6 +2386,11 @@ export function EmailComposer({
|
|||||||
>
|
>
|
||||||
{fromOverrideEnabled ? t('from_override.toggle_on') : t('from_override.toggle_off')}
|
{fromOverrideEnabled ? t('from_override.toggle_on') : t('from_override.toggle_off')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{fromOverrideWarning && (
|
||||||
|
<span className="text-xs text-amber-600 dark:text-amber-400 ml-2" role="alert">
|
||||||
|
{fromOverrideWarning}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { useAccountStore } from '@/stores/account-store';
|
||||||
|
import {
|
||||||
|
getPendingOperationsCount,
|
||||||
|
onPendingCountChange,
|
||||||
|
processQueue,
|
||||||
|
} from '@/lib/offline-write-queue';
|
||||||
|
|
||||||
|
export function OfflineQueueIndicator() {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
const [processing, setProcessing] = useState(false);
|
||||||
|
const client = useAuthStore((s) => s.client);
|
||||||
|
const activeAccountId = useAccountStore((s) => s.activeAccountId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCount(getPendingOperationsCount());
|
||||||
|
return onPendingCountChange(setCount);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRetry = useCallback(async () => {
|
||||||
|
if (!client || !activeAccountId) return;
|
||||||
|
setProcessing(true);
|
||||||
|
try {
|
||||||
|
await processQueue(client, activeAccountId);
|
||||||
|
} finally {
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
}, [client, activeAccountId]);
|
||||||
|
|
||||||
|
if (count === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2 bg-amber-50 border-b border-amber-200 px-4 py-1.5 text-sm dark:bg-amber-950 dark:border-amber-800">
|
||||||
|
<span className="text-amber-800 dark:text-amber-200">
|
||||||
|
{count} pending {count === 1 ? 'operation' : 'operations'} (offline)
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleRetry}
|
||||||
|
disabled={processing || !client}
|
||||||
|
className="rounded bg-amber-200 px-2 py-0.5 text-xs font-medium text-amber-900 hover:bg-amber-300 disabled:opacity-50 dark:bg-amber-800 dark:text-amber-100 dark:hover:bg-amber-700"
|
||||||
|
>
|
||||||
|
{processing ? 'Retrying...' : 'Retry now'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { get as httpGet } from "node:http";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import type { Duplex } from "node:stream";
|
import type { Duplex } from "node:stream";
|
||||||
|
import { WebSocket } from "ws";
|
||||||
import { attachKeyService, checkEncryptionAvailable } from "./key-service";
|
import { attachKeyService, checkEncryptionAvailable } from "./key-service";
|
||||||
|
|
||||||
let serverProcess: ChildProcess | null = null;
|
let serverProcess: ChildProcess | null = null;
|
||||||
@@ -463,6 +464,70 @@ ipcMain.handle(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- WebSocket bridge for renderer ----------------------------------------
|
||||||
|
// The browser WebSocket constructor cannot attach Authorization headers, so
|
||||||
|
// JMAP-over-WebSocket (RFC 8887) push paths that require auth at the upgrade
|
||||||
|
// handshake are unreachable from the renderer. This IPC bridge opens the
|
||||||
|
// WebSocket from the main process (where we control headers) and forwards
|
||||||
|
// messages to the renderer as 'vnc:ws-message' events.
|
||||||
|
|
||||||
|
const wsConnections = new Map<string, WebSocket>();
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
"vnc:ws-connect",
|
||||||
|
(event, { url, authHeader }: { url: string; authHeader: string }) => {
|
||||||
|
const id = randomBytes(8).toString("hex");
|
||||||
|
const ws = new WebSocket(url, {
|
||||||
|
headers: { Authorization: authHeader },
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("open", () => {
|
||||||
|
event.sender.send("vnc:ws-message", { id, type: "open" });
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("message", (data: Buffer) => {
|
||||||
|
event.sender.send("vnc:ws-message", {
|
||||||
|
id,
|
||||||
|
type: "message",
|
||||||
|
data: data.toString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("close", (code: number) => {
|
||||||
|
wsConnections.delete(id);
|
||||||
|
event.sender.send("vnc:ws-message", { id, type: "close", code });
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("error", (err: Error) => {
|
||||||
|
event.sender.send("vnc:ws-message", {
|
||||||
|
id,
|
||||||
|
type: "error",
|
||||||
|
message: err.message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
wsConnections.set(id, ws);
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
"vnc:ws-send",
|
||||||
|
(_event, { id, data }: { id: string; data: string }) => {
|
||||||
|
const ws = wsConnections.get(id);
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
|
||||||
|
ws.send(data);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ipcMain.handle("vnc:ws-close", (_event, { id }: { id: string }) => {
|
||||||
|
const ws = wsConnections.get(id);
|
||||||
|
if (!ws) return;
|
||||||
|
ws.close();
|
||||||
|
wsConnections.delete(id);
|
||||||
|
});
|
||||||
|
|
||||||
// --- Auto-update -------------------------------------------------------
|
// --- Auto-update -------------------------------------------------------
|
||||||
// GitHub Releases as the update feed (electron-builder.config.js's
|
// GitHub Releases as the update feed (electron-builder.config.js's
|
||||||
// `publish` block) - the skill's recommendation over standing up a new
|
// `publish` block) - the skill's recommendation over standing up a new
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ export interface ShowNotificationResult {
|
|||||||
shown: boolean;
|
shown: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WsMessageEvent {
|
||||||
|
id: string;
|
||||||
|
type: "open" | "message" | "close" | "error";
|
||||||
|
data?: string;
|
||||||
|
code?: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("vnc", {
|
contextBridge.exposeInMainWorld("vnc", {
|
||||||
isElectron: true,
|
isElectron: true,
|
||||||
// Routes to Electron's own Notification API in main.ts (ipcMain.handle
|
// Routes to Electron's own Notification API in main.ts (ipcMain.handle
|
||||||
@@ -24,4 +32,26 @@ contextBridge.exposeInMainWorld("vnc", {
|
|||||||
options?: ShowNotificationOptions,
|
options?: ShowNotificationOptions,
|
||||||
): Promise<ShowNotificationResult> =>
|
): Promise<ShowNotificationResult> =>
|
||||||
ipcRenderer.invoke("vnc:show-notification", title, options),
|
ipcRenderer.invoke("vnc:show-notification", title, options),
|
||||||
|
|
||||||
|
// WebSocket bridge for JMAP-over-WebSocket (RFC 8887). The browser
|
||||||
|
// WebSocket constructor cannot attach Authorization headers, so
|
||||||
|
// connections go through the main process which controls headers.
|
||||||
|
wsConnect: (
|
||||||
|
url: string,
|
||||||
|
authHeader: string,
|
||||||
|
): Promise<string> =>
|
||||||
|
ipcRenderer.invoke("vnc:ws-connect", { url, authHeader }),
|
||||||
|
|
||||||
|
wsSend: (id: string, data: string): Promise<boolean> =>
|
||||||
|
ipcRenderer.invoke("vnc:ws-send", { id, data }),
|
||||||
|
|
||||||
|
wsClose: (id: string): Promise<void> =>
|
||||||
|
ipcRenderer.invoke("vnc:ws-close", { id }),
|
||||||
|
|
||||||
|
onWsMessage: (callback: (event: WsMessageEvent) => void): () => void => {
|
||||||
|
const handler = (_event: Electron.IpcRendererEvent, data: WsMessageEvent) =>
|
||||||
|
callback(data);
|
||||||
|
ipcRenderer.on("vnc:ws-message", handler);
|
||||||
|
return () => { ipcRenderer.removeListener("vnc:ws-message", handler); };
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,18 +11,26 @@ import { useFilterStore } from '@/stores/filter-store';
|
|||||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||||
import { useIdentityStore } from '@/stores/identity-store';
|
import { useIdentityStore } from '@/stores/identity-store';
|
||||||
import { useVacationStore } from '@/stores/vacation-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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
type StoreSnapshot = Record<string, any>;
|
type StoreData = Record<string, any>;
|
||||||
|
|
||||||
interface AccountSnapshot {
|
interface AccountSnapshot {
|
||||||
email: StoreSnapshot;
|
email: StoreData;
|
||||||
contact: StoreSnapshot;
|
contact: StoreData;
|
||||||
calendar: StoreSnapshot;
|
calendar: StoreData;
|
||||||
filter: StoreSnapshot;
|
filter: StoreData;
|
||||||
identity: StoreSnapshot;
|
identity: StoreData;
|
||||||
vacation: StoreSnapshot;
|
vacation: StoreData;
|
||||||
|
messageListTabs: StoreData;
|
||||||
|
tasks: StoreData;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cache = new Map<string, AccountSnapshot>();
|
const cache = new Map<string, AccountSnapshot>();
|
||||||
@@ -35,11 +43,9 @@ export function snapshotAccount(accountId: string): void {
|
|||||||
const filterState = useFilterStore.getState();
|
const filterState = useFilterStore.getState();
|
||||||
const identityState = useIdentityStore.getState();
|
const identityState = useIdentityStore.getState();
|
||||||
const vacationState = useVacationStore.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, {
|
cache.set(accountId, {
|
||||||
email: {
|
email: {
|
||||||
emails: [...emailState.emails],
|
emails: [...emailState.emails],
|
||||||
@@ -73,6 +79,17 @@ export function snapshotAccount(accountId: string): void {
|
|||||||
isEnabled: vacationState.isEnabled,
|
isEnabled: vacationState.isEnabled,
|
||||||
isSupported: vacationState.isSupported,
|
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);
|
useFilterStore.setState(snapshot.filter);
|
||||||
useIdentityStore.setState(snapshot.identity);
|
useIdentityStore.setState(snapshot.identity);
|
||||||
useVacationStore.setState(snapshot.vacation);
|
useVacationStore.setState(snapshot.vacation);
|
||||||
|
useMessageListTabsStore.setState(snapshot.messageListTabs);
|
||||||
|
useTaskStore.setState(snapshot.tasks);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -132,6 +151,8 @@ export function clearAllStores(): void {
|
|||||||
useVacationStore.getState().clearState();
|
useVacationStore.getState().clearState();
|
||||||
useCalendarStore.getState().clearState();
|
useCalendarStore.getState().clearState();
|
||||||
useFilterStore.getState().clearState();
|
useFilterStore.getState().clearState();
|
||||||
|
useMessageListTabsStore.getState().clearState();
|
||||||
|
useTaskStore.getState().clearTasks();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Evict cached state for one account */
|
/** Evict cached state for one account */
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { configManager } from './config-manager';
|
||||||
|
import type { FeatureGates } from './types';
|
||||||
|
|
||||||
|
export function isFeatureEnabledServer(feature: keyof FeatureGates): boolean {
|
||||||
|
return configManager.getPolicy().features[feature] ?? true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
|
export function handleAuthError(error: unknown): boolean {
|
||||||
|
if (error instanceof Error && error.message.includes('401')) {
|
||||||
|
useAuthStore.getState().logout();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
const SESSION_KEY_STORAGE_KEY = 'vncmail:session-encryption-key';
|
||||||
|
const ALGORITHM = 'AES-GCM';
|
||||||
|
|
||||||
|
let _available: boolean | null = null;
|
||||||
|
|
||||||
|
export function isEncryptionAvailable(): boolean {
|
||||||
|
if (_available !== null) return _available;
|
||||||
|
try {
|
||||||
|
if (typeof window === 'undefined') { _available = false; return false; }
|
||||||
|
if (!window.crypto || !window.crypto.subtle) { _available = false; return false; }
|
||||||
|
_available = true;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
_available = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreateSessionKey(): Promise<CryptoKey | null> {
|
||||||
|
if (!isEncryptionAvailable()) return Promise.resolve(null);
|
||||||
|
try {
|
||||||
|
let raw = sessionStorage.getItem(SESSION_KEY_STORAGE_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
const keyBytes = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(keyBytes);
|
||||||
|
raw = btoa(String.fromCharCode(...keyBytes));
|
||||||
|
sessionStorage.setItem(SESSION_KEY_STORAGE_KEY, raw);
|
||||||
|
}
|
||||||
|
const keyData = Uint8Array.from(atob(raw), (c) => c.charCodeAt(0));
|
||||||
|
return crypto.subtle.importKey('raw', keyData, { name: ALGORITHM }, false, [
|
||||||
|
'encrypt',
|
||||||
|
'decrypt',
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _cachedKey: CryptoKey | null | undefined;
|
||||||
|
|
||||||
|
async function getKey(): Promise<CryptoKey | null> {
|
||||||
|
if (_cachedKey !== undefined) return _cachedKey;
|
||||||
|
_cachedKey = await getOrCreateSessionKey();
|
||||||
|
return _cachedKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateKey(): void {
|
||||||
|
_cachedKey = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptValue(plaintext: string): Promise<string> {
|
||||||
|
if (!isEncryptionAvailable()) {
|
||||||
|
console.warn('[localStorage crypto] Web Crypto unavailable, storing in plaintext');
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await getKey();
|
||||||
|
if (!key) {
|
||||||
|
console.warn('[localStorage crypto] Failed to derive key, storing in plaintext');
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const encoded = new TextEncoder().encode(plaintext);
|
||||||
|
const ciphertext = await crypto.subtle.encrypt({ name: ALGORITHM, iv }, key, encoded);
|
||||||
|
const combined = new Uint8Array(iv.length + new Uint8Array(ciphertext).length);
|
||||||
|
combined.set(iv);
|
||||||
|
combined.set(new Uint8Array(ciphertext), iv.length);
|
||||||
|
return btoa(String.fromCharCode(...combined));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[localStorage crypto] Encryption failed:', err);
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptValue(ciphertext: string): Promise<string | null> {
|
||||||
|
if (!isEncryptionAvailable()) {
|
||||||
|
return ciphertext;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await getKey();
|
||||||
|
if (!key) {
|
||||||
|
return ciphertext;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const combined = Uint8Array.from(atob(ciphertext), (c) => c.charCodeAt(0));
|
||||||
|
if (combined.length < 13) return null;
|
||||||
|
const iv = combined.slice(0, 12);
|
||||||
|
const data = combined.slice(12);
|
||||||
|
const decrypted = await crypto.subtle.decrypt({ name: ALGORITHM, iv }, key, data);
|
||||||
|
return new TextDecoder().decode(decrypted);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetSessionKey(): void {
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(SESSION_KEY_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
invalidateKey();
|
||||||
|
}
|
||||||
+12
-3
@@ -7,9 +7,6 @@
|
|||||||
// Web/PWA deployments never get `window.vnc` at all (contextBridge only
|
// Web/PWA deployments never get `window.vnc` at all (contextBridge only
|
||||||
// exists inside the Electron shell), so `isElectronShell()` is false there
|
// exists inside the Electron shell), so `isElectronShell()` is false there
|
||||||
// and callers should keep using the lib/web-push.ts + public/sw.js path.
|
// and callers should keep using the lib/web-push.ts + public/sw.js path.
|
||||||
// Wiring this bridge up to real mail-delivery events (JMAP WebSocket push
|
|
||||||
// vs. polling) is a separate, later decision - this module is only the
|
|
||||||
// plumbing.
|
|
||||||
|
|
||||||
export interface ShowNotificationOptions {
|
export interface ShowNotificationOptions {
|
||||||
body?: string;
|
body?: string;
|
||||||
@@ -20,12 +17,24 @@ export interface ShowNotificationResult {
|
|||||||
shown: boolean;
|
shown: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WsMessageEvent {
|
||||||
|
id: string;
|
||||||
|
type: "open" | "message" | "close" | "error";
|
||||||
|
data?: string;
|
||||||
|
code?: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VncElectronBridge {
|
export interface VncElectronBridge {
|
||||||
isElectron: true;
|
isElectron: true;
|
||||||
showNotification: (
|
showNotification: (
|
||||||
title: string,
|
title: string,
|
||||||
options?: ShowNotificationOptions,
|
options?: ShowNotificationOptions,
|
||||||
) => Promise<ShowNotificationResult>;
|
) => Promise<ShowNotificationResult>;
|
||||||
|
wsConnect: (url: string, authHeader: string) => Promise<string>;
|
||||||
|
wsSend: (id: string, data: string) => Promise<boolean>;
|
||||||
|
wsClose: (id: string) => Promise<void>;
|
||||||
|
onWsMessage: (callback: (event: WsMessageEvent) => void) => () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
+101
-4
@@ -6,6 +6,8 @@ import { batched, itemsPerRequest } from "./request-limits";
|
|||||||
import { noteTransportFailure, noteTransportSuccess, transportFailureCount } from "./transport-health";
|
import { noteTransportFailure, noteTransportSuccess, transportFailureCount } from "./transport-health";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization";
|
import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization";
|
||||||
|
import type { VncElectronBridge, WsMessageEvent } from "@/lib/electron-bridge";
|
||||||
|
import { isElectronShell } from "@/lib/electron-bridge";
|
||||||
|
|
||||||
export class TransportError extends Error {
|
export class TransportError extends Error {
|
||||||
constructor(message = 'Network transport failure') {
|
constructor(message = 'Network transport failure') {
|
||||||
@@ -759,6 +761,12 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
import('@/lib/auth-error-handler').then(({ handleAuthError }) => {
|
||||||
|
handleAuthError(new Error('401 Unauthorized'));
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6120,7 +6128,90 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
// mean piping raw credentials from the renderer to the main process over
|
// mean piping raw credentials from the renderer to the main process over
|
||||||
// IPC, which is a materially bigger security-sensitive change than what
|
// IPC, which is a materially bigger security-sensitive change than what
|
||||||
// was scoped here.
|
// was scoped here.
|
||||||
private ws: WebSocket | null = null;
|
private ws: (WebSocket | ReturnType<JMAPClient['createElectronWebSocket']>) | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connects a WebSocket through Electron's main process IPC bridge (which
|
||||||
|
* can attach Authorization headers the browser WebSocket API cannot).
|
||||||
|
* Returns a WebSocket-like wrapper that the calling code in
|
||||||
|
* connectWebSocket() interacts with identically to a browser WebSocket.
|
||||||
|
*/
|
||||||
|
private createElectronWebSocket(wsUrl: string): {
|
||||||
|
addEventListener: (type: string, handler: (event: unknown) => void) => void;
|
||||||
|
send: (data: string) => void;
|
||||||
|
close: () => void;
|
||||||
|
} {
|
||||||
|
const bridge: VncElectronBridge = (window as Window & { vnc: VncElectronBridge }).vnc!;
|
||||||
|
let connectionId: string | null = null;
|
||||||
|
const listeners = new Map<string, Array<(event: unknown) => void>>();
|
||||||
|
|
||||||
|
const emit = (type: string, event: unknown) => {
|
||||||
|
for (const handler of listeners.get(type) || []) {
|
||||||
|
try { handler(event); } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = bridge.onWsMessage((msg: WsMessageEvent) => {
|
||||||
|
// Only deliver events for our connection
|
||||||
|
if (msg.id !== connectionId) return;
|
||||||
|
switch (msg.type) {
|
||||||
|
case "open":
|
||||||
|
emit("open", {});
|
||||||
|
break;
|
||||||
|
case "message":
|
||||||
|
emit("message", { data: msg.data || "" });
|
||||||
|
break;
|
||||||
|
case "close":
|
||||||
|
connectionId = null;
|
||||||
|
emit("close", { code: msg.code || 0 });
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
// The main process already logged the error - trigger the
|
||||||
|
// "close" path so the reconnect logic engages.
|
||||||
|
if (connectionId !== null) {
|
||||||
|
connectionId = null;
|
||||||
|
emit("close", { code: 1006 });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
bridge.wsConnect(wsUrl, this.authHeader).then((id) => {
|
||||||
|
// Don't update `connectionId` here — let 'open' from onWsMessage do it.
|
||||||
|
// The main process sends 'open' on the message channel, and that sets
|
||||||
|
// connectionId & fires the open handler. This avoids a race: if the
|
||||||
|
// bridge fires 'open' before .then() runs, connectionId would be stale
|
||||||
|
// for the 'message' and 'close' events arriving between 'open' and here.
|
||||||
|
//
|
||||||
|
// But we NEED connectionId before any message arrives, so set it now
|
||||||
|
// and let the 'open' event be purely for notification.
|
||||||
|
connectionId = id;
|
||||||
|
// If 'open' hasn't already been delivered, fire it now.
|
||||||
|
emit("open", {});
|
||||||
|
}).catch((err: Error) => {
|
||||||
|
// Connection failed immediately — simulate a close with error.
|
||||||
|
emit("close", { code: 1006, reason: err.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
addEventListener(type: string, handler: (event: unknown) => void) {
|
||||||
|
if (!listeners.has(type)) listeners.set(type, []);
|
||||||
|
listeners.get(type)!.push(handler);
|
||||||
|
},
|
||||||
|
send(data: string) {
|
||||||
|
if (connectionId !== null) {
|
||||||
|
bridge.wsSend(connectionId, data).catch(() => {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
if (connectionId !== null) {
|
||||||
|
bridge.wsClose(connectionId).catch(() => {});
|
||||||
|
connectionId = null;
|
||||||
|
}
|
||||||
|
cleanup();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
private wsReconnectTimeout: NodeJS.Timeout | null = null;
|
private wsReconnectTimeout: NodeJS.Timeout | null = null;
|
||||||
private wsReconnectAttempts: number = 0;
|
private wsReconnectAttempts: number = 0;
|
||||||
private wsConsecutiveFailures: number = 0;
|
private wsConsecutiveFailures: number = 0;
|
||||||
@@ -6242,9 +6333,13 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let socket: WebSocket;
|
let socket: WebSocket | ReturnType<JMAPClient['createElectronWebSocket']>;
|
||||||
try {
|
try {
|
||||||
|
if (isElectronShell()) {
|
||||||
|
socket = this.createElectronWebSocket(wsUrl);
|
||||||
|
} else {
|
||||||
socket = new WebSocket(wsUrl, "jmap");
|
socket = new WebSocket(wsUrl, "jmap");
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// New URL()-level failures (malformed URL) - retry later in case a
|
// New URL()-level failures (malformed URL) - retry later in case a
|
||||||
// session refresh fixes it; getWebSocketUrl() re-reads capabilities
|
// session refresh fixes it; getWebSocketUrl() re-reads capabilities
|
||||||
@@ -6286,7 +6381,9 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
socket.addEventListener("message", (event) => {
|
socket.addEventListener("message", (event) => {
|
||||||
if (!isCurrent()) return;
|
if (!isCurrent()) return;
|
||||||
this.lastWSActivity = Date.now();
|
this.lastWSActivity = Date.now();
|
||||||
this.processWebSocketMessage(typeof event.data === "string" ? event.data : "");
|
this.processWebSocketMessage(
|
||||||
|
typeof (event as MessageEvent).data === "string" ? (event as MessageEvent).data : ""
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.addEventListener("close", () => {
|
socket.addEventListener("close", () => {
|
||||||
@@ -6417,7 +6514,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}, delay);
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
private startWSHeartbeat(socket: WebSocket): void {
|
private startWSHeartbeat(socket: WebSocket | ReturnType<JMAPClient['createElectronWebSocket']>): void {
|
||||||
this.stopWSHeartbeat();
|
this.stopWSHeartbeat();
|
||||||
this.wsHeartbeatTimer = setInterval(() => {
|
this.wsHeartbeatTimer = setInterval(() => {
|
||||||
if (this.ws !== socket) return;
|
if (this.ws !== socket) return;
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
import { TransportError } from '@/lib/jmap/client';
|
||||||
|
import { debug } from '@/lib/debug';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'vncmail:pending-ops';
|
||||||
|
|
||||||
|
export type OperationType =
|
||||||
|
| 'sendEmail'
|
||||||
|
| 'createEvent'
|
||||||
|
| 'updateEvent'
|
||||||
|
| 'deleteEvent'
|
||||||
|
| 'createContact'
|
||||||
|
| 'updateContact'
|
||||||
|
| 'deleteContact'
|
||||||
|
| 'createTask'
|
||||||
|
| 'updateTask'
|
||||||
|
| 'deleteTask';
|
||||||
|
|
||||||
|
export interface PendingOperation {
|
||||||
|
id: string;
|
||||||
|
type: OperationType;
|
||||||
|
accountId: string;
|
||||||
|
payload: unknown;
|
||||||
|
createdAt: string;
|
||||||
|
retryCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadOps(): PendingOperation[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
return JSON.parse(raw) as PendingOperation[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveOps(ops: PendingOperation[]): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(ops));
|
||||||
|
} catch {
|
||||||
|
debug.error('offline-write-queue', 'Failed to persist pending operations');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enqueueOperation(
|
||||||
|
op: Omit<PendingOperation, 'id' | 'createdAt' | 'retryCount'>,
|
||||||
|
): void {
|
||||||
|
const ops = loadOps();
|
||||||
|
ops.push({
|
||||||
|
...op,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
retryCount: 0,
|
||||||
|
});
|
||||||
|
saveOps(ops);
|
||||||
|
notifyCountChanged(getPendingOperationsCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dequeueOperation(id: string): void {
|
||||||
|
const ops = loadOps();
|
||||||
|
saveOps(ops.filter((o) => o.id !== id));
|
||||||
|
notifyCountChanged(getPendingOperationsCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPendingOperations(accountId: string): PendingOperation[] {
|
||||||
|
return loadOps().filter((o) => o.accountId === accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPendingOperationsCount(): number {
|
||||||
|
return loadOps().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAllOperations(): void {
|
||||||
|
saveOps([]);
|
||||||
|
notifyCountChanged(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processQueue(
|
||||||
|
client: IJMAPClient,
|
||||||
|
accountId: string,
|
||||||
|
): Promise<{ succeeded: number; failed: number }> {
|
||||||
|
const ops = getPendingOperations(accountId);
|
||||||
|
let succeeded = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const op of ops) {
|
||||||
|
try {
|
||||||
|
await executeOperation(client, op);
|
||||||
|
dequeueOperation(op.id);
|
||||||
|
succeeded++;
|
||||||
|
} catch {
|
||||||
|
op.retryCount += 1;
|
||||||
|
failed++;
|
||||||
|
if (op.retryCount >= 5) {
|
||||||
|
dequeueOperation(op.id);
|
||||||
|
debug.warn('offline-write-queue', 'Dropping operation after max retries', {
|
||||||
|
id: op.id,
|
||||||
|
type: op.type,
|
||||||
|
});
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist updated retry counts for failed operations
|
||||||
|
const allOps = loadOps();
|
||||||
|
for (const failedOp of ops.filter((o) => allOps.some((a) => a.id === o.id))) {
|
||||||
|
const idx = allOps.findIndex((a) => a.id === failedOp.id);
|
||||||
|
if (idx >= 0) allOps[idx] = failedOp;
|
||||||
|
}
|
||||||
|
saveOps(allOps);
|
||||||
|
|
||||||
|
notifyCountChanged(getPendingOperationsCount());
|
||||||
|
|
||||||
|
return { succeeded, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeOperation(
|
||||||
|
client: IJMAPClient,
|
||||||
|
op: PendingOperation,
|
||||||
|
): Promise<void> {
|
||||||
|
switch (op.type) {
|
||||||
|
case 'sendEmail': {
|
||||||
|
const p = op.payload as {
|
||||||
|
to: string[];
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
cc?: string[];
|
||||||
|
bcc?: string[];
|
||||||
|
identityId?: string;
|
||||||
|
fromEmail?: string;
|
||||||
|
draftId?: string;
|
||||||
|
fromName?: string;
|
||||||
|
htmlBody?: string;
|
||||||
|
attachments?: Array<{
|
||||||
|
blobId: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
disposition?: 'attachment' | 'inline';
|
||||||
|
cid?: string;
|
||||||
|
}>;
|
||||||
|
inReplyTo?: string[];
|
||||||
|
references?: string[];
|
||||||
|
delayedUntil?: string;
|
||||||
|
envelopeMailFrom?: string;
|
||||||
|
options?: { requestReadReceipt?: boolean };
|
||||||
|
};
|
||||||
|
await client.sendEmail(
|
||||||
|
p.to,
|
||||||
|
p.subject,
|
||||||
|
p.body,
|
||||||
|
p.cc,
|
||||||
|
p.bcc,
|
||||||
|
p.identityId,
|
||||||
|
p.fromEmail,
|
||||||
|
p.draftId,
|
||||||
|
p.fromName,
|
||||||
|
p.htmlBody,
|
||||||
|
p.attachments,
|
||||||
|
p.inReplyTo,
|
||||||
|
p.references,
|
||||||
|
p.delayedUntil,
|
||||||
|
p.envelopeMailFrom,
|
||||||
|
p.options,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'createEvent':
|
||||||
|
await client.createCalendarEvent(op.payload as Record<string, unknown>);
|
||||||
|
break;
|
||||||
|
case 'updateEvent': {
|
||||||
|
const up = op.payload as { id: string; updates: Record<string, unknown> };
|
||||||
|
await client.updateCalendarEvent(up.id, up.updates);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'deleteEvent':
|
||||||
|
await client.deleteCalendarEvent(op.payload as string);
|
||||||
|
break;
|
||||||
|
case 'createContact':
|
||||||
|
await client.createContact(op.payload as Record<string, unknown>);
|
||||||
|
break;
|
||||||
|
case 'updateContact': {
|
||||||
|
const uc = op.payload as { id: string; updates: Record<string, unknown> };
|
||||||
|
await client.updateContact(uc.id, uc.updates);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'deleteContact': {
|
||||||
|
const dc = op.payload as { id: string; targetAccountId?: string };
|
||||||
|
await client.deleteContact(dc.id, dc.targetAccountId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'createTask':
|
||||||
|
await client.createCalendarTask(op.payload as Record<string, unknown>);
|
||||||
|
break;
|
||||||
|
case 'updateTask': {
|
||||||
|
const ut = op.payload as { id: string; updates: Record<string, unknown> };
|
||||||
|
await client.updateCalendarTask(ut.id, ut.updates);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'deleteTask': {
|
||||||
|
const dt = op.payload as { id: string; targetAccountId?: string };
|
||||||
|
await client.deleteCalendarTask(dt.id, dt.targetAccountId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown operation type: ${op.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const countListeners = new Set<(count: number) => void>();
|
||||||
|
|
||||||
|
export function onPendingCountChange(listener: (count: number) => void): () => void {
|
||||||
|
countListeners.add(listener);
|
||||||
|
return () => countListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyCountChanged(count: number): void {
|
||||||
|
for (const listener of countListeners) {
|
||||||
|
try {
|
||||||
|
listener(count);
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNetworkError(error: unknown): boolean {
|
||||||
|
if (error instanceof TransportError) return true;
|
||||||
|
if (error instanceof TypeError) return true;
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const msg = error.message.toLowerCase();
|
||||||
|
return (
|
||||||
|
msg.includes('network') ||
|
||||||
|
msg.includes('fetch') ||
|
||||||
|
msg.includes('econnrefused') ||
|
||||||
|
msg.includes('timeout') ||
|
||||||
|
msg.includes('offline') ||
|
||||||
|
msg.includes('abort')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cleanupHandler: (() => void) | null = null;
|
||||||
|
|
||||||
|
export function initOfflineQueueHandler(
|
||||||
|
getClient: () => IJMAPClient | null,
|
||||||
|
getAccountId: () => string | null,
|
||||||
|
): () => void {
|
||||||
|
if (typeof window === 'undefined') return () => {};
|
||||||
|
|
||||||
|
const handleOnline = () => {
|
||||||
|
const client = getClient();
|
||||||
|
const accountId = getAccountId();
|
||||||
|
if (!client || !accountId) return;
|
||||||
|
processQueue(client, accountId).catch((err) => {
|
||||||
|
debug.error('offline-write-queue', 'Failed to process queue on reconnect', err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('online', handleOnline);
|
||||||
|
|
||||||
|
cleanupHandler = () => window.removeEventListener('online', handleOnline);
|
||||||
|
|
||||||
|
return cleanupHandler;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
|
||||||
|
type JmapTypeHandler = (client: IJMAPClient, accountChanges: Record<string, string>) => Promise<void>;
|
||||||
|
|
||||||
|
const handlers = new Map<string, JmapTypeHandler[]>();
|
||||||
|
|
||||||
|
export function registerPushHandler(jmapType: string, handler: JmapTypeHandler): () => void {
|
||||||
|
const list = handlers.get(jmapType) ?? [];
|
||||||
|
list.push(handler);
|
||||||
|
handlers.set(jmapType, list);
|
||||||
|
return () => {
|
||||||
|
const current = handlers.get(jmapType);
|
||||||
|
if (!current) return;
|
||||||
|
const idx = current.indexOf(handler);
|
||||||
|
if (idx >= 0) current.splice(idx, 1);
|
||||||
|
if (current.length === 0) handlers.delete(jmapType);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchPushEvent(
|
||||||
|
client: IJMAPClient,
|
||||||
|
changed: Record<string, Record<string, string>>,
|
||||||
|
accountId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const accountChanges = changed[accountId];
|
||||||
|
|
||||||
|
for (const [jmapType, typeHandlers] of handlers) {
|
||||||
|
if (accountChanges?.[jmapType]) {
|
||||||
|
for (const handler of typeHandlers) {
|
||||||
|
try {
|
||||||
|
await handler(client, accountChanges);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Push handler for ${jmapType} failed:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+22
@@ -48,6 +48,7 @@
|
|||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"webcrypto-liner": "^1.4.3",
|
"webcrypto-liner": "^1.4.3",
|
||||||
|
"ws": "^8.21.3",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -12858,6 +12859,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||||
|
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xml-name-validator": {
|
"node_modules/xml-name-validator": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||||
|
|||||||
@@ -84,6 +84,7 @@
|
|||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"webcrypto-liner": "^1.4.3",
|
"webcrypto-liner": "^1.4.3",
|
||||||
|
"ws": "^8.21.3",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const shared = {
|
|||||||
// stays external so electron-builder ships it from node_modules as a
|
// stays external so electron-builder ships it from node_modules as a
|
||||||
// normal production dependency instead of us re-bundling its native-ish
|
// normal production dependency instead of us re-bundling its native-ish
|
||||||
// internals (see electron-builder.config.js's file collection).
|
// internals (see electron-builder.config.js's file collection).
|
||||||
external: ["electron", "electron-updater"],
|
external: ["electron", "electron-updater", "ws"],
|
||||||
logLevel: "info",
|
logLevel: "info",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
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';
|
import { generateAccountId, generateAvatarColor, getMaxAccounts } from '@/lib/account-utils';
|
||||||
|
|
||||||
export interface AccountEntry {
|
export interface AccountEntry {
|
||||||
@@ -218,6 +219,7 @@ export const useAccountStore = create<AccountState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'account-registry',
|
name: 'account-registry',
|
||||||
|
storage: createJSONStorage(() => encryptedStorage),
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
accounts: state.accounts,
|
accounts: state.accounts,
|
||||||
activeAccountId: state.activeAccountId,
|
activeAccountId: state.activeAccountId,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
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 { JMAPClient, RateLimitError } from '@/lib/jmap/client';
|
||||||
import { withOfflineFallback } from '@/lib/offline-fallback-client';
|
import { withOfflineFallback } from '@/lib/offline-fallback-client';
|
||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
@@ -2010,6 +2011,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'auth-storage',
|
name: 'auth-storage',
|
||||||
|
storage: createJSONStorage(() => encryptedStorage),
|
||||||
partialize: (state) => {
|
partialize: (state) => {
|
||||||
// Don't persist unauthenticated state - prevents resurrecting stale sessions
|
// Don't persist unauthenticated state - prevents resurrecting stale sessions
|
||||||
if (!state.isAuthenticated) return {};
|
if (!state.isAuthenticated) return {};
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { generateUUID } from '@/lib/utils';
|
|||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar';
|
import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar';
|
||||||
import { getClientByLocalAccountId } from './client-registry';
|
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
|
* When the Pro shell aggregates calendars/events from every connected
|
||||||
@@ -409,13 +411,13 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
|
|
||||||
createEvent: async (client, event, sendSchedulingMessages) => {
|
createEvent: async (client, event, sendSchedulingMessages) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
|
let targetAccountId: string | undefined = event.accountId;
|
||||||
|
const cleanEvent = sanitizeOutgoingCalendarEventData({ ...event });
|
||||||
try {
|
try {
|
||||||
// Resolve shared calendar context from calendarIds. Also pin the
|
// Resolve shared calendar context from calendarIds. Also pin the
|
||||||
// local account from the calendar so we route through that
|
// local account from the calendar so we route through that
|
||||||
// server's client when in multi-account Pro mode.
|
// server's client when in multi-account Pro mode.
|
||||||
let targetAccountId = event.accountId;
|
|
||||||
let localAccountId = event.localAccountId;
|
let localAccountId = event.localAccountId;
|
||||||
const cleanEvent = sanitizeOutgoingCalendarEventData({ ...event });
|
|
||||||
if (event.calendarIds) {
|
if (event.calendarIds) {
|
||||||
const remapped: Record<string, boolean> = {};
|
const remapped: Record<string, boolean> = {};
|
||||||
for (const calId of Object.keys(event.calendarIds)) {
|
for (const calId of Object.keys(event.calendarIds)) {
|
||||||
@@ -491,6 +493,12 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
return mappedCreated;
|
return mappedCreated;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('Failed to create event:', 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' });
|
set({ error: 'Failed to create event' });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -498,11 +506,12 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
|
|
||||||
updateEvent: async (client, id, updates, sendSchedulingMessages) => {
|
updateEvent: async (client, id, updates, sendSchedulingMessages) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
|
||||||
// Resolve shared event IDs and client-side expanded occurrence IDs
|
|
||||||
const storeEvent = get().events.find(e => e.id === id);
|
const storeEvent = get().events.find(e => e.id === id);
|
||||||
const realId = storeEvent?.originalId || stripLocalAccountPrefix(id, storeEvent?.localAccountId);
|
const realId = storeEvent?.originalId || stripLocalAccountPrefix(id, storeEvent?.localAccountId);
|
||||||
const targetAccountId = storeEvent?.accountId;
|
const targetAccountId = storeEvent?.accountId;
|
||||||
|
const cleanUpdates = sanitizeOutgoingCalendarEventData({ ...updates });
|
||||||
|
try {
|
||||||
|
// Resolve shared event IDs and client-side expanded occurrence IDs
|
||||||
client = resolveAccountClient(client, storeEvent?.localAccountId);
|
client = resolveAccountClient(client, storeEvent?.localAccountId);
|
||||||
debug.log('calendar', 'Calendar updateEvent', {
|
debug.log('calendar', 'Calendar updateEvent', {
|
||||||
storeId: id,
|
storeId: id,
|
||||||
@@ -513,7 +522,6 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
updateKeys: Object.keys(updates),
|
updateKeys: Object.keys(updates),
|
||||||
});
|
});
|
||||||
// Remap namespaced calendarIds back to original IDs
|
// Remap namespaced calendarIds back to original IDs
|
||||||
const cleanUpdates = sanitizeOutgoingCalendarEventData({ ...updates });
|
|
||||||
if (cleanUpdates.calendarIds) {
|
if (cleanUpdates.calendarIds) {
|
||||||
const remapped: Record<string, boolean> = {};
|
const remapped: Record<string, boolean> = {};
|
||||||
for (const [calId, v] of Object.entries(cleanUpdates.calendarIds)) {
|
for (const [calId, v] of Object.entries(cleanUpdates.calendarIds)) {
|
||||||
@@ -556,6 +564,12 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
// iMIP send here produced duplicate emails.
|
// iMIP send here produced duplicate emails.
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('Failed to update event:', 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' });
|
set({ error: 'Failed to update event' });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -788,11 +802,11 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
|
|
||||||
deleteEvent: async (client, id, sendSchedulingMessages) => {
|
deleteEvent: async (client, id, sendSchedulingMessages) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
|
||||||
// Resolve shared event IDs and client-side expanded occurrence IDs
|
|
||||||
const storeEvent = get().events.find(e => e.id === id);
|
const storeEvent = get().events.find(e => e.id === id);
|
||||||
const realId = storeEvent?.originalId || stripLocalAccountPrefix(id, storeEvent?.localAccountId);
|
const realId = storeEvent?.originalId || stripLocalAccountPrefix(id, storeEvent?.localAccountId);
|
||||||
const targetAccountId = storeEvent?.accountId;
|
const targetAccountId = storeEvent?.accountId;
|
||||||
|
try {
|
||||||
|
// Resolve shared event IDs and client-side expanded occurrence IDs
|
||||||
client = resolveAccountClient(client, storeEvent?.localAccountId);
|
client = resolveAccountClient(client, storeEvent?.localAccountId);
|
||||||
// Cancellation emails (iTIP CANCEL) are sent by the server via the
|
// Cancellation emails (iTIP CANCEL) are sent by the server via the
|
||||||
// `sendSchedulingMessages` argument on the destroy below - a manual
|
// `sendSchedulingMessages` argument on the destroy below - a manual
|
||||||
@@ -811,6 +825,12 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('Failed to delete event:', 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' });
|
set({ error: 'Failed to delete event' });
|
||||||
throw error;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
+34
-5
@@ -5,6 +5,8 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
|||||||
import { generateUUID } from '@/lib/utils';
|
import { generateUUID } from '@/lib/utils';
|
||||||
import { debug } from '@/lib/debug';
|
import { debug } from '@/lib/debug';
|
||||||
import { getClientByLocalAccountId } from './client-registry';
|
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. */
|
/** One connected JMAP account for contact multi-account aggregation. */
|
||||||
export interface ContactAccountClient {
|
export interface ContactAccountClient {
|
||||||
@@ -375,12 +377,12 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
|
|
||||||
createContact: async (client, contact) => {
|
createContact: async (client, contact) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
|
let accountId: string | undefined = contact.isShared ? contact.accountId : undefined;
|
||||||
|
let cleanedContact = contact;
|
||||||
try {
|
try {
|
||||||
// Determine target account from the selected address book. Also
|
// Determine target account from the selected address book. Also
|
||||||
// pin the local account so we route through the right server's
|
// pin the local account so we route through the right server's
|
||||||
// client in multi-account Pro mode.
|
// client in multi-account Pro mode.
|
||||||
let accountId = contact.isShared ? contact.accountId : undefined;
|
|
||||||
let cleanedContact = contact;
|
|
||||||
let localAccountId = contact.localAccountId;
|
let localAccountId = contact.localAccountId;
|
||||||
|
|
||||||
// De-namespace addressBookIds if they reference a shared address book
|
// De-namespace addressBookIds if they reference a shared address book
|
||||||
@@ -424,6 +426,12 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = error instanceof Error ? error.message : 'Failed to create contact';
|
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 });
|
set({ error: msg, isLoading: false });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -431,14 +439,14 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
|
|
||||||
updateContact: async (client, id, updates) => {
|
updateContact: async (client, id, updates) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
|
||||||
const contact = get().contacts.find(c => c.id === id);
|
const contact = get().contacts.find(c => c.id === id);
|
||||||
const originalId = contact?.originalId || stripLocalAccountPrefix(id, contact?.localAccountId);
|
const originalId = contact?.originalId || stripLocalAccountPrefix(id, contact?.localAccountId);
|
||||||
const accountId = contact?.isShared ? contact.accountId : undefined;
|
const accountId = contact?.isShared ? contact.accountId : undefined;
|
||||||
|
let cleanedUpdates = updates;
|
||||||
|
try {
|
||||||
client = resolveAccountClient(client, contact?.localAccountId);
|
client = resolveAccountClient(client, contact?.localAccountId);
|
||||||
|
|
||||||
// De-namespace addressBookIds for shared contacts before sending to JMAP server
|
// De-namespace addressBookIds for shared contacts before sending to JMAP server
|
||||||
let cleanedUpdates = updates;
|
|
||||||
if (contact?.isShared && contact?.accountId && updates.addressBookIds) {
|
if (contact?.isShared && contact?.accountId && updates.addressBookIds) {
|
||||||
const prefix = `${contact.accountId}:`;
|
const prefix = `${contact.accountId}:`;
|
||||||
const deNamespaced = Object.fromEntries(
|
const deNamespaced = Object.fromEntries(
|
||||||
@@ -458,6 +466,12 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = error instanceof Error ? error.message : 'Failed to update contact';
|
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 });
|
set({ error: msg });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -465,10 +479,10 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
|
|
||||||
deleteContact: async (client, id) => {
|
deleteContact: async (client, id) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
|
||||||
const contact = get().contacts.find(c => c.id === id);
|
const contact = get().contacts.find(c => c.id === id);
|
||||||
const originalId = contact?.originalId || stripLocalAccountPrefix(id, contact?.localAccountId);
|
const originalId = contact?.originalId || stripLocalAccountPrefix(id, contact?.localAccountId);
|
||||||
const accountId = contact?.isShared ? contact.accountId : undefined;
|
const accountId = contact?.isShared ? contact.accountId : undefined;
|
||||||
|
try {
|
||||||
client = resolveAccountClient(client, contact?.localAccountId);
|
client = resolveAccountClient(client, contact?.localAccountId);
|
||||||
await client.deleteContact(originalId, accountId);
|
await client.deleteContact(originalId, accountId);
|
||||||
set((state) => {
|
set((state) => {
|
||||||
@@ -481,6 +495,12 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = error instanceof Error ? error.message : 'Failed to delete contact';
|
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 });
|
set({ error: msg });
|
||||||
throw error;
|
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 };
|
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 { UnifiedMailboxRole, CrossView } from "@/lib/jmap/types";
|
||||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
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 { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
|
||||||
import { emailHooks } from "@/lib/plugin-hooks";
|
import { emailHooks } from "@/lib/plugin-hooks";
|
||||||
import type { ExternalSearchResult } from "@/lib/plugin-types";
|
import type { ExternalSearchResult } from "@/lib/plugin-types";
|
||||||
@@ -11,6 +10,7 @@ import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, adv
|
|||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
import { useMessageListTabsStore } from "@/stores/message-list-tabs-store";
|
import { useMessageListTabsStore } from "@/stores/message-list-tabs-store";
|
||||||
|
import { enqueueOperation, isNetworkError } from "@/lib/offline-write-queue";
|
||||||
|
|
||||||
type ScheduledSubmissionMetadata = {
|
type ScheduledSubmissionMetadata = {
|
||||||
submissionId: string;
|
submissionId: string;
|
||||||
@@ -1367,6 +1367,16 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} 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({
|
set({
|
||||||
error: error instanceof Error ? error.message : "Failed to send email",
|
error: error instanceof Error ? error.message : "Failed to send email",
|
||||||
isLoading: false
|
isLoading: false
|
||||||
@@ -2911,53 +2921,15 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
await get().fetchMailboxes(client);
|
await get().fetchMailboxes(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Calendar/CalendarEvent state changes - refresh calendar data
|
// Delegate Calendar/CalendarEvent, SieveScript, ContactCard, FileNode
|
||||||
if (accountChanges?.Calendar || accountChanges?.CalendarEvent) {
|
// push handling to the push event bus where each feature store
|
||||||
const calendarStore = useCalendarStore.getState();
|
// registers itself. Decouples email-store from the 5+ other stores
|
||||||
if (calendarStore.supportsCalendar) {
|
// it previously imported directly for push handling.
|
||||||
calendarStore.fetchCalendars(client);
|
import('@/lib/push-event-bus').then(({ dispatchPushEvent }) => {
|
||||||
const { dateRange, selectedCalendarIds } = calendarStore;
|
dispatchPushEvent(client, change.changed, accountId).catch((err) => {
|
||||||
if (dateRange && selectedCalendarIds.length > 0) {
|
console.error('Push event bus dispatch failed:', err);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
}
|
}).catch(() => {});
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Local search index last, with the refreshed ids (see above).
|
// Local search index last, with the refreshed ids (see above).
|
||||||
scheduleIndexUpdate();
|
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,
|
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;
|
registerTabs: (pluginId: string, config: MessageListTabsConfig) => void;
|
||||||
clearTabs: (pluginId: string) => void;
|
clearTabs: (pluginId: string) => void;
|
||||||
|
clearState: () => void;
|
||||||
setActiveTab: (tabId: string, mailboxId: string | null) => void;
|
setActiveTab: (tabId: string, mailboxId: string | null) => void;
|
||||||
/**
|
/**
|
||||||
* JMAP filter fragment for the active tab (to AND into the mailbox query),
|
* 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);
|
void messageListTabHooks.onEmailCategorize.emit(ctx);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
clearState: () => set({
|
||||||
|
registrations: {},
|
||||||
|
tabs: [],
|
||||||
|
mailboxRoles: [],
|
||||||
|
activeTabId: null,
|
||||||
|
tabCounts: {},
|
||||||
|
isCountsLoading: false,
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { create } from 'zustand';
|
|||||||
import type { CalendarTask } from '@/lib/jmap/types';
|
import type { CalendarTask } from '@/lib/jmap/types';
|
||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
import { debug } from '@/lib/debug';
|
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';
|
export type TaskViewFilter = 'all' | 'pending' | 'completed' | 'overdue';
|
||||||
|
|
||||||
@@ -58,10 +60,22 @@ export const useTaskStore = create<TaskStore>((set, get) => ({
|
|||||||
|
|
||||||
createTask: async (client, task) => {
|
createTask: async (client, task) => {
|
||||||
debug.log('tasks', 'TaskStore/createTask', task);
|
debug.log('tasks', 'TaskStore/createTask', task);
|
||||||
|
try {
|
||||||
const created = await client.createCalendarTask(task);
|
const created = await client.createCalendarTask(task);
|
||||||
debug.log('tasks', 'TaskStore/createTask result', { id: created.id, uid: created.uid, title: created.title });
|
debug.log('tasks', 'TaskStore/createTask result', { id: created.id, uid: created.uid, title: created.title });
|
||||||
set({ tasks: [...get().tasks, created] });
|
set({ tasks: [...get().tasks, created] });
|
||||||
return 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) => {
|
updateTask: async (client, id, updates) => {
|
||||||
@@ -72,6 +86,12 @@ export const useTaskStore = create<TaskStore>((set, get) => ({
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('TaskStore/updateTask failed', 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' });
|
set({ error: 'Failed to update task' });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -85,6 +105,12 @@ export const useTaskStore = create<TaskStore>((set, get) => ({
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('TaskStore/deleteTask failed', 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' });
|
set({ error: 'Failed to delete task' });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user