Files
SRCmail/app/api/offline/reindex/route.ts
T
Bernd Rodler cfdd091d22 feat: Phase 3+4 — security hardening + polish + offline + Electron push
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)
2026-08-07 22:10:26 +02:00

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 });
}
}