feat(mail-index): 1-year retention by default + a recency retrieval leg

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.
This commit is contained in:
Bernd Rodler
2026-08-07 09:45:10 +02:00
parent f6fc34fab3
commit 87336981d3
11 changed files with 406 additions and 22 deletions
+63
View File
@@ -374,6 +374,69 @@ export class MailIndex {
}));
}
/**
* Newest documents by date, ignoring keyword relevance entirely.
*
* The retrieval leg for RECENCY questions — "what was the last mail", "who
* wrote most recently", "everything from July". Full-text search cannot
* answer those even with a perfect index: bm25 ranks by term overlap and has
* no notion of "latest", so "the last mail" matches documents containing the
* word "last". Two real questions failed exactly that way before this
* existed. `doc(jmap_account_id, content_type, occurred_at DESC)` is already
* indexed, so this is an ordered range scan, not a table sweep.
*
* `since`/`until` are ISO strings, both optional — a month-name question
* becomes a bounded range, a bare "latest" becomes an unbounded top-N.
*/
recent(opts: {
types?: readonly ContentType[];
limit?: number;
since?: string;
until?: string;
snippetChars?: number;
}): SearchHit[] {
const limit = Math.min(Math.max(opts.limit ?? 10, 1), 200);
const snippetChars = Math.min(Math.max(opts.snippetChars ?? 400, 80), 2000);
const types = opts.types && opts.types.length > 0 ? opts.types : null;
const where: string[] = ['d.occurred_at IS NOT NULL'];
const params: Array<string | number> = [];
if (types) {
where.push(`d.content_type IN (${types.map(() => '?').join(',')})`);
params.push(...types);
}
if (opts.since) { where.push('d.occurred_at >= ?'); params.push(opts.since); }
if (opts.until) { where.push('d.occurred_at <= ?'); params.push(opts.until); }
const rows = this.db
.prepare(`
SELECT d.content_type, d.id, d.jmap_account_id, d.title, d.people,
d.occurred_at, d.metadata_json,
substr(f.body, 1, ${snippetChars}) AS snip
FROM doc d
LEFT JOIN doc_fts f ON f.rowid = d.rowid
WHERE ${where.join(' AND ')}
ORDER BY d.occurred_at DESC
LIMIT ?
`)
.all([...params, limit]);
return rows.map((r) => ({
contentType: String(r.content_type) as ContentType,
id: String(r.id),
jmapAccountId: String(r.jmap_account_id),
title: String(r.title ?? ''),
people: String(r.people ?? ''),
occurredAt: r.occurred_at === null || r.occurred_at === undefined ? null : String(r.occurred_at),
metadata: safeParseObject(r.metadata_json),
// No bm25 score here: these are ordered by time, not relevance, and
// faking a relevance number would let the fusion step rank them as if
// they had been scored.
score: 0,
snippet: String(r.snip ?? ''),
}));
}
/** Per-type counts and freshness, for the Settings UI and for debugging. */
stats(): Array<{ contentType: string; count: number; newest: string | null; indexedAt: number | null }> {
return this.db