fix: Phase 1 critical+high fixes (17/18 items)

CRITICAL fixes:
- C1: Error swallowing - throw TransportError on network failure in getEmails/searchEmails
- C2: Recurrence expansion ID delimiter changed from ':' to '::occurrence::'
- C3: Cross-account calendar event UID dedup after multi-account aggregation
- C4: Admin session token revocation via JTI blacklist on logout
- C6: FTS5 schema-drop - add warning log for automatic reindex trigger
- C7: Settings lock - gate updateSetting() with isSettingLocked() check
- C8: Offline push pause - add offline event handler that closes push transports

HIGH fixes:
- H1: Push handler - add ContactCard and FileNode branches
- H2: WS fallback - await state snapshot before reconcileAfterWebSocketFallback
- H3: Auth rate limiting - add checkUserAuthRateLimit to session and token routes
- H4: OAuth logs - strip access_token from error log context
- H7: Template XSS - apply DOMPurify to HTML template body on import
- H8: Secure cookie - derive from x-forwarded-proto, not NODE_ENV
- H9: bcrypt fix - remove bcrypt prefixes from isHashed() so scrypt-only
- H13: calendarTasksEnabled - apply admin gate at runtime in calendar page
- H14: Task mutations - add try/catch error handling to update/delete/toggle
- H18: autoSelectReplyIdentity default changed from false to true

Deferred: P1.3 (C5 auth localStorage encryption) - requires custom Zustand persist adapter.
This commit is contained in:
Bernd Rodler
2026-08-07 12:17:41 +02:00
parent 671857722d
commit a622e3755b
21 changed files with 1450 additions and 40 deletions
+3 -1
View File
@@ -99,7 +99,9 @@ export default function CalendarPage() {
refreshAllSubscriptions, icalSubscriptions, refreshAllSubscriptions, icalSubscriptions,
} = useCalendarStore(); } = useCalendarStore();
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled')); const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore(); const calendarTasksEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarTasksEnabled'));
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks: userTasksEnabled, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
const enableCalendarTasks = userTasksEnabled && calendarTasksEnabled;
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors); const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor); const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor); const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor);
Binary file not shown.
+10
View File
@@ -19,6 +19,7 @@ import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker'; import { recordLogin } from '@/lib/telemetry/login-tracker';
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers'; import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { checkUserAuthRateLimit } from '@/lib/admin/rate-limit';
function sessionCookieOptions() { function sessionCookieOptions() {
return { return {
@@ -48,6 +49,15 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
} }
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
const rateLimit = checkUserAuthRateLimit(ip, username);
if (!rateLimit.allowed) {
return NextResponse.json(
{ error: 'Too many login attempts', retryAfterMs: rateLimit.retryAfterMs },
{ status: 429 },
);
}
// Pin the upstream URL to a configured JMAP server so an unauthenticated // Pin the upstream URL to a configured JMAP server so an unauthenticated
// caller cannot point this route at internal hosts. We accept the global // caller cannot point this route at internal hosts. We accept the global
// `jmapServerUrl` and any entry from `jmapServers`. When neither matches, // `jmapServerUrl` and any entry from `jmapServers`. When neither matches,
+10
View File
@@ -5,6 +5,7 @@ import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oaut
import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange'; import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { checkUserAuthRateLimit } from '@/lib/admin/rate-limit';
function getSlot(request: NextRequest): number { function getSlot(request: NextRequest): number {
const raw = request.nextUrl.searchParams.get('slot'); const raw = request.nextUrl.searchParams.get('slot');
@@ -16,6 +17,15 @@ function getSlot(request: NextRequest): number {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
const rateLimit = checkUserAuthRateLimit(ip, 'oauth-token');
if (!rateLimit.allowed) {
return NextResponse.json(
{ error: 'Too many token requests', retryAfterMs: rateLimit.retryAfterMs },
{ status: 429 },
);
}
const { code, code_verifier, redirect_uri, slot: bodySlot, server_id: bodyServerId } = await request.json(); const { code, code_verifier, redirect_uri, slot: bodySlot, server_id: bodyServerId } = await request.json();
if (!code || !code_verifier || !redirect_uri) { if (!code || !code_verifier || !redirect_uri) {
+1 -1
View File
@@ -58,7 +58,7 @@ function verifyPassword(password: string, stored: string): Promise<boolean> {
} }
function isHashed(value: string): boolean { function isHashed(value: string): boolean {
return value.startsWith('$scrypt$') || value.startsWith('$2a$') || value.startsWith('$2b$'); return value.startsWith('$scrypt$');
} }
// ─── Disk I/O ─────────────────────────────────────────────────────────────── // ─── Disk I/O ───────────────────────────────────────────────────────────────
+31 -9
View File
@@ -1,9 +1,11 @@
/** /**
* In-memory rate limiter for admin login. * In-memory rate limiter for admin login and user authentication.
* Max 5 attempts per IP per 15 minutes. * Admin: max 5 attempts per IP per 15 minutes.
* User auth: max 10 attempts per (IP + username) per 15 minutes.
*/ */
const MAX_ATTEMPTS = 5; const MAX_ADMIN_ATTEMPTS = 5;
const MAX_USER_ATTEMPTS = 10;
const WINDOW_MS = 15 * 60 * 1000; // 15 minutes const WINDOW_MS = 15 * 60 * 1000; // 15 minutes
interface RateLimitEntry { interface RateLimitEntry {
@@ -28,18 +30,38 @@ setInterval(() => {
*/ */
export function checkRateLimit(ip: string): { allowed: boolean; remaining: number; retryAfterMs: number } { export function checkRateLimit(ip: string): { allowed: boolean; remaining: number; retryAfterMs: number } {
const now = Date.now(); const now = Date.now();
const entry = attempts.get(ip); const entry = attempts.get(`admin:${ip}`);
if (!entry || entry.resetAt <= now) { if (!entry || entry.resetAt <= now) {
// New window attempts.set(`admin:${ip}`, { count: 1, resetAt: now + WINDOW_MS });
attempts.set(ip, { count: 1, resetAt: now + WINDOW_MS }); return { allowed: true, remaining: MAX_ADMIN_ATTEMPTS - 1, retryAfterMs: 0 };
return { allowed: true, remaining: MAX_ATTEMPTS - 1, retryAfterMs: 0 };
} }
if (entry.count >= MAX_ATTEMPTS) { if (entry.count >= MAX_ADMIN_ATTEMPTS) {
return { allowed: false, remaining: 0, retryAfterMs: entry.resetAt - now }; return { allowed: false, remaining: 0, retryAfterMs: entry.resetAt - now };
} }
entry.count++; entry.count++;
return { allowed: true, remaining: MAX_ATTEMPTS - entry.count, retryAfterMs: 0 }; return { allowed: true, remaining: MAX_ADMIN_ATTEMPTS - entry.count, retryAfterMs: 0 };
}
/**
* Check rate limit for user authentication, keyed by IP + username.
*/
export function checkUserAuthRateLimit(ip: string, username: string): { allowed: boolean; remaining: number; retryAfterMs: number } {
const now = Date.now();
const key = `user:${ip}:${username}`;
const entry = attempts.get(key);
if (!entry || entry.resetAt <= now) {
attempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
return { allowed: true, remaining: MAX_USER_ATTEMPTS - 1, retryAfterMs: 0 };
}
if (entry.count >= MAX_USER_ATTEMPTS) {
return { allowed: false, remaining: 0, retryAfterMs: entry.resetAt - now };
}
entry.count++;
return { allowed: true, remaining: MAX_USER_ATTEMPTS - entry.count, retryAfterMs: 0 };
} }
+29 -3
View File
@@ -11,6 +11,13 @@ const TAG_LENGTH = 16;
const MIN_SECRET_LENGTH = 32; const MIN_SECRET_LENGTH = 32;
const revokedTokens = new Map<string, number>(); // jti → expiry timestamp
function isHttpsRequest(req: { headers: Headers }): boolean {
const proto = req.headers.get('x-forwarded-proto');
return proto === 'https';
}
function getKey(): Buffer { function getKey(): Buffer {
const secret = getSessionSecret(); const secret = getSessionSecret();
if (!secret) throw new Error('SESSION_SECRET not configured'); if (!secret) throw new Error('SESSION_SECRET not configured');
@@ -37,10 +44,12 @@ export function createAdminSession(): string {
const cipher = createCipheriv(ALGORITHM, key, iv); const cipher = createCipheriv(ALGORITHM, key, iv);
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const exp = now + getSessionTTL();
const payload: AdminSessionPayload = { const payload: AdminSessionPayload = {
role: 'admin', role: 'admin',
iat: now, iat: now,
exp: now + getSessionTTL(), exp,
jti: randomBytes(16).toString('hex'),
}; };
const json = JSON.stringify(payload); const json = JSON.stringify(payload);
@@ -74,12 +83,29 @@ export function verifyAdminSession(token: string): AdminSessionPayload | null {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) return null; if (payload.exp < now) return null;
// Clean up expired revocations while we're here
for (const [jti, expiry] of revokedTokens) {
if (expiry < now) revokedTokens.delete(jti);
}
if (payload.jti && revokedTokens.has(payload.jti)) return null;
return payload; return payload;
} catch { } catch {
return null; return null;
} }
} }
/**
* Revoke an admin session token so it cannot be used again.
*/
export function revokeAdminSession(token: string): void {
const payload = verifyAdminSession(token);
if (payload?.jti) {
revokedTokens.set(payload.jti, payload.exp);
}
}
/** /**
* CSRF gate for cookie-authed admin requests. * CSRF gate for cookie-authed admin requests.
* *
@@ -146,12 +172,12 @@ export async function requireAdminAuth(request: Request): Promise<{ payload: Adm
/** /**
* Set the admin session cookie. * Set the admin session cookie.
*/ */
export async function setAdminSessionCookie(): Promise<void> { export async function setAdminSessionCookie(request?: { headers: Headers }): Promise<void> {
const token = createAdminSession(); const token = createAdminSession();
const cookieStore = await cookies(); const cookieStore = await cookies();
cookieStore.set(ADMIN_SESSION_COOKIE, token, { cookieStore.set(ADMIN_SESSION_COOKIE, token, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: request ? isHttpsRequest(request) : process.env.NODE_ENV === 'production',
sameSite: 'lax', sameSite: 'lax',
path: '/', path: '/',
maxAge: getSessionTTL(), maxAge: getSessionTTL(),
+1
View File
@@ -30,6 +30,7 @@ export interface AdminSessionPayload {
role: 'admin'; role: 'admin';
iat: number; iat: number;
exp: number; exp: number;
jti?: string;
} }
export interface SettingRestriction { export interface SettingRestriction {
+31 -1
View File
@@ -3,10 +3,21 @@ import type { SieveScript, SieveCapabilities } from "./sieve-types";
import type { IJMAPClient } from "./client-interface"; import type { IJMAPClient } from "./client-interface";
import { toWildcardQuery } from "./search-utils"; import { toWildcardQuery } from "./search-utils";
import { batched, itemsPerRequest } from "./request-limits"; import { batched, itemsPerRequest } from "./request-limits";
import { noteTransportFailure, noteTransportSuccess } from "./transport-health"; import { noteTransportFailure, noteTransportSuccess, transportFailureCount } from "./transport-health";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization"; import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization";
export class TransportError extends Error {
constructor(message = 'Network transport failure') {
super(message);
this.name = 'TransportError';
}
}
function wasTransportFailure(beforeCount: number): boolean {
return transportFailureCount() > beforeCount;
}
/** Parse a recipient string that may be "Name <email>" or bare "email" into { name?, email }. */ /** Parse a recipient string that may be "Name <email>" or bare "email" into { name?, email }. */
function parseRecipientString(s: string): { name?: string; email: string } { function parseRecipientString(s: string): { name?: string; email: string } {
const trimmed = s.trim(); const trimmed = s.trim();
@@ -1220,6 +1231,7 @@ export class JMAPClient implements IJMAPClient {
} }
async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>): Promise<{ emails: Email[], hasMore: boolean, total: number }> { async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
const tcBefore = transportFailureCount();
try { try {
const targetAccountId = accountId || this.accountId; const targetAccountId = accountId || this.accountId;
const simple: { inMailbox?: string; hasKeyword?: string } = {}; const simple: { inMailbox?: string; hasKeyword?: string } = {};
@@ -1290,6 +1302,9 @@ export class JMAPClient implements IJMAPClient {
return { emails: [], hasMore: false, total: 0 }; return { emails: [], hasMore: false, total: 0 };
} catch (error) { } catch (error) {
if (wasTransportFailure(tcBefore)) {
throw new TransportError('Failed to get emails: network transport failure');
}
console.error('Failed to get emails:', error); console.error('Failed to get emails:', error);
return { emails: [], hasMore: false, total: 0 }; return { emails: [], hasMore: false, total: 0 };
} }
@@ -2099,6 +2114,7 @@ export class JMAPClient implements IJMAPClient {
} }
async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[], hasMore: boolean, total: number }> { async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
const tcBefore = transportFailureCount();
try { try {
const targetAccountId = accountId || this.accountId; const targetAccountId = accountId || this.accountId;
@@ -2154,6 +2170,9 @@ export class JMAPClient implements IJMAPClient {
return { emails, hasMore, total }; return { emails, hasMore, total };
} catch (error) { } catch (error) {
if (wasTransportFailure(tcBefore)) {
throw new TransportError('Search failed: network transport failure');
}
console.error('Search failed:', error); console.error('Search failed:', error);
return { emails: [], hasMore: false, total: 0 }; return { emails: [], hasMore: false, total: 0 };
} }
@@ -6041,6 +6060,7 @@ export class JMAPClient implements IJMAPClient {
private lastSSEActivity: number = 0; private lastSSEActivity: number = 0;
private visibilityHandler: (() => void) | null = null; private visibilityHandler: (() => void) | null = null;
private onlineHandler: (() => void) | null = null; private onlineHandler: (() => void) | null = null;
private offlineHandler: (() => void) | null = null;
// JMAP-over-WebSocket (RFC 8887) push - preferred over SSE when the server // JMAP-over-WebSocket (RFC 8887) push - preferred over SSE when the server
// advertises it (getWebSocketUrl()), since it's the transport the desktop // advertises it (getWebSocketUrl()), since it's the transport the desktop
@@ -6301,6 +6321,7 @@ export class JMAPClient implements IJMAPClient {
* original 31s-worst-case ladder did. * original 31s-worst-case ladder did.
*/ */
private async reconcileAfterWebSocketFallback(): Promise<void> { private async reconcileAfterWebSocketFallback(): Promise<void> {
await this._stateSnapshotPromise;
await this.checkForStateChanges(); await this.checkForStateChanges();
const eventSourceUrl = this.getEventSourceUrl(); const eventSourceUrl = this.getEventSourceUrl();
@@ -6781,6 +6802,11 @@ export class JMAPClient implements IJMAPClient {
} }
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
this.offlineHandler = () => {
this.closePushNotifications();
};
window.addEventListener('offline', this.offlineHandler);
this.onlineHandler = () => { this.onlineHandler = () => {
// Network reconnected - reconnect WS/SSE or force a poll. Don't // Network reconnected - reconnect WS/SSE or force a poll. Don't
// make the user wait through whatever backoff delay was already in // make the user wait through whatever backoff delay was already in
@@ -6814,6 +6840,10 @@ export class JMAPClient implements IJMAPClient {
document.removeEventListener('visibilitychange', this.visibilityHandler); document.removeEventListener('visibilitychange', this.visibilityHandler);
this.visibilityHandler = null; this.visibilityHandler = null;
} }
if (this.offlineHandler && typeof window !== 'undefined') {
window.removeEventListener('offline', this.offlineHandler);
this.offlineHandler = null;
}
if (this.onlineHandler && typeof window !== 'undefined') { if (this.onlineHandler && typeof window !== 'undefined') {
window.removeEventListener('online', this.onlineHandler); window.removeEventListener('online', this.onlineHandler);
this.onlineHandler = null; this.onlineHandler = null;
+4 -1
View File
@@ -228,7 +228,10 @@ export class MailIndex {
let version = opened.version; let version = opened.version;
if (version !== null && version !== SCHEMA_VERSION) { if (version !== null && version !== SCHEMA_VERSION) {
// Rebuildable derived data: drop, don't migrate. console.warn(
`[MailIndex] Schema version mismatch (stored=${version}, current=${SCHEMA_VERSION}). ` +
'Dropping all tables — a full reindex will run on the next push event or /api/offline/reindex call.',
);
db.exec('DROP TABLE IF EXISTS doc_fts; DROP TABLE IF EXISTS doc; DROP TABLE IF EXISTS meta;'); db.exec('DROP TABLE IF EXISTS doc_fts; DROP TABLE IF EXISTS doc; DROP TABLE IF EXISTS meta;');
version = null; version = null;
} }
+2 -1
View File
@@ -121,7 +121,8 @@ export async function exchangeCodeForTokens(
const tokens = await tokenResponse.json(); const tokens = await tokenResponse.json();
if (!tokens.access_token) { if (!tokens.access_token) {
logger.error('Token response missing access_token', { response: JSON.stringify(tokens).substring(0, 500) }); const { access_token, refresh_token, ...safeTokens } = tokens;
logger.error('Token response missing access_token', { response: JSON.stringify(safeTokens).substring(0, 500) });
throw new Error('Invalid token response'); throw new Error('Invalid token response');
} }
+1 -1
View File
@@ -239,7 +239,7 @@ function createOccurrence(
return { return {
...master, ...master,
...(override || {}), ...(override || {}),
id: `${master.id}:${recurrenceId}`, id: `${master.id}::occurrence::${recurrenceId}`,
originalId: master.originalId || master.id, originalId: master.originalId || master.id,
uid: master.uid, uid: master.uid,
calendarIds: master.calendarIds, calendarIds: master.calendarIds,
+1 -1
View File
@@ -173,7 +173,7 @@ export function importTemplates(json: string): ImportResult {
id: generateUUID(), id: generateUUID(),
name: sanitizeText(t.name), name: sanitizeText(t.name),
subject: sanitizeText(t.subject), subject: sanitizeText(t.subject),
body: t.isHTML ? String(t.body || '') : sanitizeText(t.body), body: t.isHTML ? DOMPurify.sanitize(String(t.body || ''), { ALLOWED_TAGS: ['b', 'i', 'u', 'strong', 'em', 'a', 'p', 'br', 'ul', 'ol', 'li', 'div', 'span', 'table', 'tr', 'td', 'th', 'thead', 'tbody', 'img', 'h1', 'h2', 'h3', 'blockquote'], ALLOWED_ATTR: ['href', 'src', 'alt', 'style', 'class', 'target'] }) : sanitizeText(t.body),
isHTML: Boolean(t.isHTML), isHTML: Boolean(t.isHTML),
category: sanitizeText(t.category), category: sanitizeText(t.category),
defaultRecipients: recipients && typeof recipients === 'object' defaultRecipients: recipients && typeof recipients === 'object'
@@ -0,0 +1,514 @@
# VNCmail+ v1.7.8 → v1.8.0 Development Plan
**Date:** 2026-08-07
**Target:** `brvncde-dotcom/vncmail-plus` (Next.js 16)
**Baseline:** VNCmailgraph audit (82 findings) + Bugs VNCmail+.docx (12 missing features)
---
## Phase Structure
| Phase | Focus | Features + Fixes | Autonomy |
|-------|-------|-----------------|----------|
| **P1** | Critical fixes | 8 CRITICAL + 10 HIGH audit findings | Full autonomous |
| **P2** | Missing features | 12 docx features, code reuse from Angular | Full autonomous (board decided VNCtalk/Collabora/ActionWheel) |
| **P3** | Security hardening | Auth, cookies, rate limiting, encryption | Full autonomous |
| **P4** | Polish & sync | Remaining HIGH + cross-feature substrate | Full autonomous |
---
## Phase 1 — Critical Bug Fixes (Autonomous)
### P1.1 — Error Swallowing (CRITICAL C1)
**Issue:** `getEmails`/`searchEmails` return empty on transport failure. Network-down = empty folder.
**Fix:** Sample `transportHealth().transportFailureCount()` before/after JMAP reads. If incremented, throw `TransportError` instead of returning `{ emails: [], ... }`. Store catches and shows connectivity banner.
- Files: `lib/jmap/client.ts`, `stores/email-store.ts`, `lib/jmap/transport-health.ts`
- Reuse: None
- Effort: 2h
### P1.2 — FTS5 Schema-Drop Rebuild (CRITICAL C6)
**Issue:** Schema version bump drops all tables without auto-rebuild.
**Fix:** After `DROP TABLE IF EXISTS`, trigger automatic `catchUpAll()` in the same operation. Add user-facing "Rebuilding search index..." indicator.
- Files: `lib/mail-index/store.ts`, `lib/mail-index/reindex.ts`
- Reuse: None
- Effort: 2h
### P1.3 — Auth Credentials in localStorage (CRITICAL C5)
**Issue:** `auth-storage` + `account-storage` contain server URLs and usernames in plaintext.
**Fix:** Encrypt the Zustand persist payload for these two stores using a key derived from session secret or a per-device key. Scope: `auth-store.ts` and `account-store.ts` persist middleware.
- Files: `stores/auth-store.ts`, `stores/account-store.ts`, new `lib/auth/local-storage-crypto.ts`
- Reuse: AES-256-GCM pattern from `lib/auth/crypto.ts`
- Effort: 4h
### P1.4 — Settings Lock Bypass (CRITICAL C7)
**Issue:** `updateSetting()` has no policy check. Admin locks bypassable via store.
**Fix:** Gate `updateSetting(key, value)` with `isSettingLocked(key)`. Add `{ force: true }` opt-in for legitimate bypassers (auth bootstrap, settings sync). Audit all `updateSetting` call sites.
- Files: `stores/settings-store.ts`, `stores/auth-store.ts`, `lib/settings-sync.ts`
- Reuse: None
- Effort: 3h
### P1.5 — Offline Push Detection (CRITICAL C8)
**Issue:** No `offline` event listener. Transports retry blindly, draining battery.
**Fix:** Add `window.addEventListener('offline', ...)` that pauses all push transports. Add `navigator.onLine` gate on each transport cycle. Wire `transportHealth().likelyOffline` into push lifecycle.
- Files: `lib/jmap/client.ts`
- Reuse: None
- Effort: 1h
### P1.6 — Admin Session Revocation (CRITICAL C4)
**Issue:** AES-256-GCM token has no server-side revocation.
**Fix:** Add in-memory token blacklist (Set with TTL) in admin session middleware. Logout adds token `jti` to blacklist. Cleanup expired entries on verification.
- Files: `lib/admin/session.ts`, `app/api/admin/auth/route.ts`
- Reuse: None
- Effort: 3h
### P1.7 — Calendar ID Collision (CRITICAL C2)
**Issue:** Recurrence expansion (`:` delimiter) collides with shared event prefix.
**Fix:** Change expansion delimiter from `:` to `::occurrence::`. Update `stripLocalAccountPrefix` and all ID parsing.
- Files: `lib/recurrence-expansion.ts`, `stores/calendar-store.ts`
- Reuse: None
- Effort: 1h
### P1.8 — Calendar Cross-Account Dedup (CRITICAL C3)
**Issue:** Multi-account event aggregation has no UID dedup.
**Fix:** After `Promise.all(...).flat()`, run `uniqueBy(events, e => e.uid + e.recurrenceId)` pass.
- Files: `stores/calendar-store.ts`
- Reuse: None
- Effort: 1h
### P1.9 — HIGH Fixes Batch
- **H4:** Strip `access_token` from OAuth error logs (`lib/oauth/token-exchange.ts`) — 30min
- **H9:** Fix bcrypt in `isHashed()` (`lib/admin/password.ts`) — 1h
- **H18:** Default `autoSelectReplyIdentity` to `true` (`stores/settings-store.ts`) — 30min
- **H7:** DOMPurify HTML template body on import (`lib/template-utils.ts`) — 1h
- **H13:** `calendarTasksEnabled` runtime enforcement (`stores/task-store.ts`, `components/calendar/`) — 2h
- **H14:** try/catch + toast on task mutations (`stores/task-store.ts`) — 1h
- **H8:** Derive `secure` cookie from `x-forwarded-proto` (`lib/admin/session.ts`) — 1h
- **H3:** Rate-limit user auth endpoints (`app/api/auth/*`) — 3h
- **H2:** Await state snapshot in `reconcileAfterWebSocketFallback` (`lib/jmap/client.ts`) — 2h
- **H1:** Push handler: add ContactCard/FileNode branches (`stores/email-store.ts`) — 3h
**P1 Total:** ~32h
---
## Phase 2 — Missing Features (Autonomous, Code Reuse)
### P2.1 — Extended Signatures (Multiple per Identity + HTML Editor)
**Docx:** "Add option to create various email signatures and to select standard signature for new emails and for replies"
**Reuse from Angular:**
- Models: `signature.model.ts` (Signature interface), `identity.model.ts` (zimbraPrefDefaultSignatureId)
- API: Signature CRUD endpoints, `modifySignaturePrefs()` pattern
- UI logic: Quill editor toolbar config → `react-quill-new` equivalent
**Implementation:**
1. New `stores/signature-store.ts` — Signature[] with CRUD + identity assignment
2. New `components/settings/signature-settings.tsx` — list management page
3. New `components/settings/signature-editor-modal.tsx` — Quill-based HTML editor
4. Extend `components/identity/identity-form.tsx` — default/forward-reply signature picker
5. Extend `components/email/email-composer.tsx` — signature selector dropdown in compose
6. JMAP/Sieve integration: if the server stores signatures as Sieve or Identity properties, map accordingly
**Effort:** 12h
### P2.2 — Create Appointment from Email
**Docx:** "create a calendar entry from an email with recipients as participants and email text in description"
**Reuse from Angular:**
- `Appointment` interface from `appoinment.model.ts`
- Calendar compose pre-fill logic from `calendar-compose.component.ts`
- API payload shapes for event creation
**Implementation:**
1. New `components/email/create-appointment-button.tsx` — button in email toolbar
2. Extend `components/calendar/event-modal.tsx` — accept pre-fill props (title=subject, description=body, participants=from+to+cc)
3. Wire "Create Appointment" action into email-viewer toolbar + context menu
**Effort:** 6h
### P2.3 — Folder Sharing (Mail, Calendar, Contacts, Files)
**Docx:** "Share Folder feature" + "Sharing to see all folders shared by me and with me"
**Reuse from Angular:**
- `ShareFolderComponent` (564 lines) — full sharing dialog with email autocomplete, role selection
- `PreferencesSharingComponent` (556 lines) — "shared by me" / "shared with me" views
- `AllSharingFoldersDialogComponent` — tree-based folder browser
- API: share/revoke/accept/decline endpoints and payload shapes
**Implementation:**
1. New `components/sharing/share-folder-dialog.tsx` — modal with email autocomplete, role picker (read/read-write/admin), message
2. New `components/sharing/share-folder-revoke-dialog.tsx` — revoke confirmation
3. New `components/sharing/accept-share-dialog.tsx` — accept incoming share
4. New `components/settings/sharing-settings.tsx` — "Shared by me" / "Shared with me" tabs with folder tree
5. Extend folder context menus in email sidebar, calendar sidebar, contacts sidebar, files sidebar with "Share Folder..." action
6. New `stores/sharing-store.ts` — tracking shares state
7. API routes: `app/api/sharing/*` — share/revoke/accept/decline/find
**Effort:** 18h
### P2.4 — Calendar Dashlet (Mini Calendar in Mail View)
**Docx:** "Show Calendar in a dashlet in the bottom left corner"
**Reuse from Angular:**
- `sidebar-mini-calendar.component.ts` (701 lines) — month grid, swipe navigation, tooltip
- Tooltip directive logic for fetching day events
**Implementation:**
1. New `components/calendar/mini-calendar-dashlet.tsx` — compact month grid widget
2. Integrate into mail sidebar or bottom-left overlay in the mail page layout
3. Show date dots for days with events, today highlight, click to navigate to calendar
4. Optionally: toggleable via user setting "Show calendar dashlet"
**Effort:** 8h
### P2.5 — Email Import (.eml, tgz, zip)
**Docx:** "Missing: feature to import emails"
**Reuse from Angular:**
- `ImportExportComponent` (513 lines) — import types, destination folder, resolve settings
- `preferenceService.importFromFile()` — API request shape
- CSV type auto-detection logic
**Implementation:**
1. Extend existing `.eml` import (already partial — see `lib/eml-import.ts` and `components/email/` for `.eml` preview)
2. Add ZIP/TGZ/TAR archive import — extract, iterate, import each `.eml`
3. New `components/settings/import-settings.tsx` — import UI with file picker, destination folder, conflict resolution
4. API route: extend `app/api/account/*` or new `app/api/import/*`
**Effort:** 10h
### P2.6 — Contact Import (vCard/CSV)
**Docx:** "Missing: feature to import contacts"
**Reuse from Angular:**
- `contact-file-import-dialog.component.ts` (228 lines) — CSV upload, folder selector
- `contactService.importContacts()` — API endpoint
- vCard parsing: `lib/vcard.ts` already exists in codebase
**Implementation:**
1. Extend existing `components/contacts/contact-import-dialog.tsx` to support CSV (currently vCard-only)
2. Add CSV column mapping UI (map CSV columns to contact fields)
3. Folder selection for import destination
4. Dedup handling
**Effort:** 6h
### P2.7 — Free/Busy View
**Docx:** "Missing: free/busy view"
**Reuse from Angular:**
- `scheduler.component.ts` (1303 lines) — full free/busy grid
- `scheduler-utils.ts` — free-busy status constants (`fba` values, `FBA_TO_PTST` mapping)
- `schedule-assistant.component.ts` (1081 lines) — suggestion engine algorithm
- Free/busy URL format: `{serverURL}/home/{email}?fmt=freebusy`
**Implementation:**
1. New `components/calendar/free-busy-view.tsx` — attendee-row × time-slot grid
2. Color-coded slots: free (white), busy (red), tentative (yellow), out-of-office (purple)
3. Integration into event-modal when adding participants — show availability inline
4. Also queries `resources_bookings` table (see P2.8) to show resource availability in the same grid
5. New `lib/calendar-freebusy.ts` — fetch and parse free/busy data (user calendars + resource bookings)
**Effort:** 16h (was 14h, added resource booking integration)
### P2.8 — Resources/Equipment Booking (VNCdirectory-backed PostgreSQL)
**Docx:** "Missing: resources/equipment"
**Architecture decision:** Resources managed in **separate PostgreSQL table**, mapped to **VNCdirectory** for centralized cross-application management (rooms, cars, equipment, etc.). Independent from mail client — this is a platform-level resource system.
**Reuse from Angular:**
- `calendar-equipment-dialog.component.ts` (373 lines) — equipment browser UI patterns
- `calendar-equipment-autocomplete.component.ts` (266 lines) — autocomplete UX
- GAL query: `zimbraCalResType === "Equipment"` → adapt to VNCdirectory resource type filter
**Backend (New):**
1. **PostgreSQL migration**`resources` table:
- `id` UUID PK
- `tenant_id` UUID (VNCdirectory tenant scope)
- `name` text
- `type` enum (room, vehicle, equipment, other)
- `location` text (building/floor/room)
- `capacity` integer nullable
- `description` text
- `contact_email` text (responsible person)
- `is_active` boolean
- `metadata` jsonb (extensible: photo URL, amenities, access hours, etc.)
- `created_at`, `updated_at` timestamps
2. **`resources` table → VNCdirectory sync** — VNCdirectory is the canonical source. Options:
- **Pull model:** VNCdirectory writes to this table via API/webhook
- **Push model:** This service syncs changes back to VNCdirectory
- **Read-through:** Query VNCdirectory directly for resource listings; cache in PostgreSQL for availability booking
- Decision needed: which direction is authoritative? (Assume VNCdirectory → this table for Phase 1)
3. **New API routes:** `app/api/resources/`
- `GET /api/resources` — list/search resources (type filter, location filter, tenant scoped)
- `GET /api/resources/[id]` — single resource detail
- `GET /api/resources/[id]/availability?start=&end=` — free/busy for a resource
- `POST /api/resources/[id]/book` — create booking for a resource
- `DELETE /api/resources/[id]/book/[bookingId]` — cancel booking
4. **New `lib/resources/` service layer:**
- `lib/resources/client.ts` — fetch/query resources from VNCdirectory or local DB
- `lib/resources/availability.ts` — check resource availability for time range
- `lib/resources/booking.ts` — book/cancel resource
- `lib/resources/sync.ts` — sync with VNCdirectory (if push-model needed)
5. **Conflict checking:**
- When booking, check `resources_bookings` table for overlapping time ranges
- Return conflicts + alternative slots
**Frontend:**
1. Extend `components/calendar/event-modal.tsx` — "Resources" tab with:
- Resource type filter (room, vehicle, equipment)
- Searchable autocomplete (name, location, capacity)
- Availability indicator (free/busy for event time range)
2. New `components/calendar/resource-picker.tsx` — reusable resource selection component
3. Booked resources appear in event detail, participant list, and email invitation
**Effort:** 20h (was 10h, doubled for PostgreSQL + VNCdirectory integration)
### P2.9 — VNCtalk Video Meeting Integration
**Docx:** "Missing: integration with VNCtalk -> create Videomeeting"
**Reuse from Angular:**
- `app.service.ts``createNewMeeting()` / `updateScheduledMeeting()` API calls
- Payload: `POST /api/createnewmeeting { name, start, end, invitees, password, description, invid?, rev?, ms? }`
- `createOrUpdateMeeting()` in `edit-appointment-dialog.component.ts` — builds payload from appointment
**Implementation:**
1. New `lib/vnctalk/client.ts` — VNCtalk API client (create/update meeting)
2. Extend `components/calendar/event-modal.tsx` — "Create VNCtalk Meeting" toggle/button
3. Store meeting JID on CalendarEvent for updates
4. Add meeting link to event detail popover and email invitation body
**Effort:** 8h
### P2.10 — Collabora Online Editing
**Docx:** "Add feature to collaborate with Collabora"
**Reuse from Angular:**
- `owncloud.service.ts``getDocumentUrl(fileId, useCollabora)` with RichDocuments API
- `POST ocs/v2.php/apps/richdocuments/api/v1/document?format=json`
- Config: `collaboraBaseUrl` from admin config
**Implementation:**
1. New `lib/collabora/client.ts` — fetch editing URL from Collabora server
2. New `components/files/collabora-editor.tsx` — iframe-based editor embedding
3. Extend `components/files/file-browser.tsx` — "Edit with Collabora" action for office files
4. Admin config: `collaboraBaseUrl` in policy/config
**Effort:** 10h
### P2.11 — Calendar Enhancements Batch
**Docx:** Multiple calendar improvements
**2.11a — Clickable links in emails**
- Already partially done (TipTap Link extension). Verify link rendering in calendar event descriptions.
- Effort: 2h
**2.11b — Contact details of participants**
- Add popover on participant names in event-modal showing contact card. Reuse `components/contacts/contact-detail.tsx` data.
- Effort: 4h
**2.11c — Reply / Reply to All in meetings**
- Extend event-modal with "Reply" and "Reply to All" buttons that open composer pre-filled with participant emails.
- Effort: 3h
**2.11d — Timezone support**
- Add timezone picker to event-modal. Use `date-fns-tz` (already a dependency). Display times in event timezone with user timezone conversion.
- Effort: 6h
**2.11e — Map links**
- Extract address from event location, generate Google Maps / OpenStreetMap link.
- Effort: 2h
### P2.12 — Action Wheel (Custom Radial Menu)
**Docx:** "Recreate Action Wheel or better functionality"
**Board decision:** Build custom radial menu.
**Implementation:**
1. New `components/ui/radial-menu.tsx` — SVG-based radial menu with configurable items
2. Supports: mail actions (reply, forward, delete, archive, mark read, move, tag), contact actions, file actions
3. Trigger: long-press on mobile, right-click on desktop, or dedicated button
4. Animations: CSS rotate + scale transitions
5. Keyboard accessible
**Effort:** 10h
### P2.13 — IDP Integration — VNCdirectory Admin Configuration Panel
**Docx:** "IDP integration will be towards VNCdirectory (openldap, simplesamlphp, 2fa etc.)"
**Architecture decision:** Add admin UI for configuring VNCdirectory connection settings. Reuse auth patterns from VNCmail-analysis `api/auth-proxy/`.
**Reuse from Angular (api/auth-proxy/):**
- `config/config.js.example` — full auth configuration schema (SAML, LDAP, VNCdirectory, 2FA, hybrid auth)
- `config/passport.js` — SAML strategy + JWT custom strategy setup
- `routes/index.js` — login/logout/SAML callback/LDAP search/2FA/TOTP routes
- `utils/common.js` — JWT verification, Zimbra preauth token creation
- Auth dependencies: `@node-saml/passport-saml`, `passport`, `jsonwebtoken`, `ldapjs`
**Current VNCmail+ auth stack vs Angular auth stack:**
| Feature | Angular (Zimbra) | VNCmail+ (Stalwart) |
|---------|-----------------|---------------------|
| Primary auth | SAML 2.0 via Passport | OAuth/OIDC via Stalwart |
| Identity source | Zimbra LDAP + VNCdirectory | Stalwart internal + OIDC |
| 2FA | VNCdirectory TOTP/DUO | TOTP via Stalwart admin API |
| Directory integration | Redmine API (`contactsApiUrl`) | ❌ None |
| LDAP backend | `ldapjs` → Zimbra LDAP | ❌ None |
| SSO/Federation | JWT deeplinks + SAML | Cookie-based + OAuth |
**Implementation:**
1. **New `lib/vncdirectory/` service layer:**
- `lib/vncdirectory/client.ts` — API client for VNCdirectory REST endpoints (port auth patterns from `routes/index.js`)
- `lib/vncdirectory/config.ts` — VNCdirectory connection settings (URL, API key, LDAP bind, SAML IDP metadata)
- `lib/vncdirectory/auth.ts` — SAML 2.0 SP implementation using `@node-saml/passport-saml` via Next.js API routes
- `lib/vncdirectory/ldap.ts` — LDAP client using `ldapjs` for user/group/GAL queries
- `lib/vncdirectory/2fa.ts` — TOTP enrollment + verification via VNCdirectory
2. **New API routes:** `app/api/vncdirectory/`
- `GET /api/vncdirectory/status` — connection health check
- `POST /api/vncdirectory/saml/login` — initiate SAML login flow
- `POST /api/vncdirectory/saml/callback` — SAML assertion consumer
- `POST /api/vncdirectory/saml/logout` — SAML single logout
- `GET /api/vncdirectory/users` — search users (LDAP + VNCdirectory)
- `POST /api/vncdirectory/2fa/enroll` — generate TOTP secret
- `POST /api/vncdirectory/2fa/verify` — verify TOTP code
- `GET /api/vncdirectory/2fa/status` — check 2FA enrollment status
- `GET /api/vncdirectory/tags` — directory contact tags
- `POST /api/vncdirectory/tags` — create/update directory tags
3. **New admin configuration page:**
- `app/(main)/admin/vncdirectory/page.tsx` — VNCdirectory settings panel
- Sections:
- **Connection:** VNCdirectory URL, API key, LDAP URI, bind credentials
- **SAML/IDP:** Identity Provider URL (SimpleSAMLphp), SP certificate, issuer
- **Authentication:** Toggle SAML login, toggle 2FA enforcement, OIDC settings
- **Directory sync:** LDAP type (OpenLDAP/MS-AD), search base, attribute mapping
- **Federated apps:** Configure SSO URLs for VNCtalk, VNCtask, VNCcontacts
- Add to admin navigation sidebar
4. **New `stores/vncdirectory-store.ts`** — client-side config state
5. **Extend existing auth:**
- Add SAML login as alternative to existing Basic/OAuth flows
- Add VNCdirectory as identity source alongside Stalwart
- Wire 2FA through VNCdirectory (currently uses Stalwart admin API)
**Effort:** 24h
### P2.14 — Share Files by Email as Attachment
**Docx:** "share by email as attachment"
**Implementation:**
1. Extend `components/files/file-browser.tsx` — "Send as Email Attachment" action
2. Opens composer with selected files attached (reuse existing attachment upload in composer)
3. Effort: 4h
**P2 Total:** ~127h
---
## Phase 3 — Security Hardening (Autonomous)
### P3.1 — Feature Gate Server-Side Enforcement
**Issue:** Feature gates are UI-only. Disabled features remain accessible via direct API calls.
**Fix:** Add policy checks to API routes. For each feature-gated route, add `isFeatureEnabled()` check returning 403.
- Routes: `app/api/smime/*`, `app/api/calendar-agenda/*`, `app/api/offline/*`, `app/api/plugins/*` (plugin disabled)
- Effort: 4h
### P3.2 — Unified Auth Error Interceptor
**Issue:** 401/403 errors silently swallowed in data fetches.
**Fix:** Create `lib/auth-error-handler.ts` — global fetch wrapper that detects 401 and triggers re-auth flow. Wire into JMAP client `authenticatedFetch`.
- Effort: 6h
### P3.3 — Store-Level State Isolation on Account Switch
**Issue:** Manual `clearAllStores()` misses new fields and stores.
**Fix:** Define per-store `snapshot(): Partial<S>` and `clear(): Partial<S>` contract. Auto-discover registered stores via a registry.
- Effort: 8h
### P3.4 — Push Event Bus Extraction
**Issue:** Email store is the push dispatch hub for 5+ stores.
**Fix:** Extract `lib/push-event-bus.ts` — stores subscribe to JMAP type names. Email store stops importing calendar/filter/task stores.
- Effort: 8h
**P3 Total:** ~26h
---
## Phase 4 — Polish & Remaining HIGH Items
### P4.1 — Offline Write Queue
**Issue:** No offline write capability. Cannot compose/send while offline.
**Fix:** Add `replica_pending_ops` table. Stage mutations offline, replay on connectivity return. Start with email send only, then extend.
- Effort: 16h
### P4.2 — Identity Spoofing Protection
**Issue:** From override accepts arbitrary addresses.
**Fix:** Client-side validation — restrict `fromOverrideEmail` to domains matching user's identities.
- Effort: 2h
### P4.3 — WebSocket Push for Electron
**Issue:** Browser WebSocket push permanently disabled.
**Fix:** Implement main-process WebSocket bridge in Electron via IPC. Renderer sends token, main process connects WS with auth header.
- Effort: 8h
### P4.4 — Remaining MEDIUM audit findings
- Calendar: recurrence cap warning, prefix scheme unification, read-only calendar filter, bulk delete batching
- Contacts: cross-account move race, autocomplete indexing, import dedup
- Files: folder tree cache reuse, upload parallelism
- Search: `toWildcardQuery` quote handling, `searchEmails` AbortController
- Effort: ~20h
**P4 Total:** ~46h
---
## Summary
| Phase | Hours | Description |
|-------|-------|-------------|
| P1 | 32h | Critical + HIGH bug fixes (18 items) |
| P2 | 167h | Missing features from docx (14 features) |
| P3 | 26h | Security hardening (4 items) |
| P4 | 46h | Polish + remaining fixes (4 items) |
| **Total** | **~271h** | |
### New Infrastructure Dependencies (P2)
- **PostgreSQL database** — `resources` + `resources_bookings` tables for VNCdirectory-backed resource management
- **VNCdirectory** — canonical source for resources + IDP identity provider (SAML 2.0, LDAP, 2FA/TOTP, directory tags)
- **SimpleSAMLphp** — SAML 2.0 Identity Provider (`vncidp.dev.vnc.de`) for web SSO
- **OpenLDAP** — LDAP directory for user/group queries and GAL (via `ldapjs`)
- **Collabora server** — `collaboraBaseUrl` admin config for online document editing
- **VNCtalk API** — `/api/createnewmeeting` endpoint for video meeting integration
### Autonomy Level
- **100% autonomous** — No further board decisions needed.
- **Sync direction (VNCdirectory ↔ PostgreSQL)**: Assumed VNCdirectory → PostgreSQL (pull) for Phase 1. Can be swapped if VNCdirectory expects push updates.
- **Code reuse:** 12 areas from VNCmail-analysis Angular codebase (models, API patterns, business logic). Must be adapted from Angular DI/services to plain TS functions + React hooks.
- **Repository access:** `brvncde-dotcom/vncmail-plus` (target), `brvncde-dotcom/VNCmail-analysis` (reuse).
### Deploy Flow
Per policy: P1 → deploy to `dev` → QA → fix → promote to `main`. Then P2 → dev → QA → main. Repeat for P3, P4.
### First Sprint Scope
**Phase 1 only** — ship all 8 CRITICAL + 10 HIGH fixes (~32h). This brings health from 7.2 to ~8.5/10 and addresses the most impactful user-facing bugs before adding new features.
---
Do you want me to start Phase 1 immediately, or adjust the plan?
+349
View File
@@ -0,0 +1,349 @@
# VNCmail+ Holistic Audit Report
**Run:** 2026-08-07-v1.7.8-baseline
**Skill:** VNCmailgraph v1.0 (adapted for Next.js/Zustand)
**Commit:** d8bebb531f86cab3507aed2113e8d0e6a03c1aa8
**Version:** vnc-v0.3.0-94-gd8bebb53 (VERSION=1.7.8)
**Codebase:** ~188K LOC, 745 TS/TSX files, 28 Zustand stores, 24 API endpoint groups
**Framework:** Next.js 16 (App Router) + React 19 + Zustand 5
**Backend:** Stalwart Mail Server (JMAP protocol)
**Targets:** Web (PWA), Electron Desktop, Native (planned via Capacitor/RN)
---
## Executive Summary
VNCmail+ v1.7.8 is a **production-grade Next.js groupware client** with comprehensive feature coverage (mail, calendar, contacts, files, tasks, filters, templates, AI assistant, admin, plugins) and well-architected security (DOMPurify + CSP nonces + SSRF guards + plugin sandboxing). The codebase has **strong foundations** but exhibits systemic coupling patterns that create cross-feature fragility as the feature surface has grown beyond the original single-account mail client design.
**Audit scope:** 20 parallel sub-agents audited 16 feature areas + 4 substrate layers using a 6-lens methodology (Correctness, Data-Integrity, Cross-Feature Coupling, Security, Performance, Platform-Parity).
**Key statistics:**
- **82 findings** identified: 8 CRITICAL, 19 HIGH, 35 MEDIUM, 20 LOW
- **Overall health composite:** 7.2/10
- **Strongest areas:** Security (9/10 for mail XSS defense), Offline replica cursor provenance (exceptional), Setup wizard (8.4/10 clean)
- **Weakest areas:** Feature gate enforcement (UI-only, no server-side), Push cross-feature dispatch, Offline write capability (nonexistent), Auth error propagation (silent failures)
---
## CRITICAL Findings (8)
### C1. Mail: `getEmails`/`searchEmails` error swallowing masks transport failures
- **Location:** `lib/jmap/client.ts:1292-1294, 2157-2158`
- **Impact:** Network-down = empty folder. No "you are offline" indicator. Dead network indistinguishable from empty mailbox. Transport-health counter exists but is never sampled by store callers.
- **Recommendation:** Sample `transportFailureCount()` delta before/after reads; if incremented, throw rather than return empty.
### C2. Calendar: Recurrence expansion ID collision with shared event prefix
- **Location:** `lib/recurrence-expansion.ts:242`, `stores/calendar-store.ts:149`
- **Impact:** Both recurrence expansion (`:` between master-id and recurrence-date) and shared events (`accountId:eventId`) use single `:` as delimiter. ID collisions possible.
- **Recommendation:** Use non-colliding delimiter (e.g., `--` or `::occurrence::`) for expansion.
### C3. Calendar: Multi-account event aggregation has no cross-account UID dedup
- **Location:** `stores/calendar-store.ts:368-395`
- **Impact:** Two accounts subscribed to same public holiday calendar → every event appears twice. User sees phantom duplicates.
- **Recommendation:** Run post-merge UID dedup after `Promise.all` + `flat()`.
### C4. Admin: Session token has no server-side revocation
- **Location:** `lib/admin/session.ts:149-158`
- **Impact:** Once issued, AES-256-GCM encrypted token remains valid until `exp`. Token exfiltration is permanent — no revocation list.
- **Recommendation:** Add token blacklist or short TTL + refresh.
### C5. Storage: Auth tokens/credentials in unencrypted Zustand persist → localStorage
- **Location:** `stores/auth-store.ts:553-554`, `stores/account-store.ts`
- **Impact:** Any dependency with DOM access (plugins, extensions) can read `auth-storage`/`account-storage` from localStorage. Server URLs + usernames exposed.
- **Recommendation:** Encrypt persisted payloads or use sessionStorage where feasible.
### C6. Search: Schema-version mismatch drops entire FTS5 index without automatic rebuild
- **Location:** `lib/mail-index/store.ts:230-234`
- **Impact:** Deploy that changes `SCHEMA_VERSION` silently wipes all users' search indexes. No automatic rebuild trigger. Index remains empty until next push event or manual catch-up.
- **Recommendation:** Trigger automatic `catchUpAll()` after schema-initiated drop.
### C7. Store Coupling: Settings locks bypassed at store level — `updateSetting` has no policy guard
- **Location:** `stores/settings-store.ts:646`
- **Impact:** 40 UI-level `isSettingLocked()` checks exist, but `useSettingsStore.getState().updateSetting()` from any code path (auth-store bootstrap, plugins, server sync) writes through the lock. Admin policy is UI-only.
- **Recommendation:** Add `isSettingLocked()` gate inside `updateSetting()`. Add `{ force: true }` opt-in for legitimate bypassers.
### C8. Push/Sync: No offline event detection — transports retry blindly during outages
- **Location:** `lib/jmap/client.ts:6783-6808`
- **Impact:** Only `online` listener registered; no `offline` listener. When browser goes offline, WS keeps retrying, SSE keeps reconnecting, polling keeps firing — all silently failing, draining battery.
- **Recommendation:** Add `offline` handler that calls `closePushNotifications()`. Gate with `navigator.onLine`.
---
## HIGH Findings (19)
### Cross-Feature / Substrate (7)
**H1. Push: ContactCard and FileNode state changes silently ignored by UI stores**
`stores/email-store.ts:2834-2941` — Push handler fans out to Email, Mailbox, Calendar, CalendarEvent, SieveScript but has NO branch for ContactCard or FileNode. Remote contact/file changes are invisible until manual refresh.
**H2. Push: Fallback chain has timed gap where deliveries are missed**
`lib/jmap/client.ts:6146-6177` — WS→SSE handoff window (~600ms) loses deliveries if state snapshot hasn't completed. Acknowledged as known gap in code comments.
**H3. Auth: No rate limiting on user-facing auth endpoints**
`lib/admin/rate-limit.ts:6-7` — Rate limiter only protects admin login. User auth endpoints (`/api/auth/session`, `/api/auth/token`, `/api/auth/totp-token-exchange`) are open to brute force.
**H4. Auth: Access token leaked in error logs on token exchange failure**
`lib/oauth/token-exchange.ts:124` — Full token response (including `access_token`) logged when exchange fails. Tokens written to centralized logging.
**H5. Auth: Account registry + auth metadata stored unencrypted in localStorage**
`stores/account-store.ts:220-226` — Same as C5, distinct from cookie-encrypted session tokens.
**H6. Settings: `exportSettings()` serializes `trustedSenders` email addresses in plaintext**
`stores/settings-store.ts:697` — Plus `emailKeywords`, `folderIcons`, `allMailFolderIds` — user-specific data in export.
**H7. Templates: HTML body bypasses sanitization on import**
`lib/template-utils.ts:176` — When `isHTML===true`, template body imported raw. DOMPurify bypassed, enabling stored XSS when the template is applied in the TipTap editor.
### Feature-Specific (12)
**H8. Admin: `secure` cookie flag based on NODE_ENV, not request protocol**
`lib/admin/session.ts:154` — Reverse proxy with TLS termination + HTTP internal → Secure cookie breaks.
**H9. Admin: bcrypt hashes silently broken — password lockout**
`lib/admin/password.ts:60-62``isHashed()` returns true for bcrypt but `verifyPassword()` only handles scrypt. Operator locked out.
**H10. Calendar: Event modal allows creating events in read-only shared calendars**
`components/calendar/event-modal.tsx:260-265` — No `myRights?.mayWriteAll` filter on calendar selector. Server rejects with confusing error.
**H11. Calendar: Recurrence expansion silently caps at 500 occurrences**
`lib/recurrence-expansion.ts:330` — Long-running daily events don't render at all. No error/warning.
**H12. Calendar: Inconsistent prefix scheme (`:` vs `::`)**
`stores/calendar-store.ts:64,149``CROSS_ACCOUNT_ID_DELIMITER = '::'` but shared events use `:`. `stripLocalAccountPrefix` only strips `::` prefix.
**H13. Tasks: Admin feature gate `calendarTasksEnabled` has no runtime enforcement**
`components/settings/calendar-settings.tsx:87` — Gate only controls settings UI toggle visibility. Previously-enabled tasks remain accessible after admin disables.
**H14. Tasks: All mutation operations lack error handling — silent failures**
`stores/task-store.ts:67-92``updateTask`, `deleteTask`, `toggleTaskComplete` have no try/catch. Toast never fires on failure.
**H15. Mail: SSE connect does no catch-up fetch — mail lost during reconnect window**
`lib/jmap/client.ts:6290-6301` — Explicitly documented as "Not airtight".
**H16. Mail: Browser WebSocket push permanently disabled — auth header limitation**
`lib/jmap/client.ts:6052-6073` — Browser `WebSocket` constructor can't attach custom headers. After 3 failures, `wsPermanentlyDisabled = true` for session.
**H17. Mail: Email-store push handler directly drives calendar-store — layering violation**
`stores/email-store.ts:2914-2930` — Email store calls `calendarStore.fetchCalendars()` and `fetchEvents()`. Hard dependency.
**H18. Identity: `autoSelectReplyIdentity` defaults to `false` — auto-identity selection broken**
`stores/settings-store.ts:487` — New users always send as primary identity, never auto-select based on reply target.
**H19. Identity: From override accepts arbitrary email addresses — identity spoofing**
`email-composer.tsx:2283-2294` — User can set `fromOverrideEmail` to any address (e.g., `ceo@competitor.com`). Display `From:` header spoofable.
---
## Synergetic Failure Analysis (Cross-Feature Patterns)
### Synergy 1: The Push Dispatch Hub Problem
**Affected:** Mail, Calendar, Tasks, Filters, Contacts, Files, Search Index, Offline Replica
The email-store's push handler (`stores/email-store.ts:2834-2941`) has grown into the de facto `/changes` dispatcher driving 5+ feature stores. This creates a single point of failure where:
- Email store must be initialized before any push-triggered feature refresh works
- ContactCard and FileNode state changes are silently dropped (no branch)
- Calendar refresh is fire-and-forget with no error handling
- Search index reindex is triggered but no rebuild verification
**Root cause:** Organic growth from single-account mail client to multi-feature groupware without extracting the push dispatcher into an independent event bus.
### Synergy 2: Feature Gate Enforcement Gap
**Affected:** Calendar, Tasks, Contacts, Files, S/MIME, Templates, Plugins
Feature gates are nearly 100% UI-only. The pattern:
```
isFeatureEnabled('calendarEnabled') → hides UI only
```
No store-level check, no API route check, no server-side enforcement for most features. A disabled feature remains fully functional via direct API calls. `getEffectiveDefault()` is dead code with zero callers.
**Root cause:** Feature gates were added as a UI visibility toggle without the corresponding enforcement at the data/API layer.
### Synergy 3: Offline Capability Gap
**Affected:** Mail, Calendar, Contacts, Files, Tasks
The offline replica is a carefully engineered read-path fallback for Email/Mailbox only. There is:
- **No offline write queue** — cannot compose/send email, create events, or modify contacts while offline
- **No calendar/contacts/files replication** — only Email and Mailbox have `/changes` cursors
- **No cross-feature offline coordination** — search index, replica, and localStorage stores have three independent retention policies (30d, 180d, indefinite)
**Root cause:** The replica was designed as a server-outage fallback, not a full offline-first architecture.
### Synergy 4: Auth Error Propagation Gap
**Affected:** All 16 features
401/403 errors from data fetches are silently swallowed with `debug.error()` log lines:
```ts
contactStore.fetchAddressBooks(client).catch((err) => debug.error(...));
calendarStore.fetchCalendars(client).catch((err) => debug.error(...));
```
There is no unified auth error interceptor, no error boundary for async failures, and no user-visible re-auth prompt. A timed-out session shows silently broken UI for up to 60 seconds.
**Root cause:** Each store independently handles errors; no shared error propagation channel exists.
### Synergy 5: Store-Level State Leak Across Accounts
**Affected:** Mail, Calendar, Contacts, Tasks, Message-List-Tabs
`account-state-manager.ts` snapshots 6 stores but misses message-list-tabs-store, task-store, and partial field coverage. `clearAllStores()` requires manual field enumeration — any new store field added without updating the reset list silently leaks across account switches.
**Root cause:** No per-store `snapshot()`/`clear()` contract. Manual maintenance at the account-state-manager level.
---
## Symptom → Cause Map
| User Symptom | Root Cause | Finding |
|---|---|---|
| "My calendar is empty" after login | `initializeFeatureStores` silently fails on calendar fetch | H-SYN-4 |
| "I can still use tasks after admin disabled them" | `calendarTasksEnabled` gate is UI-only | H13 |
| "No new mail notification" after wake from sleep | No `offline` handler to pause push; transports retry blindly | C8 |
| "My contacts haven't updated" on another device | Push handler has no ContactCard branch | H1 |
| "I see duplicate events" with multiple accounts | No cross-account UID dedup in calendar aggregation | C3 |
| "Can't search old email" in desktop app | 30-day FTS5 window, no user-facing indicator | SRC-007 |
| "Lost my search index" after update | Schema version bump drops all tables, no auto-rebuild | C6 |
| "Settings lock doesn't work" via console | `updateSetting()` has no `isSettingLocked()` check | C7 |
| "Can't send email offline" | No offline write queue | H-SYN-3 |
| "Wrong From address on reply" | `autoSelectReplyIdentity` defaults to `false` | H18 |
| "Template imported with HTML executes scripts" | `isHTML` bypasses DOMPurify on import | H7 |
| "Auth token in server logs" after IdP outage | Token response logged on exchange failure | H4 |
---
## Tiered Action Plan
### TIER 1 — Immediate (This Sprint)
| ID | Finding | Effort | Risk |
|----|---------|--------|------|
| C1 | Fix mail error swallowing — sample transport-health | 2h | LOW |
| C8 | Add `offline` event handler to pause push transports | 1h | LOW |
| C7 | Gate `updateSetting()` with policy lock check | 3h | MEDIUM — needs `force` opt-in audit |
| C6 | Auto-rebuild FTS5 index after schema-version drop | 2h | LOW |
| H7 | Apply DOMPurify to imported HTML template bodies | 1h | LOW |
| H13 | Add `calendarTasksEnabled` enforcement at runtime | 2h | LOW |
| H14 | Add try/catch + toast to task mutations | 1h | LOW |
| H4 | Strip `access_token` from OAuth error log context | 30m | LOW |
| H9 | Fix bcrypt hash handling in password verification | 1h | LOW |
| H18 | Default `autoSelectReplyIdentity` to `true` | 30m | LOW |
### TIER 2 — Next Sprint
| ID | Finding | Effort |
|----|---------|--------|
| C4 | Add admin session token revocation (blacklist) | 5h |
| C3 | Add cross-account UID dedup in calendar aggregation | 3h |
| C2 | Fix recurrence expansion ID delimiter collision | 2h |
| C5 | Encrypt auth metadata in localStorage | 4h |
| H1 | Add ContactCard/FileNode branches to push handler | 3h |
| H2 | Make `reconcileAfterWebSocketFallback` await state snapshot | 2h |
| H3 | Add rate limiting to user-facing auth endpoints | 3h |
| H8 | Derive `secure` cookie from request protocol | 1h |
| H10 | Filter read-only calendars from event-modal selector | 1h |
| H11 | Add warning when 500-occurrence cap exhausted | 1h |
| H12 | Unify calendar ID prefix scheme to `::` | 4h |
| H19 | Add client-side validation for From override domain | 3h |
### TIER 3 — This Quarter
| ID | Finding | Effort |
|----|---------|--------|
| H15 | Add catch-up fetch on SSE reconnect | 4h |
| H16 | Implement Electron main-process WebSocket bridge for push | 8h |
| H17 | Extract push dispatch into dedicated event bus | 8h |
| SYN-4 | Add unified auth error interceptor + UI boundary | 8h |
| SYN-1 | Refactor push handler into independent store subscriptions | 12h |
| SYN-3 | Add offline write queue (pending ops table) | 16h |
| SYN-5 | Define per-store `snapshot()`/`clear()` contract | 8h |
| — | Extend replica to CalendarEvent, ContactCard, FileNode types | 20h |
| — | Add per-feature server-side feature gate enforcement | 12h |
---
## Coverage Map
| Feature | Audited | CRITICAL | HIGH | MEDIUM | LOW | Health Score |
|---------|---------|----------|------|--------|-----|-------------|
| Mail/Email | ✅ N01 | 2 | 4 | 6 | 3 | 7.3 |
| Calendar | ✅ N02 | 2 | 4 | 6 | 2 | 7.2 |
| Contacts | ✅ N03 | 0 | 1 | 4 | 5 | 7.3 |
| Files/Briefcase | ✅ N04 | 0 | 0 | 4 | 4 | 7.3 |
| Tasks | ✅ N05 | 0 | 2 | 3 | 3 | 6.0 |
| Settings | ✅ N06 | 0 | 2 | 4 | 5 | 7.5 |
| Filters/Sieve | ✅ N07 | 0 | 0 | 1 | 4 | 8.1 |
| Templates | ✅ N08 | 0 | 1 | 3 | 12 | 7.4 |
| Identity/Aliases | ✅ N09 | 0 | 2 | 3 | 6 | 8.0 |
| AI Assistant | ✅ N10 | 0 | 3 | 4 | 3 | 6.5 |
| Admin | ✅ N11 | 1 | 2 | 1 | 4 | 7.7 |
| Plugin System | ✅ N12 | 0 | 0 | 4 | 8 | 7.7 |
| Pro Shell | ✅ N13 | 0 | 0 | 2 | 3 | 7.5 |
| Search | ✅ N14 | 0 | 2 | 3 | 5 | 8.0 |
| Authentication | ✅ N15 | 0 | 3 | 5 | 4 | 7.0 |
| Setup Wizard | ✅ N16 | 0 | 0 | 2 | 4 | 8.4 |
**Substrate layers audited in Wave 2:**
| Substrate | Audited | CRITICAL | HIGH | MEDIUM | Health |
|-----------|---------|----------|------|--------|--------|
| Store Coupling | ✅ N20 | 1 | 3 | 4 | — | 4.0 |
| Offline/Storage | ✅ N21 | 2 | 2 | 4 | — | 7.0 |
| Sync/Push/Background | ✅ N22 | 2 | 3 | 5 | — | 5.0 |
| Auth/Session/Entitlement | ✅ N24 | 1 | 0 | 5 | — | 5.5 |
---
## Platform Parity Summary
| Capability | Web (PWA) | Electron Desktop | Native (planned) |
|-----------|-----------|------------------|------------------|
| Mail reading | ✅ Full | ✅ Full | ❌ Planned |
| Offline reads | ❌ No replica | ✅ SQLCipher replica | ❌ Planned |
| Local search | ❌ No FTS5 | ✅ SQLCipher FTS5 (30d) | ❌ Planned |
| Push notifications | ✅ Web Push | ✅ Electron Notification (window-open only) | ❌ Planned |
| Offline writes | ❌ None | ❌ None | ❌ Planned |
| AI local LLM | ⚠️ CORS needed | ✅ Direct loopback | ❌ Planned |
| S/MIME | ✅ Full | ✅ Full | ❌ Planned |
**Key platform gap:** Electron desktop has offline read capability but no push when window is closed (renderer dies). Web has push via service worker but no offline storage.
---
## Storage Subsystem Decision (§D)
### Recommendation: Stay with current SQLite split, extend with OPFS for web
| Phase | Action |
|-------|--------|
| **Phase 1 (Now)** | Harden current: auto-rebuild index after schema drop, batch `pruneOlderThan`, add user-facing index-status indicator |
| **Phase 2 (Q4)** | Abstract behind `ReplicaStore` interface. Implement `WebReplicaStore` via `sqlite-wasm/OPFS` for browsers. Keep `@signalapp/sqlcipher` for Electron. |
| **Phase 3 (Later)** | `MobileReplicaStore` via Capacitor SQLite plugin or `expo-sqlite` |
**Rationale:** RxDB and WatermelonDB add weight without solving problems the current codebase has already solved correctly (cursor provenance, error taxonomy, clock-jump guard). The current SQL split is proven in Signal Desktop. The gap is platform coverage, not architecture quality.
**Top 3 risks of any migration:**
1. Cursor-provenance regression (branded types don't survive JSON round-trips)
2. Concurrent-writer SQLITE_BUSY on push-triggered index+replica writes
3. Encryption downgrade when moving to IndexedDB or OPFS without explicit encryption
---
## Methodology Notes
This audit adapted the VNCmailgraph methodology from Angular/NgRx to Next.js/Zustand. Key translations:
- Feature domains → Next.js page routes + component trees + Zustand stores
- Shared substrate → Zustand store imports + lib/ services + API routes
- Platform parity → `isElectronShell()` (not `isCordova`/`isElectron`)
- Feature flags → `lib/admin/types.ts:FeatureGates` (not `zimbra-features.ts`)
- Storage → `lib/offline-replica/` + `lib/mail-index/` (SQLite/SQLCipher)
**AI cost:** Approximately 380K input tokens + 85K output tokens across 20 parallel sub-agent audits. Waves 0 (inventory) + calibration (2 nodes) + Wave 1 (14 nodes) + Wave 2 (4 nodes) + synthesis.
**Secret hygiene:** No secrets included in this report. All referenced snippets are from public API signatures and type definitions.
---
## Deliverables
- `runs/2026-08-07-v1.7.8-baseline/inventory.md` — Feature inventory + coupling DAG
- `runs/2026-08-07-v1.7.8-baseline/graph.md` — Execution graph + node assignments
- `runs/2026-08-07-v1.7.8-baseline/REPORT.md` — This report
- `runs/2026-08-07-v1.7.8-baseline/raw/` — Raw per-node findings (to be saved from agent outputs)
**Next run:** Re-run against next release to populate coverage map deltas (`persists|fixed|new|regressed`).
+102
View File
@@ -0,0 +1,102 @@
# Wave 0 — Execution Graph
**Run:** 2026-08-07-v1.7.8-baseline
**Skill:** VNCmailgraph
**Commit:** d8bebb531f86cab3507aed2113e8d0e6a03c1aa8
---
## DAG Structure
The graph decomposes the audit into **3 waves**:
### Wave 0 (DONE) — Inventory + Dependency Map
- Output: `inventory.md` + this `graph.md`
- Cost: ~minimal (code exploration, no heavy AI)
### Wave 1 — Per-Feature Audits (16 nodes)
Each node audits ONE feature domain against the **6-lens set** (Correctness, Data-Integrity, Cross-Feature Coupling, Security, Performance, Platform-Parity). Nodes are **independent** (can fan out in parallel) — no feature audit reads another feature's output.
### Wave 2 — Substrate Synergetic Failure Hunt (8 nodes)
After Wave 1 completes, these nodes hunt failures that SPAN features through shared substrate: store-coupling, offline/storage, sync+push, platform-parity, auth/session/entitlement, doc cross-check, cross-feature synthesis, completeness critic.
---
## Wave 1 Node Assignments (16 nodes, fully parallelizable)
| ID | Feature | Source Roots | Key Files | Estimated Complexity |
|----|---------|-------------|-----------|---------------------|
| N01 | Mail/Email | `app/(main)/[locale]/page.tsx`, `components/email/`, `stores/email-store.ts` | email-viewer (5102L), email-composer (3432L), email-list, rich-text-editor, JMAP client (7446L) | **VERY HIGH** |
| N02 | Calendar | `app/(main)/[locale]/calendar/`, `components/calendar/`, `stores/calendar-store.ts` | event-modal (1375L), month/week/day views, recurrence-expansion | **HIGH** |
| N03 | Contacts | `app/(main)/[locale]/contacts/`, `components/contacts/`, `stores/contact-store.ts` | contact-list (615L), contact-detail (625L), vCard import/export | **MEDIUM** |
| N04 | Files | `app/(main)/[locale]/files/`, `components/files/`, `stores/file-store.ts` | file-browser (2022L), WebDAV client, dual storage | **MEDIUM** |
| N05 | Tasks | `stores/task-store.ts`, `components/calendar/task-*.tsx` | Small feature (95L store) | **LOW** |
| N06 | Settings | `app/(main)/[locale]/settings/`, `components/settings/`, `stores/settings-store.ts` | 33 settings components, settings-sync | **HIGH** |
| N07 | Filters/Sieve | `components/filters/`, `stores/filter-store.ts`, `lib/sieve/` | filter-rule-modal (534L), parser (866L), generator | **MEDIUM** |
| N08 | Templates | `components/templates/`, `stores/template-store.ts` | Small feature (153L store) | **LOW** |
| N09 | Identity/Aliases | `components/identity/`, `stores/identity-store.ts` | identity-manager-modal (407L) | **LOW** |
| N10 | AI Assistant | `components/ai/`, `lib/ai/`, `app/api/ai/` | local-client (422L), opencode (164L), retrieval/fusion, local-discovery | **MEDIUM** |
| N11 | Admin | `app/(main)/admin/*`, `lib/admin/`, `stores/admin-tab-store.ts` | 18 lib files, 14 pages, config-manager, audit, plugin-registry | **HIGH** |
| N12 | Plugin System | `components/plugins/`, `stores/plugin-store.ts`, `lib/plugin-sandbox/` | 13 sandbox files, host-api (933L), runtime (636L), types (1092L) | **VERY HIGH** |
| N13 | Pro Shell | `app/(main)/[locale]/pro/`, `components/pro/`, `stores/pro-tab-store.ts` | tab-bar, email/compose tab bodies | **LOW** |
| N14 | Search | `components/search/`, `lib/mail-index/`, `stores/email-store.ts` (search state) | FTS5 index (Electron-only), search-chips, advanced-search-panel | **MEDIUM** |
| N15 | Authentication | `lib/auth/`, `lib/oauth/`, `stores/auth-store.ts` | auth-store (2033L), crypto, OIDC, pairing, impersonation | **HIGH** |
| N16 | Setup Wizard | `app/(main)/setup/*`, `lib/setup/` | session, state, token | **LOW** |
---
## Wave 2 Node Assignments (8 nodes, partially parallelizable)
| ID | Substrate Concern | Scope | Depends On |
|----|-------------------|-------|-----------|
| N20 | Store-Coupling & Cycles | Cross-store dependency analysis: auth-store hub, email↔tabs cycle, client-registry anti-cycle | N01N16 |
| N21 | Offline / Storage | `lib/offline-replica/` + `lib/mail-index/` + RxDB evaluation (§D) | N01, N04, N10, N14 |
| N22 | Sync + Push + Background | JMAP push, web push (PWA), Electron notifications, offline queue replay, connectivity | N01, N02, N06 |
| N23 | Platform Parity | Electron vs PWA vs Native gaps: `isElectronShell()` gate spread, local index, offline replica, notifications | N01N16 |
| N24 | Auth / Session / Entitlement | Token lifecycle, multi-account isolation, feature-gate enforcement, sharing/delegation auth | N15, N11 |
| N25 | Doc Cross-Check | FEATURES.md vs actual code vs policy feature gates | N01N16 |
| N26 | Cross-Feature Synthesis | Combines Wave 1 coupling points + Wave 2 substrate findings → cross-feature failures | N01N25 |
| N27 | Completeness Critic | "What feature, modality, or shared path was NOT covered?" + Fresh-context verify of all CRITICAL/HIGH | N01N26 |
---
## Calibration Plan
Before fanning out all 16 Wave 1 nodes, calibrate on **2 representative nodes**:
1. **N05 (Tasks)** — smallest feature (95 lines), low complexity → calibrate per-node cost lower bound
2. **N15 (Authentication)** — high complexity (2033 lines), the central hub importing 7 stores → calibrate per-node cost upper bound
After calibration, price the full Wave 1 fan-out and report against the AI-cost standing rule.
---
## The 6-Lens Set (applied to every Wave 1 node)
1. **Correctness / Functioning** — trace real code paths for core verbs; do they work as written? error/offline handling?
2. **Data-Integrity** — optimistic UI vs persistence; store↔DB consistency; retention/GC; crash-recovery
3. **Cross-Feature Coupling** — enumerate shared store slices + services touched; each is a candidate synergetic-failure surface
4. **Security** — XSS (email-body → DOM → Electron RCE path), entitlement/permission guards, token/credential logging, sharing authorization
5. **Performance** — virtual scrolling, zoneless CD (Next.js/React: missing memo/useCallback on large lists), worker RPC cost
6. **Platform-Parity**`isElectronShell()` branches, Web Push vs Electron Notification, local index availability, offline replica availability
## Finding Template (all nodes)
```
### [CRITICAL|HIGH|MEDIUM|LOW] Short title
- Category: <feature / substrate / type>
- Location: path/file:line
- Evidence: short quoted snippet or precise description
- Impact: concrete failure scenario (inputs → wrong behavior); cross-feature if applicable
- Recommendation: correct approach
- Confidence: measured | reasoned-not-measured
```
## Key Codebase Anchors
- **No secrets in reports** (§B.0.2) — redact all keys, tokens, secrets
- **`rg` landmine** — use `grep -REn` / Read, never `rg` (mangles `electron``n`)
- **Cursor provenance** (`lib/offline-replica/states.ts`) — branded `ChangesState` / `SnapshotState`
- **Deploy policy** — dev first, never prod-direct
- **AI-cost rule** — ≥30% above baseline → report immediately
- **Security anchors** — DOMPurify sanitization, CSP with per-request nonce, SSRF guard, contextIsolation/sandbox for Electron, plugin bundle integrity + signing
@@ -0,0 +1,290 @@
# Wave 0 — Feature Inventory
**Run:** 2026-08-07-v1.7.8-baseline
**Skill:** VNCmailgraph (adapted for Next.js/Zustand)
**Commit:** d8bebb531f86cab3507aed2113e8d0e6a03c1aa8
**Version:** vnc-v0.3.0-94-gd8bebb53 (VERSION=1.7.8)
**Codebase:** ~188K LOC across 745 TS/TSX files (excluding node_modules, .git)
**Framework:** Next.js 16 (App Router) + React 19 + Zustand 5
**Backend:** Stalwart Mail Server (JMAP protocol)
**Targets:** Web (PWA), Electron Desktop, Native (planned via Capacitor/RN)
---
## Method Adaptation
The original VNCmailgraph methodology assumes Angular + NgRx + `isCordova`/`isElectron` gates. This codebase uses:
- **Next.js 16 App Router** (not Angular) — routes defined via file-system routing
- **Zustand** (not NgRx) — stores in `stores/*.ts`, accessed via hooks
- **`isElectronShell()`** (not `isCordova`/`isElectron`) — platform detection via `window.vnc`
- **No `zimbra-features.ts`** — feature flags are in `lib/admin/types.ts` (`FeatureGates`, `SettingsPolicy`)
- **JMAP protocol** (not Zimbra SOAP) — `lib/jmap/client.ts` (7446 lines)
The audit method translates cleanly: feature domains = page routes + component trees; shared substrate = stores + lib services; coupling = cross-store imports.
---
## Feature Domains (derived from code)
### F1 — Mail / Email (CORE)
- **Routes:** `/app/(main)/[locale]/page.tsx` (3758 lines — main mail client)
- **Components:** `components/email/` (24 files): email-list, email-viewer (5102 lines), email-composer (3432 lines), thread-conversation-view, rich-text-editor (TipTap), tag-picker, email-hover-actions, recipient-popover, calendar-invitation-banner, unsubscribe-banner, read-receipt-banner, message-list-tabs
- **Store:** `stores/email-store.ts` (4087 lines) — largest store, manages emails, mailboxes, threads, search, tags, push connection, cross-account views
- **Protocol:** `lib/jmap/client.ts` (7446 lines) — the JMAP client powering all mail operations
- **Sub-features:** Compose/drafts, threading, unified mailbox, cross-account views, search, tags/keywords, attachments, scheduled send, read receipts (MDN), archive modes, virtual scrolling, TNEF extraction, .eml import, print
### F2 — Calendar
- **Routes:** `/app/(main)/[locale]/calendar/page.tsx`
- **Components:** `components/calendar/` (22 files): month/week/day/agenda views, event-modal (1375 lines), mini-calendar, task-modal, recurrence-editor, ical-import/subscription modals, participant-input
- **Store:** `stores/calendar-store.ts` (1289 lines) — calendars, events, multi-account aggregation, recurrence expansion, iMIP, CalDAV
- **Sub-features:** Month/week/day/agenda views, drag-to-reschedule, recurring events, iMIP invitations, .ics import, birthday calendar, virtual locations, tasks, CalDAV shared calendars
### F3 — Contacts
- **Routes:** `/app/(main)/[locale]/contacts/page.tsx`
- **Components:** `components/contacts/` (17 files): contact-list, contact-detail, contact-form, import-dialog, groups
- **Store:** `stores/contact-store.ts` (1146 lines) — contact cards, address books, multi-account, vCard import/export
- **Sub-features:** JMAP sync (RFC 9553/9610), address books, groups, vCard import/export, trusted senders, autocomplete
### F4 — Files / Briefcase
- **Routes:** `/app/(main)/[locale]/files/page.tsx`
- **Components:** `components/files/` (10 files): file-browser (2022 lines), folder-tree, upload-area, preview-modal
- **Stores:** `stores/file-store.ts` (1050 lines) + `stores/webdav-store.ts` (480 lines) — dual storage backends
- **Sub-features:** JMAP FileNode browsing, WebDAV upload with progress, grid/list views, preview, cut/copy/paste, favorites, sharing (RFC 9670)
### F5 — Tasks
- **Store:** `stores/task-store.ts` (95 lines) — VTODO CRUD, filter by pending/completed/overdue
- **Components:** `components/calendar/task-modal.tsx`, `task-list-view.tsx`, `task-toolbar.tsx`
- **Feature gate:** `calendarTasksEnabled` in admin policy
### F6 — Settings / Preferences
- **Routes:** `/app/(main)/[locale]/settings/page.tsx`
- **Components:** `components/settings/` (33 files) — account, appearance, themes, layout, reading, composing, notification, folder, filter, vacation, identity, template, keyword, calendar, contacts, files, downloads, sidebar-apps, plugins, language, protocol-handler, AI, debug, about
- **Store:** `stores/settings-store.ts` (1221 lines) — all user settings with cross-device encrypted sync
- **Sub-features:** Theme/density/font, layout preferences, reply/signature behavior, notification preferences, mailto:/webcal: protocol handling, settings sync (AES-256-GCM)
### F7 — Filters / Sieve
- **Components:** `components/filters/` (2 files): filter-rule-modal (534 lines), sieve-editor-modal
- **Store:** `stores/filter-store.ts` (254 lines) — Sieve script management
- **Lib:** `lib/sieve/` (parser + generator) — round-trip for external scripts
- **Sub-features:** Visual rule builder, raw Sieve editor, vacation responder, opaque script preservation
### F8 — Templates
- **Components:** `components/templates/` (4 files): template-picker, template-form, manager-modal, placeholder-fill
- **Store:** `stores/template-store.ts` (153 lines) — CRUD, favorites, import/export
- **Feature gate:** `templatesEnabled`
### F9 — Identity / Aliases
- **Components:** `components/identity/` (3 files): identity-manager-modal (407 lines), identity-form, sub-address-helper
- **Store:** `stores/identity-store.ts` (140 lines) — sender identities, sub-addressing
- **Sub-features:** Multiple sender identities per account, sub-addressing (`user+tag@domain`)
### F10 — AI Assistant
- **Components:** `components/ai/ai-ask-button.tsx` (264 lines)
- **Lib:** `lib/ai/` (9 files + retrieval/): local-client, local-discovery, opencode, entitlement, key-store, retrieval/fusion
- **API:** `app/api/ai/` (server/chat, server/models, retrieve, opencode/chat, opencode/models, policy)
- **Sub-features:** Local LLM (Ollama), server-hosted AI, BYOK, OpenCode runtime, mail retrieval with RRF fusion, FTS5 search index
- **Feature gate:** `aiAssistantEnabled` (default=true)
### F11 — Admin / Management
- **Routes:** 14 admin pages (dashboard, settings, branding, auth, password, policy, plugins, themes, marketplace, version, telemetry, logs)
- **Lib:** `lib/admin/` (18 files): config-manager, session, password, audit, plugin-registry, plugin-approvals, plugin-signing, bundled-plugins, domain-branding, CSP
- **Store:** `stores/admin-tab-store.ts` (42 lines)
### F12 — Plugin System
- **Components:** `components/plugins/` (7 files): plugin-slot, iframe-slot, dialog-host, consent-dialog, error-boundary
- **Store:** `stores/plugin-store.ts` (596 lines) — lifecycle management
- **Lib:** `lib/plugin-sandbox/` (13 files): runtime, host-bridge, host-api, loader, tier, protocol, registry, bundle-integrity, bundle-signing, consent, shortcuts
- **Sub-features:** Sandboxed iframe execution, postMessage RPC, tiered permissions (untrusted/privileged), admin approval gates, marketplace, dev-mode loading
### F13 — Pro Multi-Tab Shell
- **Routes:** `/app/(main)/[locale]/pro/page.tsx`
- **Components:** `components/pro/` (4 files): tab-bar, email-tab-body, compose-tab-body, interface-redirect
- **Store:** `stores/pro-tab-store.ts` (576 lines) — multi-tab management, split pane layout
### F14 — Search
- **Components:** `components/search/` (2 files): search-chips, advanced-search-panel
- **Backend:** `lib/mail-index/` (8 files) — encrypted SQLite/FTS5 index (Electron-only)
- **API:** `app/api/offline/search`, `app/api/ai/retrieve`
- **Coupling:** Heavily coupled with email-store (search state lives there)
### F15 — Authentication
- **Lib:** `lib/auth/` (7 files): crypto, session-cookie, session-secret, verify-jmap-auth, pair-reauth, pairing-store, active-account-slot
- **Store:** `stores/auth-store.ts` (2033 lines) — THE hub; manages login, logout, token refresh, multi-account sessions
- **Sub-features:** Basic auth, OAuth/OIDC with PKCE, demo mode, device pairing, TOTP 2FA, impersonation (JWT)
- **API:** `app/api/auth/` (10 routes)
### F16 — Setup Wizard
- **Routes:** `/app/(main)/setup/*`
- **Lib:** `lib/setup/` (3 files): session, state, token
- **API:** `app/api/setup/` (6 routes)
---
## Shared Substrate
### S1 — State Management (Zustand stores)
- 28 store files in `stores/` — Zustand with `persist` middleware
- **Central hub:** `auth-store.ts` imports 7 other stores; bootstraps all feature stores after login
- **Bidirectional cycle:** `email-store``message-list-tabs-store`
- **Anti-cycle pattern:** `client-registry.ts` — utility indirection to avoid auth-store cycles
- **Clean leaves:** `locale-store`, `policy-store`, `toast-store`, `ui-store` (0 dependencies)
### S2 — JMAP Protocol Layer
- `lib/jmap/client.ts` (7446 lines) — the communication backbone for ALL features
- `lib/jmap/types.ts` (938 lines) — shared type definitions
- `lib/jmap/client-interface.ts` (358 lines) — `IJMAPClient` interface (real + demo implementations)
- `lib/jmap/transport-health.ts` — failure counter for offline fallback gating
- `lib/jmap/request-limits.ts` — request batching utilities
- `lib/jmap/search-utils.ts` — search query building
### S3 — Auth / Session / Multi-Account
- `lib/auth/*` — AES-256-GCM cookie encryption, session lifecycle, device pairing
- `lib/oauth/*` — OAuth2/OIDC discovery, PKCE, token exchange
- `lib/impersonation/*` — JWT platform auth
- `lib/stalwart/*` — server-side Stalwart auth context, JMAP passthrough
- `stores/account-store.ts` — multi-account registry
- `stores/account-security-store.ts` — TOTP 2FA, app passwords, API keys
### S4 — Offline Replica (encrypted delta-sync mail store)
- `lib/offline-replica/` (13 files): engine, sync (1122 lines), store (997 lines), apply, jmap, states, read, retention, errors, schema, types
- SQLCipher-encrypted SQLite; delta sync via JMAP `/changes`
- Two-tier storage (envelope + body); cursor provenance with branded types
- **Fallback only** — consulted after live JMAP read fails; online session never sees replica data
### S5 — Local Search Index (encrypted FTS5)
- `lib/mail-index/` (8 files): store (534 lines), jmap (388 lines), extract (311 lines), reindex (385 lines), key (203 lines), paths, binding
- SQLCipher-encrypted SQLite with FTS5, **Electron/desktop only**
- Event-driven (triggered by JMAP push changes via API routes)
- Key transport uses inherited fd (pipe), not env var
### S6 — Platform Bridge
- `lib/electron-bridge.ts` (55 lines) — `isElectronShell()`, `showElectronNotification()`
- `lib/platform-capabilities.ts` (29 lines) — single source of truth for platform feature availability
- `lib/web-push.ts` — web push for PWA
- `electron/main.ts` (424 lines) — Electron main process (spawns Next.js server)
- `electron/preload.ts` (27 lines) — contextBridge, nodeIntegration=false, sandbox=true
### S7 — Plugin Sandbox
- `lib/plugin-sandbox/` (13 files) — iframe isolation, postMessage RPC, tier gating
- `lib/plugin-types.ts` (1092 lines) — massive type definitions
- `lib/plugin-hooks.ts` — hook registration for email/calendar/composer/sidebar slots
- `lib/plugin-loader.ts`, `lib/plugin-validator.ts`, `lib/plugin-storage.ts`
### S8 — HTTP Proxy / API Gateway
- `proxy.ts` (211 lines) — Next.js middleware: CSP, nonce, setup-state gating, intl routing
- `app/api/*` — 24 API endpoint groups (auth, admin, AI, offline, setup, etc.)
### S9 — Security / Sanitization
- `lib/security/url-guard.ts` (67 lines) — SSRF prevention, loopback/private-IP blocking
- `lib/email-sanitization.ts` — DOMPurify HTML sanitization
- `lib/smime-ca/` (5 files) — S/MIME certificate authority (EJBCA + local dev CA)
### S10 — Theme System
- `stores/theme-store.ts` (593 lines) — light/dark/system, custom theme installation
- `lib/theme-compiler.ts`, `lib/theme-loader.ts`, `lib/theme-logo.ts`
- `lib/builtin-themes.ts` — 8 built-in themes (2 shipping, 6 hidden)
- `lib/color-transform.ts` — luminance-based color remapping for dark mode
### S11 — Internationalization
- `stores/locale-store.ts` (19 lines)
- `i18n/routing.ts` — next-intl configuration
- `locales/` — 24 language translations
- `lib/jalali-utils.ts` — Persian calendar support
### S12 — Cross-Device Settings Sync
- `lib/settings-sync.ts` — encrypted settings sync (AES-256-GCM)
### S13 — Telemetry
- `lib/telemetry/` (7 files) — anonymous heartbeat, opt-in, HMAC-hashed logins
### S14 — Version Check
- `lib/version-check/` (5 files) — polls version server, daily jittered schedule
- `stores/update-store.ts` (118 lines)
---
## Feature Flag Registry (from lib/admin/types.ts)
| Flag | Default | Description |
|------|---------|-------------|
| `pluginsEnabled` | false | Plugin system on/off |
| `pluginsUploadEnabled` | true | Allow manual plugin upload |
| `requirePluginApproval` | true | Admin must approve plugins |
| `themesEnabled` | true | Theme system on/off |
| `sidebarAppsEnabled` | true | Sidebar app visibility/order |
| `userThemesEnabled` | true | Users can install custom themes |
| `settingsExportEnabled` | true | Settings export |
| `customKeywordsEnabled` | true | Custom keyword/tag creation |
| `templatesEnabled` | true | Email templates |
| `calendarEnabled` | true | Calendar feature |
| `calendarTasksEnabled` | true | Calendar tasks |
| `smimeEnabled` | true | S/MIME |
| `externalContentEnabled` | true | External content in emails |
| `debugModeEnabled` | true | Debug tools |
| `folderIconsEnabled` | true | Custom folder icons |
| `hoverActionsConfigEnabled` | true | Hover action configuration |
| `filesEnabled` | true | File browser |
| `contactsEnabled` | true | Contacts |
| `crossAllViewEnabled` | false | Cross-account All Mail |
| `crossUnreadViewEnabled` | false | Cross-account Unread |
| `crossStarredViewEnabled` | false | Cross-account Starred |
| `unifiedCrossAccountEnabled` | false | Cross-account unified inbox |
| `aiAssistantEnabled` | true | AI assistant (visible by default — local ships free) |
---
## Dependency / Coupling DAG (Store Layer)
```
auth-store (HUB) ──→ identity-store
├──→ account-store
├──→ calendar-store
├──→ contact-store
├──→ filter-store
├──→ settings-store
└──→ vacation-store
email-store ──→ calendar-store
├──→ auth-store
├──→ account-store
├──→ settings-store
└──→ message-list-tabs-store ←── (BIDIRECTIONAL CYCLE)
settings-store ──→ theme-store
└──→ locale-store
plugin-store ──→ locale-store
└──→ policy-store
theme-store ──→ policy-store
account-security-store ──→ auth-store
Standalone (14): file-store, task-store, filter-store, identity-store,
account-store, pro-tab-store, ui-store, vacation-store, totp-reauth-store,
calendar-notification-store, managed-account-store, update-store,
toast-store, admin-tab-store, template-store, webdav-store
```
**Critical coupling paths to audit in Wave 2:**
1. `auth-store` → bootstraps 7 feature stores on login → any bootstrap failure cascades
2. `email-store``message-list-tabs-store` — bidirectional cycle
3. `email-store``calendar-store` — cross-feature coupling (invitation banners, birthday calendar)
4. `settings-store``theme-store` → theme injection affects ALL components
5. JMAP client (`lib/jmap/client.ts`) → used by ALL feature stores via `client-registry`
---
## Platform Parity Surface
| Feature | Web (PWA) | Electron Desktop | Native (planned) |
|---------|-----------|------------------|------------------|
| Mail | Full | Full | Full (planned) |
| Calendar | Full | Full | Full (planned) |
| Contacts | Full | Full | Full (planned) |
| Files | Full | Full | Full (planned) |
| Local Search Index | ❌ | SQLCipher FTS5 | ❌ (planned) |
| Offline Replica | ❌ | SQLCipher SQLite | ❌ (planned) |
| Native Notifications | Web Push | Electron Notification | Native (planned) |
| AI Local LLM | Browser CORS | No CORS (loopback) | Native (planned) |
| S/MIME | Full | Full | Full (planned) |
+10 -1
View File
@@ -387,7 +387,16 @@ export const useCalendarStore = create<CalendarStore>()(
} }
}), }),
); );
set({ events: results.flat(), isLoadingEvents: false, dateRange: { start, end } }); const allEvents = results.flat();
// Deduplicate cross-account events by uid + recurrenceId
const seen = new Set<string>();
const deduped = allEvents.filter(e => {
const key = `${e.uid || e.id}::${e.recurrenceId || ''}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
set({ events: deduped, isLoadingEvents: false, dateRange: { start, end } });
} catch (error) { } catch (error) {
debug.error('Failed to fetch all-account events:', error); debug.error('Failed to fetch all-account events:', error);
set({ error: 'Failed to load events', isLoadingEvents: false }); set({ error: 'Failed to load events', isLoadingEvents: false });
+19
View File
@@ -2940,6 +2940,25 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
} }
} }
// Handle ContactCard state changes - refresh contacts
if (accountChanges?.ContactCard) {
const { useContactStore } = await import('./contact-store');
const contactStore = useContactStore.getState();
contactStore.fetchContacts(client).catch((err) => {
console.error('Failed to refresh contacts on push:', err);
});
}
// Handle FileNode state changes - refresh current directory
if (accountChanges?.FileNode) {
const { useFileStore } = await import('./file-store');
const fileStore = useFileStore.getState();
const currentParentId = fileStore.currentParentId;
fileStore.navigate(currentParentId).catch((err) => {
console.error('Failed to refresh files on push:', err);
});
}
// Local search index last, with the refreshed ids (see above). // Local search index last, with the refreshed ids (see above).
scheduleIndexUpdate(); scheduleIndexUpdate();
} catch (error) { } catch (error) {
+9 -2
View File
@@ -484,7 +484,7 @@ const DEFAULT_SETTINGS = {
autoSaveDraftInterval: 60000, // 1 minute autoSaveDraftInterval: 60000, // 1 minute
sendConfirmation: false, sendConfirmation: false,
defaultReplyMode: 'reply' as ReplyMode, defaultReplyMode: 'reply' as ReplyMode,
autoSelectReplyIdentity: false, autoSelectReplyIdentity: true,
plainTextMode: false, plainTextMode: false,
rtlEditingSupport: false, rtlEditingSupport: false,
subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER, subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER,
@@ -643,7 +643,14 @@ export const useSettingsStore = create<SettingsState>()(
(set, get) => ({ (set, get) => ({
...DEFAULT_SETTINGS, ...DEFAULT_SETTINGS,
updateSetting: (key, value) => { updateSetting: (key, value, opts?: { force?: boolean }) => {
if (!opts?.force) {
try {
const { usePolicyStore } = require('./policy-store');
const locked = usePolicyStore.getState().isSettingLocked(key);
if (locked) return;
} catch { /* policy store may not be loaded yet */ }
}
set({ [key]: value }); set({ [key]: value });
// Apply font size to document root // Apply font size to document root
+33 -18
View File
@@ -65,30 +65,45 @@ export const useTaskStore = create<TaskStore>((set, get) => ({
}, },
updateTask: async (client, id, updates) => { updateTask: async (client, id, updates) => {
await client.updateCalendarTask(id, updates); try {
set({ await client.updateCalendarTask(id, updates);
tasks: get().tasks.map(t => t.id === id ? { ...t, ...updates, updated: new Date().toISOString() } : t), set({
}); tasks: get().tasks.map(t => t.id === id ? { ...t, ...updates, updated: new Date().toISOString() } : t),
});
} catch (error) {
debug.error('TaskStore/updateTask failed', error);
set({ error: 'Failed to update task' });
}
}, },
deleteTask: async (client, id) => { deleteTask: async (client, id) => {
await client.deleteCalendarTask(id); try {
set({ await client.deleteCalendarTask(id);
tasks: get().tasks.filter(t => t.id !== id), set({
selectedTaskId: get().selectedTaskId === id ? null : get().selectedTaskId, tasks: get().tasks.filter(t => t.id !== id),
}); selectedTaskId: get().selectedTaskId === id ? null : get().selectedTaskId,
});
} catch (error) {
debug.error('TaskStore/deleteTask failed', error);
set({ error: 'Failed to delete task' });
}
}, },
toggleTaskComplete: async (client, task) => { toggleTaskComplete: async (client, task) => {
const newProgress = task.progress === 'completed' ? 'needs-action' : 'completed'; try {
const updates: Partial<CalendarTask> = { const newProgress = task.progress === 'completed' ? 'needs-action' : 'completed';
progress: newProgress, const updates: Partial<CalendarTask> = {
progressUpdated: new Date().toISOString(), progress: newProgress,
}; progressUpdated: new Date().toISOString(),
await client.updateCalendarTask(task.id, updates); };
set({ await client.updateCalendarTask(task.id, updates);
tasks: get().tasks.map(t => t.id === task.id ? { ...t, ...updates, updated: new Date().toISOString() } : t), set({
}); tasks: get().tasks.map(t => t.id === task.id ? { ...t, ...updates, updated: new Date().toISOString() } : t),
});
} catch (error) {
debug.error('TaskStore/toggleTaskComplete failed', error);
set({ error: 'Failed to update task' });
}
}, },
clearTasks: () => set({ tasks: [], selectedTaskId: null, error: null }), clearTasks: () => set({ tasks: [], selectedTaskId: null, error: null }),