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)
This commit is contained in:
Bernd Rodler
2026-08-07 22:10:26 +02:00
parent 0ac429fe36
commit cfdd091d22
29 changed files with 1068 additions and 93 deletions
+5
View File
@@ -6,6 +6,7 @@ import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization';
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
import { parseISO } from 'date-fns';
import type { CalendarEvent } from '@/lib/jmap/types';
import { isFeatureEnabledServer } from '@/lib/admin/feature-gate';
/**
* POST /api/calendar-agenda
@@ -100,6 +101,10 @@ function firstCalendarId(event: Partial<CalendarEvent>): string | null {
}
export async function POST(request: NextRequest) {
if (!isFeatureEnabledServer('calendarEnabled')) {
return NextResponse.json({ error: 'Feature disabled' }, { status: 403 });
}
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
+5
View File
@@ -22,6 +22,7 @@ import {
} 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';
@@ -40,6 +41,10 @@ function parseIdMap(raw: unknown): Partial<Record<ContentType, string[]>> | unde
}
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 });
}
+5
View File
@@ -3,6 +3,7 @@ import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry
import { listDevPlugins } from '@/lib/admin/plugin-dev';
import { configManager } from '@/lib/admin/config-manager';
import { logger } from '@/lib/logger';
import { isFeatureEnabledServer } from '@/lib/admin/feature-gate';
/**
* GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes
@@ -11,6 +12,10 @@ import { logger } from '@/lib/logger';
* No admin auth required - this is how regular users receive plugins/themes.
*/
export async function GET() {
if (!isFeatureEnabledServer('pluginsEnabled')) {
return NextResponse.json({ error: 'Feature disabled' }, { status: 403 });
}
try {
await configManager.ensureLoaded();
const policy = configManager.getPolicy();
+5
View File
@@ -15,12 +15,17 @@ import { NextResponse } from 'next/server';
import { readStalwartAuthContext } from '@/lib/stalwart/auth-context';
import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
import { CaError, getCaProvider } from '@/lib/smime-ca';
import { isFeatureEnabledServer } from '@/lib/admin/feature-gate';
export const runtime = 'nodejs';
const MAX_CSR_BYTES = 8 * 1024;
export async function POST(request: Request) {
if (!isFeatureEnabledServer('smimeEnabled')) {
return NextResponse.json({ error: 'Feature disabled' }, { status: 403 });
}
const provider = getCaProvider();
if (!provider) {
return NextResponse.json(