// GET /api/offline/search?q=...&types=mail,calendar&limit=20 // // THE RETRIEVAL SURFACE. This is what an AI/RAG feature calls to gather // relevant context from the user's own mail, calendar, contacts and files // before prompting a model - hence the `snippet` on every hit and the // `contextBlock` convenience field, which is the same information already // flattened into text a prompt can carry directly. // // Read-only: it never touches the network and never writes. Gated identically // to the reindex route. import { NextRequest, NextResponse } from 'next/server'; import { logger } from '@/lib/logger'; import { isSqlcipherAvailable } from '@/lib/mail-index/binding'; import { hasKeyChannel, IndexKeyError, withIndexKey } from '@/lib/mail-index/key'; import { getStoreDir } from '@/lib/mail-index/paths'; import { IndexSessionError, resolveIndexSession } from '@/lib/mail-index/reindex'; import { isContentType, MailIndex, MailIndexUnavailableError, type ContentType, type SearchHit, } from '@/lib/mail-index/store'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; /** * One hit as a plain text block, ready to be concatenated into a prompt. * Kept server-side so every caller (a chat feature, a future agent, a test) * formats context the same way rather than each inventing its own. */ function toContextBlock(hit: SearchHit): string { const label: Record = { mail: 'EMAIL', calendar: 'CALENDAR EVENT', contact: 'CONTACT', file: 'FILE', }; const lines = [`[${label[hit.contentType]}] ${hit.title}`]; if (hit.occurredAt) lines.push(`Date: ${hit.occurredAt}`); if (hit.people) lines.push(`People: ${hit.people}`); const path = hit.metadata?.path; if (typeof path === 'string' && path) lines.push(`Path: ${path}`); if (hit.snippet) lines.push(`Excerpt: ${hit.snippet}`); return lines.join('\n'); } export async function GET(request: NextRequest) { if (!getStoreDir()) { return new NextResponse(null, { status: 404 }); } if (!hasKeyChannel() || !isSqlcipherAvailable()) { return NextResponse.json( { error: 'Encrypted local index is unavailable in this process.', code: 'unavailable' }, { status: 503 }, ); } const params = request.nextUrl.searchParams; const query = (params.get('q') ?? '').trim(); const wantStats = params.get('stats') === 'true'; if (!query && !wantStats) { return NextResponse.json({ error: 'Missing q parameter' }, { status: 400 }); } if (query.length > 512) { return NextResponse.json({ error: 'Query too long' }, { status: 400 }); } const types = (params.get('types') ?? '') .split(',') .map((t) => t.trim()) .filter(isContentType); const limitRaw = Number(params.get('limit') ?? '20'); const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(Math.trunc(limitRaw), 1), 100) : 20; try { const session = await resolveIndexSession(request); const storeDir = getStoreDir(); if (!storeDir) return new NextResponse(null, { status: 404 }); const payload = await withIndexKey(session.accountId, (key) => { const index = MailIndex.open({ storeDir, accountId: session.accountId, key }); try { const stats = index.stats(); if (!query) return { hits: [] as SearchHit[], stats }; return { hits: index.search({ query, types, limit }), stats: wantStats ? stats : undefined }; } finally { index.close(); } }); return NextResponse.json( { ok: true, query, types: types.length > 0 ? types : 'all', count: payload.hits.length, hits: payload.hits, // Everything a prompt needs, pre-joined in rank order. contextBlock: payload.hits.map(toContextBlock).join('\n\n---\n\n'), ...(payload.stats ? { stats: payload.stats } : {}), }, { headers: { 'Cache-Control': 'no-store' } }, ); } catch (error) { if (error instanceof IndexSessionError) { return NextResponse.json({ error: error.message }, { status: error.status }); } if (error instanceof IndexKeyError) { const status = error.code === 'no-secure-storage' ? 503 : 500; return NextResponse.json({ error: error.message, code: error.code }, { status }); } if (error instanceof MailIndexUnavailableError) { return NextResponse.json({ error: error.message, code: 'unavailable' }, { status: 503 }); } logger.error('mail-index search failed', { error: error instanceof Error ? error.message : String(error), }); return NextResponse.json({ error: 'Search failed' }, { status: 500 }); } }