// The offline READ path: stored rows back into the exact `Email` / `Mailbox` // shapes `lib/jmap/client.ts` returns, so the renderer cannot tell the difference. // // COHERENCE (the review's H3, the one finding that genuinely returns once a // replica exists). The webmail already does LOCAL DELTA ARITHMETIC on mailbox // unread counts and totals for mark-read/move/delete, with a comment referencing // a production bug from getting that cutoff wrong. A read-only cache sitting // underneath that arithmetic needs an explicit coherence story, and the story is: // // THE REPLICA IS A FALLBACK, NEVER A CACHE IN FRONT OF THE SERVER. // // It is consulted only after a read has actually failed at the transport level // (see `lib/offline-fallback-client.ts`), so an online session never sees a // replica count and the arithmetic never operates on replica numbers. While // offline, counts are whatever the last successful sync recorded and any local // mark-read drift is bounded, invisible in the same session, and repaired by the // next `Mailbox/changes` - which is the authoritative source for all four // counters. The alternative - serving the replica first and reconciling - is what // would need the coherence rules the review asked for, and is not what this does. // // Every shape here is intentionally what the ONLINE path produces, including // `parseEmailHeaders`' derived security fields, because // `components/email/email-viewer.tsx` reads them directly. In particular // `bodyValues` must be keyed by the same partIds as `htmlBody`/`textBody`, or the // viewer's `isBodyLoading` gate sits on its skeleton forever. import { parseAuthenticationResults, parseSpamLLM, parseSpamScore } from '@/lib/email-headers'; import type { Email, EmailAddress, Mailbox } from '@/lib/jmap/types'; import type { ReplicaStore } from './store'; import type { MailboxRow } from './types'; const DEFAULT_RIGHTS: Mailbox['myRights'] = { mayReadItems: true, mayAddItems: false, mayRemoveItems: false, maySetSeen: false, maySetKeywords: false, mayCreateChild: false, mayRename: false, mayDelete: false, maySubmit: false, }; function parseJson(raw: unknown, fallback: T): T { if (typeof raw !== 'string' || raw.length === 0) return fallback; try { return JSON.parse(raw) as T; } catch { return fallback; } } export function rowToMailbox(row: MailboxRow): Mailbox { return { id: row.id, name: row.name, parentId: row.parentId ?? undefined, role: row.role ?? undefined, sortOrder: row.sortOrder ?? 0, totalEmails: row.totalEmails ?? 0, unreadEmails: row.unreadEmails ?? 0, totalThreads: row.totalThreads ?? 0, unreadThreads: row.unreadThreads ?? 0, // Offline, the rights that matter are the read ones. Every mutating right // defaults to false so no UI offers an action that cannot possibly succeed // with no network; the real rights return with the next sync. myRights: parseJson(row.myRightsJson, DEFAULT_RIGHTS), isSubscribed: row.isSubscribed, }; } /** The envelope tier, as `getEmails()` would return it. */ export function rowToEnvelope(row: Record, mailboxIds: readonly string[]): Email { const keywords = parseJson>(row.keywords_json, {}); const mailboxMap: Record = {}; for (const id of mailboxIds) mailboxMap[id] = true; return { id: String(row.id), threadId: typeof row.thread_id === 'string' ? row.thread_id : String(row.id), mailboxIds: mailboxMap, keywords, size: typeof row.size === 'number' ? row.size : 0, receivedAt: String(row.received_at), from: parseJson(row.from_json, undefined), to: parseJson(row.to_json, undefined), cc: parseJson(row.cc_json, undefined), subject: typeof row.subject === 'string' ? row.subject : undefined, preview: typeof row.preview === 'string' ? row.preview : undefined, hasAttachment: row.has_attachment !== 0, blobId: typeof row.blob_id === 'string' ? row.blob_id : undefined, }; } interface StoredBody { sentAt?: string; bcc?: EmailAddress[]; replyTo?: EmailAddress[]; textBody?: Email['textBody']; htmlBody?: Email['htmlBody']; bodyValues?: Email['bodyValues']; attachments?: Email['attachments']; messageId?: string; inReplyTo?: string[]; references?: string[]; headers?: unknown; bodyStructure?: Email['bodyStructure']; } /** * Normalises JMAP's `headers` array into the record shape the renderer expects * and derives the security fields, reproducing what `JMAPClient`'s private * `parseEmailHeaders` does on the online path. * * Reproduced here rather than imported because `lib/jmap/client.ts` is a * 7400-line renderer object that holds credentials in instance fields, opens push * connections and wires itself into Zustand stores - importing it into a server * route would drag all of that into the server bundle. The parsing HELPERS in * `lib/email-headers` are shared, so the only duplicated logic is the array->record * flattening. */ function applyHeaders(email: Email, rawHeaders: unknown): void { let record: Record; if (Array.isArray(rawHeaders)) { record = {}; for (const header of rawHeaders as Array<{ name?: string; value?: string }>) { if (!header?.name || !header?.value) continue; const existing = record[header.name]; if (existing) { record[header.name] = Array.isArray(existing) ? [...existing, header.value] : [existing, header.value]; } else { record[header.name] = header.value; } } } else if (rawHeaders && typeof rawHeaders === 'object') { record = rawHeaders as Record; } else { return; } email.headers = record; const authResults = record['Authentication-Results']; if (authResults) { const value = Array.isArray(authResults) ? authResults.join('; ') : authResults; email.authenticationResults = parseAuthenticationResults(value); } for (const name of ['X-Spam-Score', 'X-Spam-Status', 'X-Spam-Result', 'X-Rspamd-Score']) { const header = record[name]; if (!header) continue; const value = Array.isArray(header) ? header[0] : header; const parsed = parseSpamScore(String(value).trim()); if (parsed) { email.spamScore = parsed.score; email.spamStatus = parsed.status; break; } } const llm = record['X-Spam-LLM']; if (llm) { const parsed = parseSpamLLM(String(Array.isArray(llm) ? llm[0] : llm)); if (parsed) email.spamLLM = parsed; } } export interface OfflineMessage { email: Email; /** False when only the envelope is held, so the caller can say so rather than render blank. */ hasBody: boolean; } /** One full message, envelope + body, exactly as `getEmail()` would return it. */ export function readMessage( store: ReplicaStore, jmapAccountId: string, id: string, ): OfflineMessage | null { const row = store.getEnvelopeRaw(jmapAccountId, id); if (!row) return null; const email = rowToEnvelope(row, store.mailboxIdsFor(jmapAccountId, id)); const bodyJson = store.getBody(jmapAccountId, id); if (!bodyJson) return { email, hasBody: false }; const body = parseJson(bodyJson, {}); email.sentAt = body.sentAt; email.bcc = body.bcc; email.replyTo = body.replyTo; email.textBody = body.textBody; email.htmlBody = body.htmlBody; email.bodyValues = body.bodyValues; email.attachments = body.attachments; email.messageId = body.messageId; email.inReplyTo = body.inReplyTo; email.references = body.references; email.bodyStructure = body.bodyStructure; applyHeaders(email, body.headers); // A body row whose `bodyValues` came back empty would render as a blank // message and, worse, leave the viewer's loading gate stuck. Report it as // "envelope only" instead, which the UI can explain. const hasBody = !!email.bodyValues && Object.keys(email.bodyValues).length > 0; return { email, hasBody }; } export function readMailboxes(store: ReplicaStore, jmapAccountId: string): Mailbox[] { return store.listMailboxes(jmapAccountId).map(rowToMailbox); } export function readEnvelopePage( store: ReplicaStore, jmapAccountId: string, mailboxId: string | null, limit: number, offset: number, ): { emails: Email[]; total: number; hasMore: boolean } { const { rows, total } = store.listEnvelopes(jmapAccountId, mailboxId, limit, offset); const emails = rows.map((row) => rowToEnvelope(row, store.mailboxIdsFor(jmapAccountId, String(row.id))), ); return { emails, total, hasMore: offset + emails.length < total }; }