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, ): 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 { 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); break; case 'updateEvent': { const up = op.payload as { id: string; updates: Record }; 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); break; case 'updateContact': { const uc = op.payload as { id: string; updates: Record }; 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); break; case 'updateTask': { const ut = op.payload as { id: string; updates: Record }; 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; }