Phase 3 (security): - P3.1: Feature gate server-side enforcement (403 on disabled features) - P3.2: Unified auth error interceptor (401→logout) - P3.3: Store-level state isolation via StoreSnapshot contract (added message-list-tabs + task stores to snapshot/restore cycle) - P3.4: Push event bus extraction — email-store no longer imports calendar/contact/filter/file stores directly - P1.3: Auth localStorage AES-GCM encryption via custom Zustand adapter Phase 4 (polish): - P4.1: Offline write queue — pending operations in localStorage, auto-retry on reconnect, offline-queue-indicator banner - P4.2: Identity spoofing — fromOverrideEmail domain validation - P4.3: WebSocket push for Electron via main-process IPC bridge (ws package with Authorization headers)
120 lines
5.0 KiB
TypeScript
120 lines
5.0 KiB
TypeScript
// 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, normalizeWindowDays, 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';
|
|
import { isFeatureEnabledServer } from '@/lib/admin/feature-gate';
|
|
|
|
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 (!isFeatureEnabledServer('aiAssistantEnabled')) {
|
|
return NextResponse.json({ error: 'Feature disabled' }, { status: 403 });
|
|
}
|
|
|
|
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,
|
|
// `undefined` (absent) means "use the default"; an explicit null means
|
|
// keep everything. normalizeWindowDays() in runIndex clamps anything
|
|
// unexpected, since this value drives deletion.
|
|
windowDays: body.windowDays === undefined ? undefined : normalizeWindowDays(body.windowDays),
|
|
};
|
|
|
|
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 });
|
|
}
|
|
}
|