// GET /api/offline/mail?kind=mailboxes|list|message - the OFFLINE READ SURFACE. // // THIS ROUTE MUST NEVER MAKE A NETWORK CALL. That is the whole feature: it is // consulted precisely when the backend is unreachable, so a JMAP session fetch to // learn the account id would fail for the exact reason the route was called. The // account is resolved from the request's own encrypted `jmap_stalwart_ctx` cookie // (a local decrypt) and from the account ids the store already holds rows for. // // It is a FALLBACK, not a cache in front of the server - see `read.ts`'s header // for the coherence rules that depend on that, and `lib/offline-fallback-client.ts` // for the one place that decides a read has genuinely failed. import { NextRequest, NextResponse } from 'next/server'; import { readEnvelopePage, readMailboxes, readMessage, } from '@/lib/offline-replica/read'; import { resolveIndexSession, resolveReadAccountId, withReplica, } from '@/lib/offline-replica/engine'; import { gateReplicaRoute, NO_STORE, replicaErrorResponse } from '@/lib/offline-replica/route-gate'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; const MAX_LIMIT = 200; export async function GET(request: NextRequest) { const gated = gateReplicaRoute(); if (gated) return gated; const params = request.nextUrl.searchParams; const kind = params.get('kind') ?? 'mailboxes'; if (kind !== 'mailboxes' && kind !== 'list' && kind !== 'message') { return NextResponse.json({ error: 'kind must be mailboxes, list or message' }, { status: 400 }); } try { const session = await resolveIndexSession(request); const payload = await withReplica(session.accountId, (store) => { const jmapAccountId = resolveReadAccountId(store, params.get('jmapAccountId')); if (!jmapAccountId) { // Nothing synced yet for this account. Not an error - the caller falls // back to whatever it would have shown without a replica. return { empty: true as const }; } if (kind === 'mailboxes') { return { empty: false as const, jmapAccountId, mailboxes: readMailboxes(store, jmapAccountId) }; } if (kind === 'list') { const rawLimit = Number(params.get('limit') ?? '50'); const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), MAX_LIMIT) : 50; const rawOffset = Number(params.get('offset') ?? '0'); const offset = Number.isFinite(rawOffset) ? Math.max(Math.trunc(rawOffset), 0) : 0; // An absent mailboxId means "everything", which is what the unified views // ask for; an empty string is a caller bug and must not silently widen. const mailboxParam = params.get('mailboxId'); const mailboxId = mailboxParam === null ? null : mailboxParam; if (mailboxId === '') { return { empty: true as const }; } const page = readEnvelopePage(store, jmapAccountId, mailboxId, limit, offset); return { empty: false as const, jmapAccountId, ...page }; } const id = params.get('id'); if (!id || id.length > 256) return { empty: true as const }; const message = readMessage(store, jmapAccountId, id); if (!message) return { empty: false as const, jmapAccountId, email: null, hasBody: false }; return { empty: false as const, jmapAccountId, email: message.email, hasBody: message.hasBody, }; }); if (payload.empty) { return NextResponse.json({ ok: true, available: false }, { headers: NO_STORE }); } return NextResponse.json({ ok: true, available: true, ...payload }, { headers: NO_STORE }); } catch (error) { return replicaErrorResponse(error, 'offline read'); } }