// The read-path fallback. Wraps an `IJMAPClient` so that when a mail read fails // because the network is down, the answer comes from the encrypted offline replica // instead of an empty list. // // WHY THIS SHAPE, AND NOT A CACHE. The replica is consulted ONLY after a read has // genuinely failed at the transport level. That ordering is the whole coherence // story for the design review's H3: the webmail does local delta arithmetic on // mailbox unread counts for mark-read/move/delete, and if the replica sat in FRONT // of the server that arithmetic would operate on replica numbers and need // reconciliation rules. Behind the server, an online session never sees a replica // value at all, and while offline any count drift is bounded and repaired by the // next `Mailbox/changes`. // // WHY IT IS NOT ENOUGH TO LOOK AT THE RESULT. `lib/jmap/client.ts`'s read methods // swallow their own errors and return plausible success: `getEmails()` returns an // empty page, `getEmail()` returns `null`, `getMailboxes()` returns a SYNTHETIC // single Inbox. Falling back on those shapes alone would serve stale replica rows // for a folder the user had genuinely just emptied. So the test is TWO-PART: a // suspicious result AND a `fetch` rejection recorded during that exact call // (`lib/jmap/transport-health.ts`). A 4xx, a 429 or a JMAP method error all mean // the server answered, so none of them triggers a fallback. // // Mutates the instance rather than wrapping it in a Proxy: `JMAPClient` is a large // class whose methods call each other through `this`, and instance patching keeps // `this` identity exactly as it was. Idempotent, so re-wrapping the same client is // harmless. import { generateAccountId } from '@/lib/account-utils'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { Email, Mailbox } from '@/lib/jmap/types'; import { transportFailureCount } from '@/lib/jmap/transport-health'; import { isReplicaUnavailable, readOfflineList, readOfflineMailboxes, readOfflineMessage, } from '@/lib/offline-replica-client'; const WRAPPED = Symbol.for('vncmail.offlineFallback.wrapped'); /** * A `getMailboxes()` result that is really the client's offline placeholder. * * `client.ts` fabricates exactly this on failure: one mailbox, id `INBOX`, role * `inbox`, zero counts. Matching it precisely matters - a real server that happens * to return a single inbox has a real id and real counts. */ function isSyntheticMailboxList(mailboxes: readonly Mailbox[]): boolean { return ( mailboxes.length === 1 && mailboxes[0]?.id === 'INBOX' && mailboxes[0]?.totalEmails === 0 && mailboxes[0]?.unreadEmails === 0 ); } /** Resolves this client's cookie slot, so a multi-account shell reads the right replica. */ async function slotFor(client: IJMAPClient): Promise { try { const { useAccountStore } = await import('@/stores/account-store'); const id = generateAccountId(client.getUsername(), client.getServerUrl()); const accounts = useAccountStore.getState().accounts; const match = accounts.find((a) => a.id === id) ?? accounts.find((a) => a.serverIdentifiers?.includes(id)); return match?.cookieSlot; } catch { return undefined; } } /** * True when the replica may answer for this call. * * v1 replicates the PRIMARY mail account only, so a read explicitly scoped to a * delegated/shared account must never be answered from it - the replica simply has * no rows, and answering "empty" would be worse than the client's own empty. */ function scopedToPrimary(client: IJMAPClient, accountId?: string): boolean { if (!accountId) return true; try { return accountId === client.getAccountId(); } catch { return false; } } export function withOfflineFallback(client: T): T { const flagged = client as unknown as Record; if (flagged[WRAPPED]) return client; flagged[WRAPPED] = true; const target = client as unknown as IJMAPClient; const originalGetEmail = target.getEmail.bind(target); const originalGetEmails = target.getEmails.bind(target); const originalGetMailboxes = target.getMailboxes.bind(target); const originalGetAllMailboxes = target.getAllMailboxes.bind(target); target.getEmail = async (emailId: string, accountId?: string): Promise => { const before = transportFailureCount(); const online = await originalGetEmail(emailId, accountId); if (online) return online; if (isReplicaUnavailable()) return online; // `null` alone is ambiguous: it is also what a genuinely-missing id returns. // Only a transport failure during THIS call earns a fallback. if (transportFailureCount() === before) return online; if (!scopedToPrimary(client, accountId)) return online; const offline = await readOfflineMessage(emailId, await slotFor(client)); // An envelope with no body would render blank AND leave the viewer's // `isBodyLoading` gate stuck, so it is not an answer - better to keep the // client's `null` and let the UI say the message is unavailable offline. if (!offline?.email || !offline.hasBody) return online; return offline.email; }; target.getEmails = async ( mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record, ): Promise<{ emails: Email[]; hasMore: boolean; total: number }> => { const before = transportFailureCount(); const online = await originalGetEmails( mailboxId, accountId, limit, position, hasKeyword, pinnedFirst, extraFilter, ); if (online.emails.length > 0) return online; if (isReplicaUnavailable()) return online; if (transportFailureCount() === before) return online; if (!scopedToPrimary(client, accountId)) return online; // A keyword or category filter is a server-side query the replica does not // reproduce. Serving an unfiltered page in its place would silently show the // wrong set, which is worse than showing nothing. if (hasKeyword || extraFilter) return online; const offline = await readOfflineList(mailboxId ?? null, { limit, offset: position, slot: await slotFor(client), }); if (!offline || offline.emails.length === 0) return online; return { emails: offline.emails, hasMore: offline.hasMore, total: offline.total }; }; const mailboxFallback = async ( online: Mailbox[], before: number, accountId?: string, ): Promise => { // Bail out unless the result is EMPTY or is the exact synthetic placeholder. // Testing `length > 1` here was a real bug found by // `lib/__tests__/offline-fallback-client.test.ts`: a server that legitimately // exposes a single mailbox got its real folder - real id, real counts - // replaced by replica rows the moment any unrelated transport blip was // recorded during the call. if (online.length > 0 && !isSyntheticMailboxList(online)) return online; if (isReplicaUnavailable()) return online; if (transportFailureCount() === before) return online; if (!scopedToPrimary(client, accountId)) return online; const offline = await readOfflineMailboxes(await slotFor(client)); if (!offline || offline.length === 0) return online; return offline; }; target.getMailboxes = async (accountId?: string): Promise => { const before = transportFailureCount(); const online = await originalGetMailboxes(accountId); return mailboxFallback(online, before, accountId); }; target.getAllMailboxes = async (): Promise => { const before = transportFailureCount(); const online = await originalGetAllMailboxes(); // `getAllMailboxes` falls back internally to `getMailboxes()`, so an offline // run arrives here as the synthetic single Inbox rather than an empty list. return mailboxFallback(online, before); }; return client; }