From a622e3755bba38a4e7bd933c16bc80fe1e2e4f36 Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Fri, 7 Aug 2026 12:15:44 +0200 Subject: [PATCH 1/2] 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. --- app/(main)/[locale]/calendar/page.tsx | 4 +- app/api/admin/auth/route.ts | Bin 10043 -> 10169 bytes app/api/auth/session/route.ts | 10 + app/api/auth/token/route.ts | 10 + lib/admin/password.ts | 2 +- lib/admin/rate-limit.ts | 40 +- lib/admin/session.ts | 32 +- lib/admin/types.ts | 1 + lib/jmap/client.ts | 32 +- lib/mail-index/store.ts | 5 +- lib/oauth/token-exchange.ts | 3 +- lib/recurrence-expansion.ts | 2 +- lib/template-utils.ts | 2 +- .../DEVELOPMENT-PLAN.md | 514 ++++++++++++++++++ runs/2026-08-07-v1.7.8-baseline/REPORT.md | 349 ++++++++++++ runs/2026-08-07-v1.7.8-baseline/graph.md | 102 ++++ runs/2026-08-07-v1.7.8-baseline/inventory.md | 290 ++++++++++ stores/calendar-store.ts | 11 +- stores/email-store.ts | 19 + stores/settings-store.ts | 11 +- stores/task-store.ts | 51 +- 21 files changed, 1450 insertions(+), 40 deletions(-) create mode 100644 runs/2026-08-07-v1.7.8-baseline/DEVELOPMENT-PLAN.md create mode 100644 runs/2026-08-07-v1.7.8-baseline/REPORT.md create mode 100644 runs/2026-08-07-v1.7.8-baseline/graph.md create mode 100644 runs/2026-08-07-v1.7.8-baseline/inventory.md diff --git a/app/(main)/[locale]/calendar/page.tsx b/app/(main)/[locale]/calendar/page.tsx index 0c9ffefd..e9c70d31 100644 --- a/app/(main)/[locale]/calendar/page.tsx +++ b/app/(main)/[locale]/calendar/page.tsx @@ -99,7 +99,9 @@ export default function CalendarPage() { refreshAllSubscriptions, icalSubscriptions, } = useCalendarStore(); 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 setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor); const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor); diff --git a/app/api/admin/auth/route.ts b/app/api/admin/auth/route.ts index 949934dd1d1225aeaf0836ce798853a25ae30bc1..7d279a74180dfc515e1905165fc43cc12a841991 100644 GIT binary patch delta 1852 zcmaJ>T})eL7*3%;ds?8hh0_18E$!(krDbjdp{$GkMwy`^4S{4_Dp}9h?I4zXRIkAW~%@lH)K76c+g_&I8hFFrw z3dxG%VooTNRWe@^3xq8SuSxL*fu9pfWj28yH7x$p4C8#wKpn%xBN4n&a{=GgOu$l` z!fb7-mLU;*M!%objv=WX!&_RP68l^`iYM9;4AiBxG$)QP>MXclXQpo!Ki6e645IY{ z?$t-}O?_I&5F(Xskrmh)hMVXQttVIH^LaUxZ?HGWA!e1q-G+- zjQS8x>!&E<+xlKS(7Wi@Z~8Q1%p_iCE@GP*ht<%73BzT4WH^h5hHmJM7id=6*n>BX zd@DsS3DQ!jNOEPRX$kqPe~cl78b>?~*H1FEJGrtj@QNtr1-_UhE7gkE8?VR!@>39` z1#zyQu%p8>Y!pu$dt_p?bS{BJlY=&fVAmSNTGM4bFj-|*GB5nubPB=dZuLKbmz#$f z6>@Np;Bm7J?=7CuPWB$YQHf zykp56sp7Gv6E5p`m7-|v!lrc)JJw8C!FqN8vIerelwBZUp;Qt}lp(IRitfY7^L)N6 zBxpp|h9b}3TS-N3rnb`uqMB^ukO|J#D|=V*Yg_QP@|hKjWl8nGv?P`Ua*psf`J6;5 z1_>Ya0~eDPgkrcr&Lv49rdlV=QR?y>D_|1!U6I0sD>#)T1)Gb#DoA0LmrZ#+QLfmM z*hB6V5S{l1_-)Dk}L;oLW zlc7c{h5E0MZiHOu^~jfyoYw6&K*w?hIdxx1e|TbWc(3h?$p(0x2ZEA2_R!l2Eg7U3 zBV-cqk}g_S0FOvgnZgHwc#BF}_J(ToMc)~G=#x9`!=BH3&+mcPAC@mXB_UdI@$q}# z^ZV3#cl>TV^*dn;gpdsQ!3UnJOna3A;C`UnRXI;N_c-!))OXCBK>q8Fzya~UJ3I%( wPj-?j>Y3v_P^LyX?129_z+seQeQ+c}lfW;*Af0?vC3M2j3r9K2l9LDiFNjkV;{X5v delta 1888 zcma)7OKcle6qTJgw#S}0fB)_0ah&mwIHaX1X((wRO`WE}YOK;lt1z`Ecp&yf<1ryc zu2^JI2?bqNb3Sbzi(5>oj{2*CnS`3WQzVZ#z6XtP2J8}5BGLtPORHYa)S-Ost_ z-aG!_iI0|FId0=`98UpUz6P*6Lvyxe8&9`v4+Fku8wYgRMZki69#FT>xOk4|3fU}A z-?z`f)t~mWfOB0%z?Zs;4xS_M_7gjk?lW*(W4P0uFyDUcUVz)M<20b;DE6R7fv4{{ zQcj+$sEwvh`<;Gz*TI3ozN6Fy8R^*`8BRMrdBC50mVpByy0%4L;OV4u{wN;XWKC&E z>+5(4M?xy=vZR%jx~#VJv<`o@%n4@5b8U?IhI0v`?KzS9vB?9X;RYEy3&+quBa-M(>Q4qXO_OrKj}AW zC8RFvHMKfT#D)2?nCIzT{uomUgn_c@cy9>8&h}-g)?1ESP9}vlX}u{kZNM6(`K9+k zupaPDxDCrTL@4@j1YY*d0)FGW6fjdIdWammJ7@EVDM8FAhY(xi#<@;H`nh!ar}B zyzwt;V9LQ5KU0@B&dbel^;wg0mR=ZKK9u$+gE`3X+u&k{E+>W_fVYOn@;>`}?SN)rKa7QYdze_u^6l`E&J~zQWq`sS zNzxZZC%rs!1hy16pv}{dQ?TB&)|s)79V4S%_-dv$5~aO2(FryY%7xOO!ih} zYS{2*jekyBSJ;?hF?mX&PH+{YIPOLOH{Fhob_RGinl$G^+LRQXv^J`eE-y(0X=t^s9K7u`yTP zkk?Cc*p5B_NtSk#;Fsugf`hn6B*0B!(ySVY+$Y-21a8}x38Oy=qwRkQ5x5)^a26gH z(!3>iJC6(f-@!f<65z2aV$1i1Xw34HT6#@x=nBlb4BsSOYss*PbV0yjyqFXKuO=tG zhB-^Z(W^qi)Y&9_pn!Fj?*Q>`k0v{1<5ICs*$&+hX$+~|iN<{JYav3}z)IP`-u{oC Ndc?9pi={M{_HU4Ez`Fnd diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 7d9dce6c..b6fd1f0b 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -19,6 +19,7 @@ import { isPublicHttpUrl } from '@/lib/security/url-guard'; import { recordLogin } from '@/lib/telemetry/login-tracker'; import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers'; import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; +import { checkUserAuthRateLimit } from '@/lib/admin/rate-limit'; function sessionCookieOptions() { return { @@ -48,6 +49,15 @@ export async function POST(request: NextRequest) { 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 // caller cannot point this route at internal hosts. We accept the global // `jmapServerUrl` and any entry from `jmapServers`. When neither matches, diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts index 16768956..4eaf6093 100644 --- a/app/api/auth/token/route.ts +++ b/app/api/auth/token/route.ts @@ -5,6 +5,7 @@ import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oaut import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange'; import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; +import { checkUserAuthRateLimit } from '@/lib/admin/rate-limit'; function getSlot(request: NextRequest): number { const raw = request.nextUrl.searchParams.get('slot'); @@ -16,6 +17,15 @@ function getSlot(request: NextRequest): number { export async function POST(request: NextRequest) { 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(); if (!code || !code_verifier || !redirect_uri) { diff --git a/lib/admin/password.ts b/lib/admin/password.ts index d9dff58a..404122cd 100644 --- a/lib/admin/password.ts +++ b/lib/admin/password.ts @@ -58,7 +58,7 @@ function verifyPassword(password: string, stored: string): Promise { } function isHashed(value: string): boolean { - return value.startsWith('$scrypt$') || value.startsWith('$2a$') || value.startsWith('$2b$'); + return value.startsWith('$scrypt$'); } // ─── Disk I/O ─────────────────────────────────────────────────────────────── diff --git a/lib/admin/rate-limit.ts b/lib/admin/rate-limit.ts index 49fea10b..0af9b3d1 100644 --- a/lib/admin/rate-limit.ts +++ b/lib/admin/rate-limit.ts @@ -1,9 +1,11 @@ /** - * In-memory rate limiter for admin login. - * Max 5 attempts per IP per 15 minutes. + * In-memory rate limiter for admin login and user authentication. + * 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 interface RateLimitEntry { @@ -28,18 +30,38 @@ setInterval(() => { */ export function checkRateLimit(ip: string): { allowed: boolean; remaining: number; retryAfterMs: number } { const now = Date.now(); - const entry = attempts.get(ip); + const entry = attempts.get(`admin:${ip}`); if (!entry || entry.resetAt <= now) { - // New window - attempts.set(ip, { count: 1, resetAt: now + WINDOW_MS }); - return { allowed: true, remaining: MAX_ATTEMPTS - 1, retryAfterMs: 0 }; + attempts.set(`admin:${ip}`, { count: 1, resetAt: now + WINDOW_MS }); + return { allowed: true, remaining: MAX_ADMIN_ATTEMPTS - 1, retryAfterMs: 0 }; } - if (entry.count >= MAX_ATTEMPTS) { + if (entry.count >= MAX_ADMIN_ATTEMPTS) { return { allowed: false, remaining: 0, retryAfterMs: entry.resetAt - now }; } 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 }; } diff --git a/lib/admin/session.ts b/lib/admin/session.ts index 4f1a73d5..b207a0e8 100644 --- a/lib/admin/session.ts +++ b/lib/admin/session.ts @@ -11,6 +11,13 @@ const TAG_LENGTH = 16; const MIN_SECRET_LENGTH = 32; +const revokedTokens = new Map(); // jti → expiry timestamp + +function isHttpsRequest(req: { headers: Headers }): boolean { + const proto = req.headers.get('x-forwarded-proto'); + return proto === 'https'; +} + function getKey(): Buffer { const secret = getSessionSecret(); if (!secret) throw new Error('SESSION_SECRET not configured'); @@ -37,10 +44,12 @@ export function createAdminSession(): string { const cipher = createCipheriv(ALGORITHM, key, iv); const now = Math.floor(Date.now() / 1000); + const exp = now + getSessionTTL(); const payload: AdminSessionPayload = { role: 'admin', iat: now, - exp: now + getSessionTTL(), + exp, + jti: randomBytes(16).toString('hex'), }; const json = JSON.stringify(payload); @@ -74,12 +83,29 @@ export function verifyAdminSession(token: string): AdminSessionPayload | null { const now = Math.floor(Date.now() / 1000); 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; } catch { 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. * @@ -146,12 +172,12 @@ export async function requireAdminAuth(request: Request): Promise<{ payload: Adm /** * Set the admin session cookie. */ -export async function setAdminSessionCookie(): Promise { +export async function setAdminSessionCookie(request?: { headers: Headers }): Promise { const token = createAdminSession(); const cookieStore = await cookies(); cookieStore.set(ADMIN_SESSION_COOKIE, token, { httpOnly: true, - secure: process.env.NODE_ENV === 'production', + secure: request ? isHttpsRequest(request) : process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: getSessionTTL(), diff --git a/lib/admin/types.ts b/lib/admin/types.ts index b4efac0d..6700df65 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -30,6 +30,7 @@ export interface AdminSessionPayload { role: 'admin'; iat: number; exp: number; + jti?: string; } export interface SettingRestriction { diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 0f06a833..f18a2323 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -3,10 +3,21 @@ import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { IJMAPClient } from "./client-interface"; import { toWildcardQuery } from "./search-utils"; 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 { 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 " or bare "email" into { name?, email }. */ function parseRecipientString(s: string): { name?: string; email: string } { 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): Promise<{ emails: Email[], hasMore: boolean, total: number }> { + const tcBefore = transportFailureCount(); try { const targetAccountId = accountId || this.accountId; const simple: { inMailbox?: string; hasKeyword?: string } = {}; @@ -1290,6 +1302,9 @@ export class JMAPClient implements IJMAPClient { return { emails: [], hasMore: false, total: 0 }; } catch (error) { + if (wasTransportFailure(tcBefore)) { + throw new TransportError('Failed to get emails: network transport failure'); + } console.error('Failed to get emails:', error); 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 }> { + const tcBefore = transportFailureCount(); try { const targetAccountId = accountId || this.accountId; @@ -2154,6 +2170,9 @@ export class JMAPClient implements IJMAPClient { return { emails, hasMore, total }; } catch (error) { + if (wasTransportFailure(tcBefore)) { + throw new TransportError('Search failed: network transport failure'); + } console.error('Search failed:', error); return { emails: [], hasMore: false, total: 0 }; } @@ -6041,6 +6060,7 @@ export class JMAPClient implements IJMAPClient { private lastSSEActivity: number = 0; private visibilityHandler: (() => 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 // 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. */ private async reconcileAfterWebSocketFallback(): Promise { + await this._stateSnapshotPromise; await this.checkForStateChanges(); const eventSourceUrl = this.getEventSourceUrl(); @@ -6781,6 +6802,11 @@ export class JMAPClient implements IJMAPClient { } if (typeof window !== 'undefined') { + this.offlineHandler = () => { + this.closePushNotifications(); + }; + window.addEventListener('offline', this.offlineHandler); + this.onlineHandler = () => { // Network reconnected - reconnect WS/SSE or force a poll. Don't // 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); this.visibilityHandler = null; } + if (this.offlineHandler && typeof window !== 'undefined') { + window.removeEventListener('offline', this.offlineHandler); + this.offlineHandler = null; + } if (this.onlineHandler && typeof window !== 'undefined') { window.removeEventListener('online', this.onlineHandler); this.onlineHandler = null; diff --git a/lib/mail-index/store.ts b/lib/mail-index/store.ts index 13735219..a4388c55 100644 --- a/lib/mail-index/store.ts +++ b/lib/mail-index/store.ts @@ -228,7 +228,10 @@ export class MailIndex { let version = opened.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;'); version = null; } diff --git a/lib/oauth/token-exchange.ts b/lib/oauth/token-exchange.ts index 51fe2d21..ebc099e6 100644 --- a/lib/oauth/token-exchange.ts +++ b/lib/oauth/token-exchange.ts @@ -121,7 +121,8 @@ export async function exchangeCodeForTokens( const tokens = await tokenResponse.json(); 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'); } diff --git a/lib/recurrence-expansion.ts b/lib/recurrence-expansion.ts index 68963a89..4be14760 100644 --- a/lib/recurrence-expansion.ts +++ b/lib/recurrence-expansion.ts @@ -239,7 +239,7 @@ function createOccurrence( return { ...master, ...(override || {}), - id: `${master.id}:${recurrenceId}`, + id: `${master.id}::occurrence::${recurrenceId}`, originalId: master.originalId || master.id, uid: master.uid, calendarIds: master.calendarIds, diff --git a/lib/template-utils.ts b/lib/template-utils.ts index c956f6a8..fd4cad2f 100644 --- a/lib/template-utils.ts +++ b/lib/template-utils.ts @@ -173,7 +173,7 @@ export function importTemplates(json: string): ImportResult { id: generateUUID(), name: sanitizeText(t.name), 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), category: sanitizeText(t.category), defaultRecipients: recipients && typeof recipients === 'object' diff --git a/runs/2026-08-07-v1.7.8-baseline/DEVELOPMENT-PLAN.md b/runs/2026-08-07-v1.7.8-baseline/DEVELOPMENT-PLAN.md new file mode 100644 index 00000000..006329ee --- /dev/null +++ b/runs/2026-08-07-v1.7.8-baseline/DEVELOPMENT-PLAN.md @@ -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` and `clear(): Partial` 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? diff --git a/runs/2026-08-07-v1.7.8-baseline/REPORT.md b/runs/2026-08-07-v1.7.8-baseline/REPORT.md new file mode 100644 index 00000000..ae62bbc1 --- /dev/null +++ b/runs/2026-08-07-v1.7.8-baseline/REPORT.md @@ -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`). diff --git a/runs/2026-08-07-v1.7.8-baseline/graph.md b/runs/2026-08-07-v1.7.8-baseline/graph.md new file mode 100644 index 00000000..cf389d4f --- /dev/null +++ b/runs/2026-08-07-v1.7.8-baseline/graph.md @@ -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 | N01–N16 | +| 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 | N01–N16 | +| 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 | N01–N16 | +| N26 | Cross-Feature Synthesis | Combines Wave 1 coupling points + Wave 2 substrate findings → cross-feature failures | N01–N25 | +| N27 | Completeness Critic | "What feature, modality, or shared path was NOT covered?" + Fresh-context verify of all CRITICAL/HIGH | N01–N26 | + +--- + +## 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: +- 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 diff --git a/runs/2026-08-07-v1.7.8-baseline/inventory.md b/runs/2026-08-07-v1.7.8-baseline/inventory.md new file mode 100644 index 00000000..7cd44554 --- /dev/null +++ b/runs/2026-08-07-v1.7.8-baseline/inventory.md @@ -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) | diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 62e8a193..a831e026 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -387,7 +387,16 @@ export const useCalendarStore = create()( } }), ); - set({ events: results.flat(), isLoadingEvents: false, dateRange: { start, end } }); + const allEvents = results.flat(); + // Deduplicate cross-account events by uid + recurrenceId + const seen = new Set(); + 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) { debug.error('Failed to fetch all-account events:', error); set({ error: 'Failed to load events', isLoadingEvents: false }); diff --git a/stores/email-store.ts b/stores/email-store.ts index 3b0359bb..d19eb877 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -2940,6 +2940,25 @@ export const useEmailStore = create((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). scheduleIndexUpdate(); } catch (error) { diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 2b628a57..2ff44c17 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -484,7 +484,7 @@ const DEFAULT_SETTINGS = { autoSaveDraftInterval: 60000, // 1 minute sendConfirmation: false, defaultReplyMode: 'reply' as ReplyMode, - autoSelectReplyIdentity: false, + autoSelectReplyIdentity: true, plainTextMode: false, rtlEditingSupport: false, subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER, @@ -643,7 +643,14 @@ export const useSettingsStore = create()( (set, get) => ({ ...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 }); // Apply font size to document root diff --git a/stores/task-store.ts b/stores/task-store.ts index a2f7d070..7a3e0cdf 100644 --- a/stores/task-store.ts +++ b/stores/task-store.ts @@ -65,30 +65,45 @@ export const useTaskStore = create((set, get) => ({ }, updateTask: async (client, id, updates) => { - await client.updateCalendarTask(id, updates); - set({ - tasks: get().tasks.map(t => t.id === id ? { ...t, ...updates, updated: new Date().toISOString() } : t), - }); + try { + await client.updateCalendarTask(id, updates); + 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) => { - await client.deleteCalendarTask(id); - set({ - tasks: get().tasks.filter(t => t.id !== id), - selectedTaskId: get().selectedTaskId === id ? null : get().selectedTaskId, - }); + try { + await client.deleteCalendarTask(id); + set({ + 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) => { - const newProgress = task.progress === 'completed' ? 'needs-action' : 'completed'; - const updates: Partial = { - progress: newProgress, - progressUpdated: new Date().toISOString(), - }; - await client.updateCalendarTask(task.id, updates); - set({ - tasks: get().tasks.map(t => t.id === task.id ? { ...t, ...updates, updated: new Date().toISOString() } : t), - }); + try { + const newProgress = task.progress === 'completed' ? 'needs-action' : 'completed'; + const updates: Partial = { + progress: newProgress, + progressUpdated: new Date().toISOString(), + }; + await client.updateCalendarTask(task.id, updates); + 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 }), From 7e3034da8d3515665750d5e18a5bfffa350e145f Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Fri, 7 Aug 2026 12:37:16 +0200 Subject: [PATCH 2/2] test: update recurrence expansion test + fix TS/Lint errors --- lib/__tests__/recurrence-expansion.test.ts | 4 ++-- lib/jmap/client.ts | 5 +++-- lib/jmap/types.ts | 1 + lib/oauth/token-exchange.ts | 2 +- package-lock.json | 4 ++-- stores/settings-store.ts | 8 +++----- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/lib/__tests__/recurrence-expansion.test.ts b/lib/__tests__/recurrence-expansion.test.ts index 71e829f8..77760f52 100644 --- a/lib/__tests__/recurrence-expansion.test.ts +++ b/lib/__tests__/recurrence-expansion.test.ts @@ -456,8 +456,8 @@ describe('expandRecurringEvents', () => { recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily' })], }); const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00'); - expect(result[0].id).toBe('evt1:2025-01-06T09:00:00'); - expect(result[1].id).toBe('evt1:2025-01-07T09:00:00'); + expect(result[0].id).toBe('evt1::occurrence::2025-01-06T09:00:00'); + expect(result[1].id).toBe('evt1::occurrence::2025-01-07T09:00:00'); }); it('preserves originalId pointing to master', () => { diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index f18a2323..afc48315 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -6096,6 +6096,7 @@ export class JMAPClient implements IJMAPClient { private wsReconnectAttempts: number = 0; private wsConsecutiveFailures: number = 0; private wsPermanentlyDisabled: boolean = false; + private _statesPromise: Promise | null = null; private wsHeartbeatTimer: NodeJS.Timeout | null = null; private lastWSActivity: number = 0; @@ -6177,7 +6178,7 @@ export class JMAPClient implements IJMAPClient { // entirely: SSE only streams changes from the moment it connects // onward (no catch-up on connect), so the one thing that CAN catch up // is a diff against a state snapshot taken before the gap started. - void this.fetchCurrentStates(); + this._statesPromise = this.fetchCurrentStates(); // Not confirmed either way whether this server's WebSocket push fans // out to shared/secondary accounts or, like Stalwart's SSE, covers the // primary account only - keep the same secondary poll running under @@ -6321,7 +6322,7 @@ export class JMAPClient implements IJMAPClient { * original 31s-worst-case ladder did. */ private async reconcileAfterWebSocketFallback(): Promise { - await this._stateSnapshotPromise; + await this._statesPromise; await this.checkForStateChanges(); const eventSourceUrl = this.getEventSourceUrl(); diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 7ebdbfff..0fbb82a6 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -800,6 +800,7 @@ export interface StateChange { Calendar?: string; CalendarEvent?: string; SieveScript?: string; + FileNode?: string; }; }; } diff --git a/lib/oauth/token-exchange.ts b/lib/oauth/token-exchange.ts index ebc099e6..c807a6f5 100644 --- a/lib/oauth/token-exchange.ts +++ b/lib/oauth/token-exchange.ts @@ -121,7 +121,7 @@ export async function exchangeCodeForTokens( const tokens = await tokenResponse.json(); if (!tokens.access_token) { - const { access_token, refresh_token, ...safeTokens } = tokens; + const { access_token: _at, refresh_token: _rt, ...safeTokens } = tokens; logger.error('Token response missing access_token', { response: JSON.stringify(safeTokens).substring(0, 500) }); throw new Error('Invalid token response'); } diff --git a/package-lock.json b/package-lock.json index d976188c..6c7789e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "bulwark-webmail", + "name": "vncmail-plus", "version": "1.7.8", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "bulwark-webmail", + "name": "vncmail-plus", "version": "1.7.8", "license": "AGPL-3.0-only", "dependencies": { diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 2ff44c17..1b6a8aa6 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { useThemeStore } from './theme-store'; import { useLocaleStore } from './locale-store'; +import { usePolicyStore } from './policy-store'; import type { NotificationSoundChoice } from '@/lib/notification-sound'; import { apiFetch } from '@/lib/browser-navigation'; import { @@ -645,11 +646,8 @@ export const useSettingsStore = create()( 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 */ } + const locked = usePolicyStore.getState().isSettingLocked(key); + if (locked) return; } set({ [key]: value });