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
+34 -1
View File
@@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
@@ -569,6 +569,34 @@ export function EmailComposer({
const [fromOverrideEnabled, setFromOverrideEnabled] = useState<boolean>(initialData?.fromOverrideEnabled ?? false);
const [fromOverrideEmail, setFromOverrideEmail] = useState<string>(initialData?.fromOverrideEmail ?? '');
const [fromOverrideName, setFromOverrideName] = useState<string>(initialData?.fromOverrideName ?? '');
const [fromOverrideWarning, setFromOverrideWarning] = useState<string>('');
// Validate that from override domain matches at least one of the user's identities
const ownIdentityDomains = useMemo(() => new Set(
identities.map(i => i.email).filter(Boolean).map(email => {
const atPos = email.indexOf('@');
return atPos >= 0 ? email.slice(atPos + 1).toLowerCase() : '';
}).filter(d => d.length > 0),
), [identities]);
useEffect(() => {
if (!fromOverrideEnabled || !fromOverrideEmail.trim()) {
setFromOverrideWarning('');
return;
}
const email = fromOverrideEmail.trim();
const atPos = email.indexOf('@');
if (atPos < 0) {
setFromOverrideWarning('Invalid email address');
return;
}
const domain = email.slice(atPos + 1).toLowerCase();
if (!ownIdentityDomains.has(domain)) {
setFromOverrideWarning(`This email's domain (${domain}) does not match any of your verified identities`);
} else {
setFromOverrideWarning('');
}
}, [fromOverrideEnabled, fromOverrideEmail, ownIdentityDomains]);
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
const [showCloseDialog, setShowCloseDialog] = useState(false);
@@ -2358,6 +2386,11 @@ export function EmailComposer({
>
{fromOverrideEnabled ? t('from_override.toggle_on') : t('from_override.toggle_off')}
</Button>
{fromOverrideWarning && (
<span className="text-xs text-amber-600 dark:text-amber-400 ml-2" role="alert">
{fromOverrideWarning}
</span>
)}
</div>
</div>
@@ -0,0 +1,49 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore } from '@/stores/account-store';
import {
getPendingOperationsCount,
onPendingCountChange,
processQueue,
} from '@/lib/offline-write-queue';
export function OfflineQueueIndicator() {
const [count, setCount] = useState(0);
const [processing, setProcessing] = useState(false);
const client = useAuthStore((s) => s.client);
const activeAccountId = useAccountStore((s) => s.activeAccountId);
useEffect(() => {
setCount(getPendingOperationsCount());
return onPendingCountChange(setCount);
}, []);
const handleRetry = useCallback(async () => {
if (!client || !activeAccountId) return;
setProcessing(true);
try {
await processQueue(client, activeAccountId);
} finally {
setProcessing(false);
}
}, [client, activeAccountId]);
if (count === 0) return null;
return (
<div className="flex items-center justify-between gap-2 bg-amber-50 border-b border-amber-200 px-4 py-1.5 text-sm dark:bg-amber-950 dark:border-amber-800">
<span className="text-amber-800 dark:text-amber-200">
{count} pending {count === 1 ? 'operation' : 'operations'} (offline)
</span>
<button
onClick={handleRetry}
disabled={processing || !client}
className="rounded bg-amber-200 px-2 py-0.5 text-xs font-medium text-amber-900 hover:bg-amber-300 disabled:opacity-50 dark:bg-amber-800 dark:text-amber-100 dark:hover:bg-amber-700"
>
{processing ? 'Retrying...' : 'Retry now'}
</button>
</div>
);
}