The two things that made real questions fail against a correctly-populated index, both fixed at the root. RETENTION (A1). `INDEX_WINDOW_DAYS = 30` was not merely a fetch bound — catch-up also PRUNED mail older than it, so "summarise everything from July" was unanswerable in August because the rows had been deleted, while the UI said only that nothing matched. Now a user-visible setting (Settings → About & Data): 30 days / 3 months / 1 year / everything, defaulting to 1 YEAR per the product owner. The window bounds the fetch AND the prune from one value so the two can never disagree and delete what was just written; "everything" skips pruning entirely rather than falling back to some default bound. The per-pass ceiling scales with the window (500/30d, hard cap 20k) because 500 messages is right for a month and nonsense for "everything". Email/query now omits the `after` filter entirely when unbounded — Stalwart rejects a malformed filter rather than treating `undefined` as unset. RECENCY (A2). Keyword search structurally cannot answer a question about WHEN: bm25 ranks by term overlap, so "who sent the last email" matches documents containing the word "last", and "all mails in July" matches documents containing "July" — not documents dated in July. Both were asked by a real user and both failed. New lib/mail-index/recency.ts detects time intent (English + German, since the UI ships German) and turns it into a date RANGE; new MailIndex.recent() answers it with an ordered scan over the already-indexed `occurred_at`. The route ADDS these hits to the keyword hits rather than replacing them — "what did the last mail from Anna say" is both kinds of question at once. Timezone subtlety worth knowing: bounds are built from LOCAL calendar boundaries and serialised as UTC instants, so "July" covers the user's July. A mail at 00:30 local on 1 July belongs to it even though its stored UTC timestamp reads 30 June. My first test asserted the ISO string prefix, which would have enshrined the opposite and passed only in UTC — the tests now assert the local-time property instead. SCOPE, stated by the product owner and now enforced structurally: the assistant only ever sees the mailbox the user is signed in to. Both retrieval legs resolve the active account (local leg by cookie slot, server leg by the session's own JMAP account); there is deliberately no fan-out across connected or shared mailboxes, and adding one would be a policy change, not a feature. Gate: tsc clean, eslint clean, 2520/2520 tests (8 new for recency intent), build clean.
150 lines
6.3 KiB
TypeScript
150 lines
6.3 KiB
TypeScript
// 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';
|
|
import { detectRecencyIntent } from '@/lib/mail-index/recency';
|
|
|
|
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<ContentType, string> = {
|
|
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 };
|
|
// 'any': this route is the AI/RAG retrieval surface (see module
|
|
// header) - its one real caller sends natural-language questions,
|
|
// not deliberate search-box keywords, so strict AND-every-token
|
|
// matching (the default) drops nearly all of them. See
|
|
// toFtsMatchQueryAny's docstring for the confirmed-live failure.
|
|
const keywordHits = index.search({ query, types, limit, mode: 'any' });
|
|
|
|
// RECENCY leg. Keyword search structurally cannot answer "the last
|
|
// mail" or "everything from July" (see lib/mail-index/recency.ts), so
|
|
// when the question is really about time, add a date-ordered slice.
|
|
// ADDED to the keyword hits rather than replacing them: "what did the
|
|
// last mail from Anna say" is both a time question and a content one.
|
|
const intent = detectRecencyIntent(query);
|
|
if (!intent) {
|
|
return { hits: keywordHits, stats: wantStats ? stats : undefined };
|
|
}
|
|
const recentHits = index.recent({
|
|
types, limit: Math.min(intent.limit, limit * 3), since: intent.since, until: intent.until,
|
|
});
|
|
const seen = new Set(keywordHits.map((h) => `${h.contentType}:${h.id}`));
|
|
const merged = [...keywordHits];
|
|
for (const hit of recentHits) {
|
|
const key = `${hit.contentType}:${hit.id}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
merged.push(hit);
|
|
}
|
|
return { hits: merged, stats: wantStats ? stats : undefined, recency: intent };
|
|
} 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 } : {}),
|
|
// Present when the question was read as a time question — lets the
|
|
// client say "these are the newest N" instead of implying relevance
|
|
// ranking it did not do.
|
|
...(payload.recency ? { recency: payload.recency } : {}),
|
|
},
|
|
{ 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 });
|
|
}
|
|
}
|