Files
SRCmail/components/settings/local-index-settings.tsx
T
Bernd Rodler 87336981d3 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.
2026-08-07 09:45:10 +02:00

344 lines
12 KiB
TypeScript

"use client";
// Settings panel for the desktop shell's encrypted local search index.
//
// Deliberately small: the index's PRIMARY trigger is the live push connection
// (see lib/mail-index-client.ts's indexOnStateChange, wired into
// stores/email-store.ts's handleStateChange), so this panel is a status readout
// plus a manual catch-up button - not the mechanism.
//
// Renders nothing at all outside the Electron shell, where the routes 404.
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { SettingsSection, SettingItem } from './settings-section';
import { isElectronShell } from '@/lib/electron-bridge';
import { useAccountStore } from '@/stores/account-store';
import {
catchUpIndex, fetchIndexStats, getRetentionDays, setRetentionDays, type IndexStats,
} from '@/lib/mail-index-client';
import {
chainSync, fetchReplicaStatus, purgeReplica, updateRetentionPolicy,
type ReplicaStatus, type RetentionPolicy,
} from '@/lib/offline-replica-client';
const TYPE_LABELS: Record<string, string> = {
mail: 'Mail',
calendar: 'Calendar',
contact: 'Contacts',
file: 'Files',
};
export function LocalIndexSettings() {
const slot = useAccountStore((s) => s.accounts.find((a) => a.id === s.activeAccountId)?.cookieSlot);
const [stats, setStats] = useState<IndexStats[] | null>(null);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<string | null>(null);
// `null` until the first probe resolves, so we don't flash a panel that then
// vanishes on a non-desktop build.
const [available, setAvailable] = useState<boolean | null>(null);
// `null` = keep everything. Read once on mount; the setter writes through.
const [retentionDays, setRetentionDaysState] = useState<number | null>(365);
useEffect(() => { setRetentionDaysState(getRetentionDays()); }, []);
const refreshStats = useCallback(async () => {
const next = await fetchIndexStats(slot);
setStats(next);
setAvailable(next !== null);
}, [slot]);
useEffect(() => {
if (!isElectronShell()) {
setAvailable(false);
return;
}
void refreshStats();
}, [refreshStats]);
const handleRebuild = async () => {
setBusy(true);
setMessage(null);
try {
const result = await catchUpIndex(slot, retentionDays);
if (result.unavailable) {
setAvailable(false);
setMessage(result.error ?? 'The encrypted index is unavailable on this system.');
return;
}
if (!result.ok) {
setMessage(result.error ?? 'Indexing failed.');
return;
}
const written = Object.entries(result.written ?? {})
.map(([type, n]) => `${TYPE_LABELS[type] ?? type}: ${n}`)
.join(', ');
const failed = (result.errors ?? []).map((e) => `${e.contentType} (${e.message})`).join('; ');
setMessage(
[
written ? `Indexed ${written}.` : 'Nothing to index.',
result.skipped?.length ? `Not supported: ${result.skipped.join(', ')}.` : '',
failed ? `Problems: ${failed}` : '',
]
.filter(Boolean)
.join(' '),
);
await refreshStats();
} finally {
setBusy(false);
}
};
if (available === false || available === null) return null;
const total = (stats ?? []).reduce((sum, s) => sum + s.count, 0);
return (
<SettingsSection
title="Local search index"
description={
'An encrypted index of your recent mail, calendar events, contacts and file names, ' +
'stored on this device only. It updates automatically as items arrive, and powers ' +
'local search and AI answers about your own data. Files are indexed by name and ' +
'location, not by their contents.'
}
>
<SettingItem
label="Indexed items"
description={
total > 0
? (stats ?? [])
.map((s) => `${TYPE_LABELS[s.contentType] ?? s.contentType}: ${s.count}`)
.join(' · ')
: 'Nothing indexed yet.'
}
>
<span className="text-sm text-muted-foreground tabular-nums">{total}</span>
</SettingItem>
<SettingItem
label="Keep AI search history for"
description={
'How far back the AI assistant can search your mail. This also PRUNES: mail older ' +
'than the window is removed from the local index on the next update, so a short ' +
'window means questions about older mail cannot be answered. Only ever covers the ' +
'mailbox you are signed in to.'
}
>
<select
className="h-9 rounded-md border border-border bg-background px-2 text-sm"
value={retentionDays === null ? 'forever' : String(retentionDays)}
onChange={(e) => {
const next = e.target.value === 'forever' ? null : Number.parseInt(e.target.value, 10);
setRetentionDaysState(next);
setRetentionDays(next);
setMessage('Saved. Choose "Update index" to apply it now.');
}}
>
<option value="30">30 days</option>
<option value="90">3 months</option>
<option value="365">1 year</option>
<option value="forever">Everything</option>
</select>
</SettingItem>
<SettingItem
label="Update now"
description={
message ??
'Catches up on anything that changed while the app was closed. Normally not needed - ' +
'the index updates itself when mail, events, contacts or files change.'
}
>
<Button variant="outline" size="sm" onClick={handleRebuild} disabled={busy}>
{busy ? 'Indexing…' : 'Update index'}
</Button>
</SettingItem>
<OfflineMailSettings slot={slot} />
</SettingsSection>
);
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ['KB', 'MB', 'GB'];
let value = bytes / 1024;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++; }
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit]}`;
}
const PHASE_LABELS: Record<string, string> = {
'never-run': 'not started',
scanning: 'downloading history',
reconciling: 'rebuilding',
complete: 'up to date',
};
/**
* Controls for the offline mail replica (lib/offline-replica/**).
*
* Lives inside the same panel as the search index because they share one
* encrypted file, one key and one purge - presenting them as two unrelated
* features would misrepresent what "delete" deletes.
*/
function OfflineMailSettings({ slot }: { slot: number | undefined }) {
const [status, setStatus] = useState<ReplicaStatus | null>(null);
const [busy, setBusy] = useState<null | 'sync' | 'purge' | 'policy'>(null);
const [message, setMessage] = useState<string | null>(null);
const refresh = useCallback(async () => {
setStatus(await fetchReplicaStatus(slot));
}, [slot]);
useEffect(() => { void refresh(); }, [refresh]);
const savePolicy = async (patch: Partial<RetentionPolicy>) => {
if (!status) return;
const next: RetentionPolicy = { ...status.policy, ...patch };
setBusy('policy');
setMessage(null);
try {
const ok = await updateRetentionPolicy(next, slot);
if (!ok) { setMessage('Could not save the retention setting.'); return; }
// The change is applied by the next cycle - a widen re-scans, a narrow
// evicts - so run one now rather than leaving the number looking wrong.
await chainSync({ slot, max: 2 });
await refresh();
} finally {
setBusy(null);
}
};
const handleSync = async () => {
setBusy('sync');
setMessage(null);
try {
const report = await chainSync({ slot });
if (!report) { setMessage('Offline mail is unavailable on this system.'); return; }
setMessage(
report.ok
? `Synced ${report.envelopesWritten} messages and ${report.bodiesWritten} bodies.` +
(report.unfinishedWork ? ' More will download in the background.' : '') +
(report.warnings.length > 0 ? ` Notes: ${report.warnings.join('; ')}` : '')
: `Sync failed: ${report.error ?? 'unknown error'}`,
);
await refresh();
} finally {
setBusy(null);
}
};
const handlePurge = async () => {
setBusy('purge');
setMessage(null);
try {
const ok = await purgeReplica(slot);
setMessage(ok ? 'Offline mail deleted from this device.' : 'Could not delete offline mail.');
await refresh();
} finally {
setBusy(null);
}
};
if (!status) return null;
const stats = status.stats;
const total = stats ? stats.fileBytes : 0;
return (
<>
<SettingItem
label="Offline mail"
description={
stats
? `${stats.envelopes} messages listed, ${stats.bodies} readable offline · ` +
`${formatBytes(stats.bodyBytes)} of message content · ` +
`status: ${PHASE_LABELS[status.coveragePhase] ?? status.coveragePhase}` +
(status.resyncRequired ? ' (a rebuild is queued)' : '') +
(stats.wantedBodies > 0 ? ` · ${stats.wantedBodies} still downloading` : '')
: 'Nothing stored yet. Mail downloads automatically as it arrives.'
}
>
<span className="text-sm text-muted-foreground tabular-nums">{formatBytes(total)}</span>
</SettingItem>
<SettingItem
label="Keep message list for"
description={
'How far back the offline message list goes. Listed messages are tiny (about a ' +
'kilobyte each), so a wide window here costs very little and means a message never ' +
'disappears from the offline list just because its content was removed to save space.'
}
>
<select
className="h-9 rounded-md border border-border bg-background px-2 text-sm"
value={status.policy.envelopeDays}
disabled={busy !== null}
onChange={(e) => void savePolicy({ envelopeDays: Number(e.target.value) })}
>
{[30, 90, 180, 365, 730, 1825].map((d) => (
<option key={d} value={d}>
{d >= 365 ? `${Math.round(d / 365)} year${d >= 730 ? 's' : ''}` : `${d} days`}
</option>
))}
</select>
</SettingItem>
<SettingItem
label="Keep full messages for"
description={
'How far back complete messages - including formatted content - are stored so they ' +
'can be read with no network. Attachments are not downloaded; they still need a ' +
'connection.'
}
>
<select
className="h-9 rounded-md border border-border bg-background px-2 text-sm"
value={status.policy.bodyDays}
disabled={busy !== null}
onChange={(e) => void savePolicy({ bodyDays: Number(e.target.value) })}
>
{[7, 14, 30, 90, 180, 365].map((d) => (
<option key={d} value={d}>{d >= 365 ? '1 year' : `${d} days`}</option>
))}
</select>
</SettingItem>
<SettingItem
label="Storage limit for message content"
description={
'The oldest stored content is removed first when this is reached. Messages stay in ' +
'the offline list either way - only their content is removed.'
}
>
<select
className="h-9 rounded-md border border-border bg-background px-2 text-sm"
value={status.policy.maxBodyMB}
disabled={busy !== null}
onChange={(e) => void savePolicy({ maxBodyMB: Number(e.target.value) })}
>
{[100, 250, 500, 1000, 2000, 5000].map((mb) => (
<option key={mb} value={mb}>{mb >= 1000 ? `${mb / 1000} GB` : `${mb} MB`}</option>
))}
</select>
</SettingItem>
<SettingItem
label="Offline mail actions"
description={message ?? 'Download now, or delete everything stored offline on this device.'}
>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleSync} disabled={busy !== null}>
{busy === 'sync' ? 'Downloading…' : 'Download now'}
</Button>
<Button variant="outline" size="sm" onClick={handlePurge} disabled={busy !== null}>
{busy === 'purge' ? 'Deleting…' : 'Delete offline mail'}
</Button>
</div>
</SettingItem>
</>
);
}