// Renderer-side client for the offline mail replica. // // The replica is EVENT-DRIVEN, exactly like the search index next to it: the // renderer already holds the live JMAP push connection, so a `StateChange` is what // triggers a sync cycle. There is no polling loop and no background worker. // // One cycle is BOUNDED (see lib/offline-replica/sync.ts's BUDGET), so a first // sync of a large mailbox needs several. `unfinishedWork` is the server saying // "call again", and `chainSync` below does that with a hard cap - the cap matters, // because an "unfinished work" signal that is true for a condition the cycle // cannot change is how the mobile client ended up chaining a new cycle every five // seconds forever. // // Every function here is best-effort and never throws: offline storage failing to // update must never break the mail UI. import { apiFetch } from '@/lib/browser-navigation'; import { debug } from '@/lib/debug'; import type { Email, Mailbox } from '@/lib/jmap/types'; import type { StateChange } from '@/lib/jmap/types'; export interface RetentionPolicy { envelopeDays: number; bodyDays: number; maxBodyMB: number; } export interface CycleReport { ok: boolean; unfinishedWork: boolean; bootstrapped: boolean; reconciled: boolean; mailboxesWritten: number; envelopesWritten: number; envelopesDeleted: number; bodiesWritten: number; bodiesEvicted: number; coveragePhase: string; resyncRequired: boolean; warnings: string[]; errorClass?: string; error?: string; durationMs: number; } export interface ReplicaStats { mailboxes: number; envelopes: number; bodies: number; bodyBytes: number; wantedBodies: number; giveUps: number; newest: string | null; oldest: string | null; fileBytes: number; } export interface ReplicaStatus { ok: boolean; policy: RetentionPolicy; limits: Record; synced: boolean; stats: ReplicaStats | null; coveragePhase: string; coveredFrom?: string | null; resyncRequired: boolean; lastCycleAt: number | null; lastCycleOk: boolean | null; lastCycleError?: string | null; } /** Set once the server says the feature isn't there, so we stop asking. */ let knownUnavailable = false; let inFlight: Promise | null = null; function slotQuery(slot?: number, extra?: string): string { const params = new URLSearchParams(); if (typeof slot === 'number') params.set('slot', String(slot)); const base = params.toString(); if (extra && base) return `?${base}&${extra}`; if (extra) return `?${extra}`; return base ? `?${base}` : ''; } /** True when the replica is known to be absent (not the desktop shell, or gated off). */ export function isReplicaUnavailable(): boolean { return knownUnavailable; } export function resetReplicaAvailability(): void { knownUnavailable = false; } /** * Runs ONE cycle. Single-flighted on the renderer as well as the server, so a * burst of deliveries coalesces instead of queueing N overlapping requests that * the server would then serialise anyway. */ export async function syncOnce( opts: { slot?: number; policy?: RetentionPolicy; forceResync?: boolean } = {}, ): Promise { if (knownUnavailable) return null; if (inFlight) return inFlight; const run = (async (): Promise => { try { const response = await apiFetch(`/api/offline/sync${slotQuery(opts.slot)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ policy: opts.policy, forceResync: opts.forceResync === true }), }); // 404 = not the desktop shell. Permanent for this page load; stop asking so // a busy mailbox doesn't post per delivery. if (response.status === 404) { knownUnavailable = true; return null; } if (response.status === 503) { const body = await response.json().catch(() => ({})); // A transport-class 503 means the BACKEND is unreachable, which is normal // and temporary - it must not latch the feature off for the session. Only // a missing binding / key channel does that. const code = typeof body?.code === 'string' ? body.code : ''; if (code === 'no-binding' || code === 'no-key-channel' || code === 'unavailable') { knownUnavailable = true; return null; } return null; } if (!response.ok) return null; const body = await response.json(); debug.log('push', '[replica] cycle', body?.report); return (body?.report ?? null) as CycleReport | null; } catch { return null; } finally { inFlight = null; } })(); inFlight = run; return run; } /** Hard cap on chained cycles per trigger. */ export const MAX_CHAINED_CYCLES = 12; /** * Runs cycles while the server reports unfinished work. * * The cap is the whole point. `unfinishedWork` is a hint, and a hint that stays * true for something the cycle cannot resolve turns into an endless chain - which * is exactly what happened on the mobile client when a body-queue counter reported * attempted rather than inserted rows. The server-side fixes make that * self-terminating; this cap means even a future regression costs a bounded number * of requests rather than an infinite loop. */ export async function chainSync( opts: { slot?: number; max?: number; onReport?: (report: CycleReport) => void } = {}, ): Promise { const max = Math.min(opts.max ?? MAX_CHAINED_CYCLES, MAX_CHAINED_CYCLES); let last: CycleReport | null = null; for (let i = 0; i < max; i++) { const report = await syncOnce({ slot: opts.slot }); if (!report) return last; last = report; opts.onReport?.(report); if (!report.ok || !report.unfinishedWork) return report; } return last; } /** The push-driven entry point. Fire-and-forget: the mail UI must not wait on it. */ export function syncOnStateChange(change: StateChange, opts: { slot?: number } = {}): void { if (knownUnavailable) return; // Only mail-shaped changes are worth a cycle. A `Mailbox` state change alone is // usually just an unread-count move, but the replica DOES hold those counts, so // unlike the search index it is worth reacting to. const relevant = Object.values(change.changed ?? {}).some( (perAccount) => perAccount && (perAccount.Email || perAccount.Mailbox), ); if (!relevant) return; void syncOnce({ slot: opts.slot }); } export async function fetchReplicaStatus(slot?: number): Promise { try { const response = await apiFetch(`/api/offline/status${slotQuery(slot)}`); if (!response.ok) return null; return (await response.json()) as ReplicaStatus; } catch { return null; } } export async function updateRetentionPolicy( policy: RetentionPolicy, slot?: number, ): Promise { try { const response = await apiFetch(`/api/offline/status${slotQuery(slot)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(policy), }); return response.ok; } catch { return false; } } export async function purgeReplica(slot?: number): Promise { try { const response = await apiFetch(`/api/offline/status${slotQuery(slot)}`, { method: 'DELETE' }); return response.ok; } catch { return false; } } // ── reads ─────────────────────────────────────────────────────────────────── interface ReadEnvelope { ok?: boolean; available?: boolean; error?: string; data?: T; } async function read(query: string): Promise<(T & { available: boolean }) | null> { if (knownUnavailable) return null; try { const response = await apiFetch(`/api/offline/mail${query}`); if (response.status === 404) { knownUnavailable = true; return null; } if (!response.ok) return null; const body = (await response.json()) as ReadEnvelope & Record; if (body?.available !== true) return null; return body as unknown as T & { available: boolean }; } catch { return null; } } export async function readOfflineMailboxes(slot?: number): Promise { const body = await read<{ mailboxes: Mailbox[] }>(slotQuery(slot, 'kind=mailboxes')); return body?.mailboxes ?? null; } export async function readOfflineList( mailboxId: string | null, opts: { limit?: number; offset?: number; slot?: number } = {}, ): Promise<{ emails: Email[]; total: number; hasMore: boolean } | null> { const params = new URLSearchParams({ kind: 'list' }); if (mailboxId !== null) params.set('mailboxId', mailboxId); if (opts.limit !== undefined) params.set('limit', String(opts.limit)); if (opts.offset !== undefined) params.set('offset', String(opts.offset)); const body = await read<{ emails: Email[]; total: number; hasMore: boolean }>( slotQuery(opts.slot, params.toString()), ); if (!body) return null; return { emails: body.emails ?? [], total: body.total ?? 0, hasMore: body.hasMore === true }; } export async function readOfflineMessage( id: string, slot?: number, ): Promise<{ email: Email | null; hasBody: boolean } | null> { const params = new URLSearchParams({ kind: 'message', id }); const body = await read<{ email: Email | null; hasBody: boolean }>( slotQuery(slot, params.toString()), ); if (!body) return null; return { email: body.email ?? null, hasBody: body.hasBody === true }; }