// POST /api/offline/reindex - write mail/calendar/contacts/files into the // encrypted local search index for the calling session's account. // // The PRIMARY caller is the renderer's live JMAP push handler: when a // StateChange arrives it posts the ids that changed, so indexing is reactive to // each delivery rather than periodic. `{ catchUp: true }` (no ids) is the // fallback used at app launch to backfill whatever changed while the app was // closed. // // GATED: returns 404 unless VNCMAIL_DESKTOP_STORE_DIR is set, which only // electron/main.ts does. The same standalone server artifact runs in the // multi-tenant production Docker image, where this feature must not exist at // all - 404 rather than 403 so nothing learns the route is there. import { NextRequest, NextResponse } from 'next/server'; import { logger } from '@/lib/logger'; import { isSqlcipherAvailable } from '@/lib/mail-index/binding'; import { hasKeyChannel, IndexKeyError } from '@/lib/mail-index/key'; import { getStoreDir } from '@/lib/mail-index/paths'; import { IndexSessionError, MAX_IDS_PER_CALL, normalizeWindowDays, resolveIndexSession, runIndex, type IndexRequest, } from '@/lib/mail-index/reindex'; import { CONTENT_TYPES, isContentType, type ContentType } from '@/lib/mail-index/store'; import { JmapIndexError } from '@/lib/mail-index/jmap'; import { isFeatureEnabledServer } from '@/lib/admin/feature-gate'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; function parseIdMap(raw: unknown): Partial> | undefined { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; const out: Partial> = {}; for (const [key, value] of Object.entries(raw as Record)) { if (!isContentType(key) || !Array.isArray(value)) continue; const ids = value .filter((v): v is string => typeof v === 'string' && v.length > 0 && v.length <= 256) .slice(0, MAX_IDS_PER_CALL); if (ids.length > 0) out[key] = ids; } return Object.keys(out).length > 0 ? out : undefined; } export async function POST(request: NextRequest) { if (!isFeatureEnabledServer('aiAssistantEnabled')) { return NextResponse.json({ error: 'Feature disabled' }, { status: 403 }); } if (!getStoreDir()) { return new NextResponse(null, { status: 404 }); } if (!hasKeyChannel()) { return NextResponse.json( { error: 'The local index has no key channel in this process.', code: 'no-key-channel' }, { status: 503 }, ); } if (!isSqlcipherAvailable()) { // The native binding is an optionalDependency, so "not installed" is a // normal state on platforms without a prebuild - not an error to log loudly. return NextResponse.json( { error: 'Encrypted local index is unavailable on this platform.', code: 'no-binding' }, { status: 503 }, ); } let body: Record = {}; try { const text = await request.text(); if (text.trim()) body = JSON.parse(text) as Record; } catch { return NextResponse.json({ error: 'Malformed JSON body' }, { status: 400 }); } const rawTypes = Array.isArray(body.types) ? body.types.filter(isContentType) : []; const req: IndexRequest = { types: rawTypes.length > 0 ? rawTypes : undefined, ids: parseIdMap(body.ids), removed: parseIdMap(body.removed), // Pruning is a catch-up concern; a single-delivery call shouldn't scan. prune: body.catchUp === true, // `undefined` (absent) means "use the default"; an explicit null means // keep everything. normalizeWindowDays() in runIndex clamps anything // unexpected, since this value drives deletion. windowDays: body.windowDays === undefined ? undefined : normalizeWindowDays(body.windowDays), }; try { const session = await resolveIndexSession(request); const result = await runIndex(session, req); return NextResponse.json( { ok: true, written: result.written, skipped: result.skipped, errors: result.errors, durationMs: result.durationMs, types: CONTENT_TYPES, }, { headers: { 'Cache-Control': 'no-store' } }, ); } catch (error) { if (error instanceof IndexSessionError) { return NextResponse.json({ error: error.message }, { status: error.status }); } if (error instanceof JmapIndexError) { return NextResponse.json({ error: error.message }, { status: error.status }); } if (error instanceof IndexKeyError) { // no-secure-storage is the Linux-without-a-keyring refusal: a real, // expected outcome with a user-facing explanation, not a server fault. const status = error.code === 'no-secure-storage' ? 503 : 500; return NextResponse.json({ error: error.message, code: error.code }, { status }); } logger.error('mail-index reindex failed', { error: error instanceof Error ? error.message : String(error), }); return NextResponse.json({ error: 'Reindex failed' }, { status: 500 }); } }