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)
269 lines
6.9 KiB
TypeScript
269 lines
6.9 KiB
TypeScript
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;
|
|
}
|