import { NextRequest, NextResponse } from 'next/server'; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; import { serverSearchMail, hydrateMailRefs } from '@/lib/ai/retrieval/mail-embeddings'; import { logger } from '@/lib/logger'; export const runtime = 'nodejs'; const MAX_QUERY_CHARS = 512; const DEFAULT_LIMIT = 6; /** * POST /api/ai/retrieve — the server embedding leg (docs/AI-ASSISTANT-CONCEPT.md * §7 step 2). Real JMAP fetch + real Ollama embeddings + real cosine ranking * (lib/ai/retrieval/mail-embeddings.ts), not a mock. * * ACL note (§7 step 2b): this only ever embeds/searches the *authenticated * session's own* JMAP account — there is no shared-mailbox fan-out to * pre-filter yet, since group accounts are still deferred entirely (matches * the doc's own "shared-mailbox retrieval ships server-only" decision, which * itself hasn't been reached because there's no group account to retrieve * from). Nothing here can leak across accounts because nothing crosses the * account boundary in the first place. */ export async function POST(request: NextRequest) { const auth = await getStalwartCredentials(request); if (!auth) { return NextResponse.json({ error: 'not authenticated' }, { status: 401 }); } if (!process.env.AI_SERVER_BASE_URL) { return new NextResponse(null, { status: 404 }); } let body: { query?: unknown; limit?: unknown }; try { body = await request.json(); } catch { return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 }); } const query = typeof body.query === 'string' ? body.query.trim() : ''; if (!query) { return NextResponse.json({ error: 'query is required' }, { status: 400 }); } if (query.length > MAX_QUERY_CHARS) { return NextResponse.json({ error: 'query too long' }, { status: 400 }); } const limit = typeof body.limit === 'number' ? Math.min(Math.max(Math.trunc(body.limit), 1), 20) : DEFAULT_LIMIT; try { const scored = await serverSearchMail(auth.serverUrl, auth.authHeader, query, limit); const chunks = await hydrateMailRefs(auth.serverUrl, auth.authHeader, scored.map((s) => s.ref)); const contextBlock = chunks .map((c, i) => `[${i + 1}] Subject: ${c.title}\n${c.text}`) .join('\n\n'); return NextResponse.json({ ok: true, hits: chunks.map((c, i) => ({ ref: c.ref, title: c.title, snippet: c.text.slice(0, 200), rank: i + 1 })), contextBlock, }, { headers: { 'Cache-Control': 'no-store' } }); } catch (cause) { logger.error('ai retrieve failed', { error: cause instanceof Error ? cause.message : String(cause) }); return NextResponse.json({ error: 'retrieval unavailable' }, { status: 502 }); } }