Merge branch 'claude/electron-offline-design' into dev
Encrypted SQLite/FTS5 offline search index for the Electron desktop client: event-driven reindex (mail, calendar, contacts, files) driven off the existing JMAP push connection, per-account keys held in OS keychain via safeStorage, search API returns ranked context ready for an LLM/RAG prompt.
This commit is contained in:
@@ -32,6 +32,7 @@ import { usePromptDialog } from "@/hooks/use-prompt-dialog";
|
||||
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { playNotificationSound } from "@/lib/notification-sound";
|
||||
import { isElectronShell, showElectronNotification } from "@/lib/electron-bridge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
|
||||
@@ -1062,7 +1063,30 @@ export default function Home() {
|
||||
debug.log('push', `[Push] Push notifications enabled for ${cleanups.length} account(s)`);
|
||||
}
|
||||
|
||||
// CATCH-UP for the desktop shell's local search index. The index's normal
|
||||
// trigger is a push StateChange (stores/email-store.ts's handleStateChange),
|
||||
// but nothing was pushed while the app was closed - and the polling
|
||||
// transport has no signal for contacts or files at all (client.ts's
|
||||
// buildStatePollingRequest covers Mailbox/Email/Calendar/CalendarEvent/
|
||||
// SieveScript only). So backfill a bounded recent window once per session,
|
||||
// after push is wired. Fire-and-forget; a no-op outside Electron.
|
||||
const catchUpTimer = setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const { catchUpIndex } = await import('@/lib/mail-index-client');
|
||||
await catchUpIndex(
|
||||
useAccountStore.getState().getActiveAccount()?.cookieSlot,
|
||||
);
|
||||
} catch {
|
||||
/* the index is optional */
|
||||
}
|
||||
})();
|
||||
// Deliberately after the initial mailbox fetch settles: the catch-up is a
|
||||
// background nicety and must not compete with first paint.
|
||||
}, 4000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(catchUpTimer);
|
||||
cleanups.forEach((fn) => fn());
|
||||
};
|
||||
}, [isAuthenticated, client, activeAccountId, connectedAccountsSignature, handleStateChange, setPushConnected, buildPopulatedUnifiedAccounts, refreshCrossCounts, refreshUnifiedCounts]);
|
||||
@@ -1186,13 +1210,34 @@ export default function Home() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedEmail?.id, isScheduledView]);
|
||||
|
||||
// Handle new email notifications - play sound
|
||||
// Handle new email notifications - play sound, and (in the Electron shell)
|
||||
// fire a native OS notification. This effect is the transport-agnostic
|
||||
// "genuinely new unread mail arrived" signal - stores/email-store.ts's
|
||||
// refreshCurrentMailbox() already filters out sends/moves/drafts and only
|
||||
// sets newEmailNotification for a real new top-of-inbox message, and it
|
||||
// fires identically whether the underlying JMAP StateChange arrived over
|
||||
// the WebSocket push connection (lib/jmap/client.ts's connectWebSocket),
|
||||
// SSE, or the polling fallback - no need to duplicate this per transport.
|
||||
useEffect(() => {
|
||||
if (newEmailNotification) {
|
||||
const { emailNotificationsEnabled, emailNotificationSound, notificationSoundChoice } = useSettingsStore.getState();
|
||||
if (emailNotificationsEnabled && emailNotificationSound) {
|
||||
playNotificationSound(notificationSoundChoice);
|
||||
}
|
||||
if (emailNotificationsEnabled && isElectronShell()) {
|
||||
// Same fallback text public/sw.js's push handler already uses for
|
||||
// its (also un-translated) system notifications - a native OS
|
||||
// notification body isn't run through next-intl either way, so
|
||||
// matching that existing precedent instead of introducing new
|
||||
// translation keys for a rarely-hit fallback.
|
||||
const sender = newEmailNotification.from?.[0];
|
||||
const senderName = sender?.name || sender?.email || 'New mail';
|
||||
const body = newEmailNotification.subject || newEmailNotification.preview || '(no subject)';
|
||||
void showElectronNotification(senderName, {
|
||||
body,
|
||||
tag: `bulwark-mail:${newEmailNotification.id}`,
|
||||
});
|
||||
}
|
||||
debug.log('email', 'New email received:', newEmailNotification.subject);
|
||||
clearNewEmailNotification();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// POST /api/offline/reindex - write mail/calendar/contacts/files into the
|
||||
// encrypted local search index for the calling session's account.
|
||||
//
|
||||
// The PRIMARY caller is the renderer's live JMAP push handler: when a
|
||||
// StateChange arrives it posts the ids that changed, so indexing is reactive to
|
||||
// each delivery rather than periodic. `{ catchUp: true }` (no ids) is the
|
||||
// fallback used at app launch to backfill whatever changed while the app was
|
||||
// closed.
|
||||
//
|
||||
// GATED: returns 404 unless VNCMAIL_DESKTOP_STORE_DIR is set, which only
|
||||
// electron/main.ts does. The same standalone server artifact runs in the
|
||||
// multi-tenant production Docker image, where this feature must not exist at
|
||||
// all - 404 rather than 403 so nothing learns the route is there.
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { isSqlcipherAvailable } from '@/lib/mail-index/binding';
|
||||
import { hasKeyChannel, IndexKeyError } from '@/lib/mail-index/key';
|
||||
import { getStoreDir } from '@/lib/mail-index/paths';
|
||||
import {
|
||||
IndexSessionError, MAX_IDS_PER_CALL, resolveIndexSession, runIndex,
|
||||
type IndexRequest,
|
||||
} from '@/lib/mail-index/reindex';
|
||||
import { CONTENT_TYPES, isContentType, type ContentType } from '@/lib/mail-index/store';
|
||||
import { JmapIndexError } from '@/lib/mail-index/jmap';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function parseIdMap(raw: unknown): Partial<Record<ContentType, string[]>> | undefined {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
|
||||
const out: Partial<Record<ContentType, string[]>> = {};
|
||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (!isContentType(key) || !Array.isArray(value)) continue;
|
||||
const ids = value
|
||||
.filter((v): v is string => typeof v === 'string' && v.length > 0 && v.length <= 256)
|
||||
.slice(0, MAX_IDS_PER_CALL);
|
||||
if (ids.length > 0) out[key] = ids;
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!getStoreDir()) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
if (!hasKeyChannel()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'The local index has no key channel in this process.', code: 'no-key-channel' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
if (!isSqlcipherAvailable()) {
|
||||
// The native binding is an optionalDependency, so "not installed" is a
|
||||
// normal state on platforms without a prebuild - not an error to log loudly.
|
||||
return NextResponse.json(
|
||||
{ error: 'Encrypted local index is unavailable on this platform.', code: 'no-binding' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: Record<string, unknown> = {};
|
||||
try {
|
||||
const text = await request.text();
|
||||
if (text.trim()) body = JSON.parse(text) as Record<string, unknown>;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Malformed JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const rawTypes = Array.isArray(body.types) ? body.types.filter(isContentType) : [];
|
||||
const req: IndexRequest = {
|
||||
types: rawTypes.length > 0 ? rawTypes : undefined,
|
||||
ids: parseIdMap(body.ids),
|
||||
removed: parseIdMap(body.removed),
|
||||
// Pruning is a catch-up concern; a single-delivery call shouldn't scan.
|
||||
prune: body.catchUp === true,
|
||||
};
|
||||
|
||||
try {
|
||||
const session = await resolveIndexSession(request);
|
||||
const result = await runIndex(session, req);
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: true,
|
||||
written: result.written,
|
||||
skipped: result.skipped,
|
||||
errors: result.errors,
|
||||
durationMs: result.durationMs,
|
||||
types: CONTENT_TYPES,
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof IndexSessionError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
if (error instanceof JmapIndexError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
if (error instanceof IndexKeyError) {
|
||||
// no-secure-storage is the Linux-without-a-keyring refusal: a real,
|
||||
// expected outcome with a user-facing explanation, not a server fault.
|
||||
const status = error.code === 'no-secure-storage' ? 503 : 500;
|
||||
return NextResponse.json({ error: error.message, code: error.code }, { status });
|
||||
}
|
||||
logger.error('mail-index reindex failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json({ error: 'Reindex failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// 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';
|
||||
|
||||
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 };
|
||||
return { hits: index.search({ query, types, limit }), stats: wantStats ? stats : undefined };
|
||||
} 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 } : {}),
|
||||
},
|
||||
{ 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user