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.
393 lines
15 KiB
TypeScript
393 lines
15 KiB
TypeScript
// A deliberately tiny server-side JMAP client, used only by the indexer.
|
|
//
|
|
// WHY NOT REUSE lib/jmap/client.ts: that class is a 7400-line renderer object.
|
|
// It holds credentials in instance fields, uses `btoa`, opens EventSource /
|
|
// WebSocket push connections, and wires itself into Zustand stores and toast
|
|
// notifications. Importing it into an API route would drag all of that into the
|
|
// server bundle for the sake of four method calls. The existing server-side
|
|
// JMAP code in this repo (lib/auth/verify-jmap-auth.ts) already sets the
|
|
// precedent: plain fetch + an Authorization header.
|
|
//
|
|
// Everything here is stateless - the caller supplies the auth header per call,
|
|
// so there is no resident credential and nothing to invalidate.
|
|
|
|
import type { CalendarEvent, ContactCard, Email, FileNode } from '@/lib/jmap/types';
|
|
|
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
|
|
export const CAP_CORE = 'urn:ietf:params:jmap:core';
|
|
export const CAP_MAIL = 'urn:ietf:params:jmap:mail';
|
|
export const CAP_CALENDARS = 'urn:ietf:params:jmap:calendars';
|
|
export const CAP_CONTACTS = 'urn:ietf:params:jmap:contacts';
|
|
export const CAP_FILENODE = 'urn:ietf:params:jmap:filenode';
|
|
|
|
export class JmapIndexError extends Error {
|
|
status: number;
|
|
constructor(message: string, status = 502) {
|
|
super(message);
|
|
this.name = 'JmapIndexError';
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
export interface JmapSessionInfo {
|
|
apiUrl: string;
|
|
/** Server-confirmed authenticated login (JMAP Session.username). */
|
|
username?: string;
|
|
primaryAccounts: Record<string, string>;
|
|
accounts: Record<string, { name?: string; isPersonal?: boolean; accountCapabilities?: Record<string, unknown> }>;
|
|
capabilities: Record<string, unknown>;
|
|
}
|
|
|
|
/**
|
|
* Pins a URL advertised by the session to the origin we authenticated against.
|
|
*
|
|
* `lib/jmap/client.ts` does the same thing in its rewriteSessionUrls() for the
|
|
* renderer's benefit. Server-side it is a security control, not a convenience:
|
|
* we attach the user's credentials to this URL, so a session document that
|
|
* advertised an `apiUrl` on someone else's host would turn this into a
|
|
* credential-leaking SSRF. Keep the path and query, take the origin from the
|
|
* server URL we were configured with.
|
|
*/
|
|
function pinToServerOrigin(advertised: string, serverUrl: string): string {
|
|
const base = new URL(serverUrl);
|
|
let target: URL;
|
|
try {
|
|
target = new URL(advertised, base);
|
|
} catch {
|
|
throw new JmapIndexError('JMAP session advertised an unusable apiUrl');
|
|
}
|
|
return `${base.origin}${target.pathname}${target.search}`;
|
|
}
|
|
|
|
async function fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
try {
|
|
return await fetch(url, { ...init, signal: controller.signal, redirect: 'manual' });
|
|
} catch (error) {
|
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
throw new JmapIndexError('JMAP request timed out', 504);
|
|
}
|
|
throw new JmapIndexError(`JMAP request failed: ${String(error)}`);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
/** Stalwart 307-redirects /.well-known/jmap to /jmap/session. */
|
|
const MAX_REDIRECTS = 3;
|
|
|
|
export async function fetchJmapSession(serverUrl: string, authHeader: string): Promise<JmapSessionInfo> {
|
|
const base = serverUrl.replace(/\/+$/, '');
|
|
const origin = new URL(base).origin;
|
|
let currentUrl = `${base}/.well-known/jmap`;
|
|
let response: Response | undefined;
|
|
|
|
// Redirects must be followed EXPLICITLY, not with `redirect: 'follow'`: we
|
|
// attach the user's credentials to every hop, so each one has to be checked to
|
|
// still be on the origin we authenticated against. A blind follow would hand
|
|
// the Authorization header to whatever host a misconfigured or hostile session
|
|
// pointed at. Same reasoning (and same bound) as lib/auth/verify-jmap-auth.ts.
|
|
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
response = await fetchWithTimeout(currentUrl, {
|
|
method: 'GET',
|
|
headers: { Authorization: authHeader },
|
|
});
|
|
if (response.status < 300 || response.status >= 400) break;
|
|
|
|
const location = response.headers.get('location');
|
|
if (!location) throw new JmapIndexError('JMAP session redirect had no Location header');
|
|
const next = new URL(location, currentUrl);
|
|
if (next.origin !== origin) {
|
|
throw new JmapIndexError(
|
|
`JMAP session redirected off-origin (${next.origin}); refusing to send credentials there`,
|
|
);
|
|
}
|
|
currentUrl = next.toString();
|
|
}
|
|
|
|
if (!response) throw new JmapIndexError('JMAP session fetch produced no response');
|
|
if (response.status === 401 || response.status === 403) {
|
|
throw new JmapIndexError('JMAP authentication failed', 401);
|
|
}
|
|
if (response.status >= 300 && response.status < 400) {
|
|
throw new JmapIndexError('Too many redirects fetching the JMAP session');
|
|
}
|
|
if (!response.ok) {
|
|
throw new JmapIndexError(`JMAP session fetch failed (${response.status})`);
|
|
}
|
|
const raw = (await response.json().catch(() => null)) as Record<string, unknown> | null;
|
|
if (!raw || typeof raw.apiUrl !== 'string') {
|
|
throw new JmapIndexError('Invalid JMAP session response');
|
|
}
|
|
return {
|
|
apiUrl: pinToServerOrigin(raw.apiUrl, serverUrl),
|
|
username: typeof raw.username === 'string' ? raw.username : undefined,
|
|
primaryAccounts: (raw.primaryAccounts as Record<string, string>) ?? {},
|
|
accounts: (raw.accounts as JmapSessionInfo['accounts']) ?? {},
|
|
capabilities: (raw.capabilities as Record<string, unknown>) ?? {},
|
|
};
|
|
}
|
|
|
|
type MethodCall = [string, Record<string, unknown>, string];
|
|
|
|
/** Raw method-response tuple, `[name, args, callId]`. `name` is 'error' on failure. */
|
|
type MethodResponse = [string, Record<string, unknown>, string];
|
|
|
|
export async function jmapRequest(
|
|
session: JmapSessionInfo,
|
|
authHeader: string,
|
|
using: readonly string[],
|
|
methodCalls: readonly MethodCall[],
|
|
): Promise<MethodResponse[]> {
|
|
const response = await fetchWithTimeout(session.apiUrl, {
|
|
method: 'POST',
|
|
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ using, methodCalls }),
|
|
});
|
|
if (response.status === 401 || response.status === 403) {
|
|
throw new JmapIndexError('JMAP authentication failed', 401);
|
|
}
|
|
if (response.status === 429) {
|
|
throw new JmapIndexError('JMAP server is rate limiting', 429);
|
|
}
|
|
if (!response.ok) {
|
|
throw new JmapIndexError(`JMAP request failed (${response.status})`);
|
|
}
|
|
const data = (await response.json().catch(() => null)) as { methodResponses?: MethodResponse[] } | null;
|
|
if (!data || !Array.isArray(data.methodResponses)) {
|
|
throw new JmapIndexError('Invalid JMAP response envelope');
|
|
}
|
|
return data.methodResponses;
|
|
}
|
|
|
|
function firstResult(responses: MethodResponse[], expected: string): Record<string, unknown> | null {
|
|
for (const [name, args] of responses) {
|
|
if (name === expected) return args;
|
|
// A method-level error is not fatal for an INDEX: a server that doesn't
|
|
// support one data type should not fail the whole reindex. The caller
|
|
// treats null as "nothing to index for this type".
|
|
if (name === 'error') return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function idsOf(args: Record<string, unknown> | null): string[] {
|
|
const ids = args?.ids;
|
|
return Array.isArray(ids) ? ids.filter((v): v is string => typeof v === 'string') : [];
|
|
}
|
|
|
|
function listOf<T>(args: Record<string, unknown> | null): T[] {
|
|
const list = args?.list;
|
|
return Array.isArray(list) ? (list as T[]) : [];
|
|
}
|
|
|
|
export function accountIdFor(session: JmapSessionInfo, capability: string): string | null {
|
|
const id = session.primaryAccounts[capability];
|
|
return typeof id === 'string' && id.length > 0 ? id : null;
|
|
}
|
|
|
|
export function hasCapability(session: JmapSessionInfo, capability: string): boolean {
|
|
return Object.prototype.hasOwnProperty.call(session.capabilities, capability);
|
|
}
|
|
|
|
/** Per-ACCOUNT capability, mirroring client.ts's supportsFiles() (#563: a server can advertise it while an account has it revoked). */
|
|
export function accountHasCapability(
|
|
session: JmapSessionInfo,
|
|
accountId: string,
|
|
capability: string,
|
|
): boolean {
|
|
const account = session.accounts[accountId];
|
|
if (!account) return false;
|
|
if (account.accountCapabilities && Object.prototype.hasOwnProperty.call(account.accountCapabilities, capability)) {
|
|
return true;
|
|
}
|
|
return account.isPersonal === false;
|
|
}
|
|
|
|
// ── mail ────────────────────────────────────────────────────────────────────
|
|
|
|
/** Properties needed to build a mail IndexDoc. Bodies come via bodyValues. */
|
|
const EMAIL_INDEX_PROPERTIES = [
|
|
'id', 'threadId', 'mailboxIds', 'keywords', 'size', 'receivedAt',
|
|
'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment',
|
|
'textBody', 'htmlBody', 'bodyValues',
|
|
] as const;
|
|
|
|
export async function getEmailsForIndex(
|
|
session: JmapSessionInfo,
|
|
authHeader: string,
|
|
accountId: string,
|
|
ids: readonly string[],
|
|
maxBodyBytes: number,
|
|
): Promise<Email[]> {
|
|
if (ids.length === 0) return [];
|
|
const responses = await jmapRequest(session, authHeader, [CAP_CORE, CAP_MAIL], [
|
|
['Email/get', {
|
|
accountId,
|
|
ids: [...ids],
|
|
properties: [...EMAIL_INDEX_PROPERTIES],
|
|
// Without these two the bodyValues map comes back EMPTY and every
|
|
// indexed body would silently fall back to `preview`.
|
|
fetchTextBodyValues: true,
|
|
fetchHTMLBodyValues: true,
|
|
maxBodyValueBytes: maxBodyBytes,
|
|
}, 'g'],
|
|
]);
|
|
return listOf<Email>(firstResult(responses, 'Email/get'));
|
|
}
|
|
|
|
export async function queryRecentEmailIds(
|
|
session: JmapSessionInfo,
|
|
authHeader: string,
|
|
accountId: string,
|
|
/** Lower bound, or undefined for "no date bound" (the keep-everything
|
|
* retention choice). An `after` of undefined must be OMITTED from the
|
|
* filter, not sent as undefined - Stalwart rejects a malformed filter
|
|
* rather than treating it as unset. */
|
|
afterIso: string | undefined,
|
|
limit: number,
|
|
): Promise<string[]> {
|
|
const responses = await jmapRequest(session, authHeader, [CAP_CORE, CAP_MAIL], [
|
|
['Email/query', {
|
|
accountId,
|
|
filter: afterIso ? { after: afterIso } : {},
|
|
sort: [{ property: 'receivedAt', isAscending: false }],
|
|
limit,
|
|
calculateTotal: false,
|
|
}, 'q'],
|
|
]);
|
|
return idsOf(firstResult(responses, 'Email/query'));
|
|
}
|
|
|
|
// ── calendar ────────────────────────────────────────────────────────────────
|
|
|
|
export async function getCalendarEventsForIndex(
|
|
session: JmapSessionInfo,
|
|
authHeader: string,
|
|
accountId: string,
|
|
ids: readonly string[],
|
|
): Promise<CalendarEvent[]> {
|
|
if (ids.length === 0) return [];
|
|
const responses = await jmapRequest(session, authHeader, [CAP_CORE, CAP_CALENDARS], [
|
|
['CalendarEvent/get', { accountId, ids: [...ids] }, 'g'],
|
|
]);
|
|
return listOf<CalendarEvent>(firstResult(responses, 'CalendarEvent/get'));
|
|
}
|
|
|
|
export async function queryCalendarEventIds(
|
|
session: JmapSessionInfo,
|
|
authHeader: string,
|
|
accountId: string,
|
|
afterIso: string,
|
|
beforeIso: string,
|
|
limit: number,
|
|
): Promise<string[]> {
|
|
const responses = await jmapRequest(session, authHeader, [CAP_CORE, CAP_CALENDARS], [
|
|
['CalendarEvent/query', {
|
|
accountId,
|
|
// LocalDateTime, per the note in lib/jmap/client.ts:307-312 - Stalwart
|
|
// parses these without a zone suffix and ignores unparseable values.
|
|
filter: { after: toLocalDateTime(afterIso), before: toLocalDateTime(beforeIso) },
|
|
limit,
|
|
calculateTotal: false,
|
|
}, 'q'],
|
|
]);
|
|
return idsOf(firstResult(responses, 'CalendarEvent/query'));
|
|
}
|
|
|
|
/** JSCalendar LocalDateTime: `YYYY-MM-DDTHH:MM:SS`, no zone designator. */
|
|
function toLocalDateTime(iso: string): string {
|
|
return iso.replace(/\.\d+/, '').replace(/Z$/, '').slice(0, 19);
|
|
}
|
|
|
|
// ── contacts ────────────────────────────────────────────────────────────────
|
|
|
|
export async function getContactsForIndex(
|
|
session: JmapSessionInfo,
|
|
authHeader: string,
|
|
accountId: string,
|
|
ids: readonly string[],
|
|
): Promise<ContactCard[]> {
|
|
if (ids.length === 0) return [];
|
|
const responses = await jmapRequest(session, authHeader, [CAP_CORE, CAP_CONTACTS], [
|
|
['ContactCard/get', { accountId, ids: [...ids] }, 'g'],
|
|
]);
|
|
return listOf<ContactCard>(firstResult(responses, 'ContactCard/get'));
|
|
}
|
|
|
|
export async function queryContactIds(
|
|
session: JmapSessionInfo,
|
|
authHeader: string,
|
|
accountId: string,
|
|
limit: number,
|
|
): Promise<string[]> {
|
|
const responses = await jmapRequest(session, authHeader, [CAP_CORE, CAP_CONTACTS], [
|
|
['ContactCard/query', { accountId, limit, calculateTotal: false }, 'q'],
|
|
]);
|
|
return idsOf(firstResult(responses, 'ContactCard/query'));
|
|
}
|
|
|
|
// ── files ───────────────────────────────────────────────────────────────────
|
|
|
|
const FILENODE_INDEX_PROPERTIES = [
|
|
'id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'modified',
|
|
] as const;
|
|
|
|
export async function getFilesForIndex(
|
|
session: JmapSessionInfo,
|
|
authHeader: string,
|
|
accountId: string,
|
|
ids: readonly string[],
|
|
): Promise<FileNode[]> {
|
|
if (ids.length === 0) return [];
|
|
const responses = await jmapRequest(session, authHeader, [CAP_CORE], [
|
|
['FileNode/get', { accountId, ids: [...ids], properties: [...FILENODE_INDEX_PROPERTIES] }, 'g'],
|
|
]);
|
|
return listOf<FileNode>(firstResult(responses, 'FileNode/get'));
|
|
}
|
|
|
|
export async function queryFileIds(
|
|
session: JmapSessionInfo,
|
|
authHeader: string,
|
|
accountId: string,
|
|
limit: number,
|
|
): Promise<string[]> {
|
|
const responses = await jmapRequest(session, authHeader, [CAP_CORE], [
|
|
['FileNode/query', { accountId, filter: {}, limit, calculateTotal: false }, 'q'],
|
|
]);
|
|
return idsOf(firstResult(responses, 'FileNode/query'));
|
|
}
|
|
|
|
/**
|
|
* Builds `id -> "Parent/Child"` paths for the given nodes, walking `parentId`
|
|
* upward. FileNode only knows its parent, so the caller has to assemble this;
|
|
* unresolvable ancestors just truncate the path rather than failing.
|
|
*/
|
|
export function buildFilePaths(nodes: readonly FileNode[]): Map<string, string> {
|
|
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
const cache = new Map<string, string>();
|
|
|
|
const resolve = (id: string, depth: number): string => {
|
|
if (depth > 32) return '';
|
|
const cached = cache.get(id);
|
|
if (cached !== undefined) return cached;
|
|
const node = byId.get(id);
|
|
if (!node) return '';
|
|
const parent = node.parentId ? resolve(node.parentId, depth + 1) : '';
|
|
const full = parent ? `${parent}/${node.name}` : node.name;
|
|
cache.set(id, full);
|
|
return full;
|
|
};
|
|
|
|
const out = new Map<string, string>();
|
|
for (const n of nodes) {
|
|
// The document's own `path` metadata is its PARENT directory chain, so a
|
|
// search for "Invoices" matches files inside it without the filename
|
|
// being duplicated into the body.
|
|
out.set(n.id, n.parentId ? resolve(n.parentId, 0) : '');
|
|
}
|
|
return out;
|
|
}
|