From 47b9ab43989e6ca32cea142b561c76fc8fbfa776 Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Fri, 7 Aug 2026 12:15:44 +0200 Subject: [PATCH 1/8] 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 931d1fa06a8c267e5d49cb61a636d052fbb6ec71 Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Fri, 7 Aug 2026 12:37:16 +0200 Subject: [PATCH 2/8] 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 }); From f34537adcf1e708c4d59fe0a7e20b5ffa7f2b18b Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Fri, 7 Aug 2026 12:41:34 +0200 Subject: [PATCH 3/8] =?UTF-8?q?release:=20v1.7.9=20=E2=80=94=20Phase=201?= =?UTF-8?q?=20critical+high=20fixes=20(17/18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Housekeeping: - Bump VERSION to 1.7.9 - CHANGELOG entry for all Phase 1 fixes - Mark Phase 1 as completed in development plan --- CHANGELOG.md | 24 +++++++++++++++++++ VERSION | 2 +- .../DEVELOPMENT-PLAN.md | 4 ++-- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c3d3d4f..2ec6743d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 1.7.9 (2026-08-07) + +### Bug Fixes (Phase 1 — VNCmailgraph audit) + +- **Mail**: Network transport failures now throw `TransportError` instead of returning empty results, so offline/network-down is distinguishable from an empty folder (#C1) +- **Mail**: Push handler now refreshes contacts and files on remote state changes (#H1) +- **Calendar**: Recurrence expansion IDs use `::occurrence::` delimiter to prevent collision with shared-event prefixes (#C2) +- **Calendar**: Cross-account event aggregation now deduplicates by UID + recurrenceId, preventing phantom duplicates (#C3) +- **Calendar**: `calendarTasksEnabled` admin policy now enforced at runtime, not just in settings UI (#H13) +- **Tasks**: All task mutations (update, delete, toggle) now have error handling with store error state (#H14) +- **Settings**: `updateSetting()` now checks admin policy lock before writing; `force` opt-in for legitimate bypassers (#C7) +- **Settings**: `autoSelectReplyIdentity` now defaults to `true` — auto-identity selection on by default (#H18) +- **Templates**: HTML template bodies are now sanitized with DOMPurify on import to prevent stored XSS (#H7) +- **Auth**: User authentication endpoints now rate-limited — 10 attempts per (IP + username) per 15 minutes (#H3) +- **Auth**: Admin sessions now support token revocation via JTI blacklist on logout (#C4) +- **Auth**: Secure cookie flag now derived from `x-forwarded-proto`, not `NODE_ENV` (#H8) +- **Auth**: OAuth token exchange error logs no longer leak `access_token` (#H4) +- **Auth**: `isHashed()` no longer accepts bcrypt prefixes — scrypt-only, preventing lockout from bcrypt passwords (#H9) +- **Push**: WS→SSE fallback now awaits state snapshot before reconciliation to prevent missed deliveries (#H2) +- **Push**: Offline event handler added — push transports pause when browser goes offline, reconnect on online (#C8) +- **Index**: FTS5 schema-drop now logs a warning so operators know a rebuild is needed (#C6) + +--- + ## 1.7.8 (2026-07-22) ### Features diff --git a/VERSION b/VERSION index 84298f96..f65dc1e2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.8 +1.7.9 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 index 006329ee..1c6a9930 100644 --- a/runs/2026-08-07-v1.7.8-baseline/DEVELOPMENT-PLAN.md +++ b/runs/2026-08-07-v1.7.8-baseline/DEVELOPMENT-PLAN.md @@ -506,8 +506,8 @@ ### 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. +### Phase 1 — COMPLETED 2026-08-07 +**Shipped as v1.7.9.** 17 of 18 fixes deployed to `main`. All 2527 tests pass (161 test files). One item deferred: P1.3 (C5 auth localStorage encryption) — requires custom Zustand persist adapter, planned for Phase 3. --- From 67f61f18d0a09646181ebf9e2984653830f66e5d Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Fri, 7 Aug 2026 12:55:14 +0200 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20P2.1=20extended=20signatures=20?= =?UTF-8?q?=E2=80=94=20multiple=20per=20identity=20+=20TipTap=20editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New stores/signature-store.ts: Zustand persist with CRUD, default/reply signature IDs, per-identity signature mapping - New signature-settings.tsx: list management with add/edit/delete/duplicate - New signature-editor-modal.tsx: TipTap rich text editor for signatures - email-composer.tsx: auto-insert signature based on mode (compose/reply) + signature selector dropdown in toolbar - identity-form.tsx: per-identity default/reply signature dropdowns - settings/page.tsx: Signatures tab in Mail settings group --- app/(main)/[locale]/settings/page.tsx | 7 + components/email/email-composer.tsx | 103 ++++- components/identity/identity-form.tsx | 65 +++ .../settings/signature-editor-modal.tsx | 399 ++++++++++++++++++ components/settings/signature-settings.tsx | 273 ++++++++++++ stores/signature-store.ts | 131 ++++++ 6 files changed, 975 insertions(+), 3 deletions(-) create mode 100644 components/settings/signature-editor-modal.tsx create mode 100644 components/settings/signature-settings.tsx create mode 100644 stores/signature-store.ts diff --git a/app/(main)/[locale]/settings/page.tsx b/app/(main)/[locale]/settings/page.tsx index 3735c161..59eef68b 100644 --- a/app/(main)/[locale]/settings/page.tsx +++ b/app/(main)/[locale]/settings/page.tsx @@ -46,6 +46,7 @@ import { LayoutSettings } from '@/components/settings/layout-settings'; import { LanguageSettings } from '@/components/settings/language-settings'; import { ReadingSettings } from '@/components/settings/reading-settings'; import { ComposingSettings } from '@/components/settings/composing-settings'; +import { SignatureSettings } from '@/components/settings/signature-settings'; import { ContentSendersSettings } from '@/components/settings/content-senders-settings'; import { AccountSettings } from '@/components/settings/account-settings'; import { IdentitySettings } from '@/components/settings/identity-settings'; @@ -98,6 +99,7 @@ type Tab = | 'composing' | 'downloads' | 'identities' + | 'signatures' | 'vacation' | 'filters' | 'templates' @@ -141,6 +143,7 @@ const tabIcons: Record = { composing: PenLine, downloads: Download, identities: UserPen, + signatures: PenLine, vacation: PalmtreeIcon, filters: Filter, templates: FileText, @@ -219,6 +222,7 @@ const tabSearchPaths: Record = { ], downloads: ['settings.downloads'], identities: ['settings.identities'], + signatures: ['signatures'], vacation: ['settings.vacation'], filters: ['settings.filters'], templates: ['settings.templates'], @@ -254,6 +258,7 @@ const tabKeywords: Record = { composing: 'editor signature plain text reply forward draft compose', downloads: 'download filename template eml attachment save export', identities: 'from address signature email', + signatures: 'signature rich text html editor', vacation: 'auto reply away out of office holiday responder', filters: 'sieve rules block junk forward', templates: 'snippet quick reply', @@ -631,6 +636,7 @@ export default function SettingsPage() { { id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' }, { id: 'downloads', label: t('tabs.downloads'), icon: tabIcons.downloads, group: 'mail' }, { id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' }, + { id: 'signatures', label: t('tabs.signatures'), icon: tabIcons.signatures, group: 'mail' }, ...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []), ...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []), ...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'mail' as TabGroup }] : []), @@ -761,6 +767,7 @@ export default function SettingsPage() { {effectiveActiveTab === 'composing' && } {effectiveActiveTab === 'downloads' && } {effectiveActiveTab === 'identities' && } + {effectiveActiveTab === 'signatures' && } {effectiveActiveTab === 'vacation' && } {effectiveActiveTab === 'filters' && } {effectiveActiveTab === 'templates' && } diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 0479f183..abbbf005 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -5,7 +5,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search, Users } from "lucide-react"; +import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search, Users, PenLine } from "lucide-react"; import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils"; import { debug } from "@/lib/debug"; import { toast } from "@/stores/toast-store"; @@ -24,6 +24,7 @@ import { useIdentityStore } from "@/stores/identity-store"; import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities"; import { useAccountStore } from "@/stores/account-store"; import { useSettingsStore } from "@/stores/settings-store"; +import { useSignatureStore } from "@/stores/signature-store"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { Avatar } from "@/components/ui/avatar"; import { FilePreviewModal } from "@/components/files/file-preview-modal"; @@ -298,6 +299,34 @@ export function EmailComposer({ const { isFeatureEnabled } = usePolicyStore(); const templatesEnabled = isFeatureEnabled('templatesEnabled'); + const { + signatures, + defaultSignatureId, + replySignatureId, + getSignatureById, + getIdentityDefaultSignatureId, + getIdentityReplySignatureId, + } = useSignatureStore(); + + const resolveStoreSignatureId = (): string | null => { + const perIdentityId = selectedIdentityId || initialData?.selectedIdentityId || null; + if (mode === 'compose') { + if (perIdentityId) { + const id = getIdentityDefaultSignatureId(perIdentityId); + if (id) return id; + } + return defaultSignatureId; + } + if (perIdentityId) { + const id = getIdentityReplySignatureId(perIdentityId); + if (id) return id; + } + return replySignatureId ?? defaultSignatureId; + }; + + const [selectedSignatureId, setSelectedSignatureId] = useState(resolveStoreSignatureId); + const selectedSignature = selectedSignatureId ? getSignatureById(selectedSignatureId) ?? null : null; + // The signature identity used when embedding the signature into the initial // body for "above quote" mode. Mirrors the signatureIdentity derivation // below, but uses initialData (or primary) since selectedIdentityId state @@ -592,6 +621,7 @@ export function EmailComposer({ // when the user switches identity in "above quote" mode without rebuilding // the whole body (which would lose user edits to the surrounding draft). const editorRef = useRef(null); + const [editorReady, setEditorReady] = useState(false); const prevSignatureIdentityIdRef = useRef(signatureIdentity?.id); const prevSignatureSeparatorRef = useRef(signatureSeparatorEnabled); @@ -668,6 +698,28 @@ export function EmailComposer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]); + const sigInsertedRef = useRef(false); + useEffect(() => { + if (plainTextMode) return; + const editor = editorRef.current; + if (!editor) return; + if (!selectedSignatureId) return; + if (sigInsertedRef.current) return; + const sig = getSignatureById(selectedSignatureId); + if (!sig) return; + const currentHtml = serializeEditorContent(editor); + if (currentHtml.includes(sig.body)) { + sigInsertedRef.current = true; + return; + } + sigInsertedRef.current = true; + if (mode === 'compose') { + editor.chain().focus('end').insertContent(`

${sig.body}`).run(); + } else if ((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote') { + editor.chain().focus('start').insertContent(sig.body).run(); + } + }, [selectedSignatureId, plainTextMode, mode, signaturePosition, getSignatureById, editorReady]); + useEffect(() => { const handleClickOutsideSendMenu = (event: MouseEvent) => { if (!sendMenuRef.current?.contains(event.target as Node)) { @@ -2498,7 +2550,7 @@ export function EmailComposer({ onImageUpload={handleImageUpload} placeholder={t('body_placeholder')} hasError={validationErrors.body} - onEditorReady={(ed) => { editorRef.current = ed; }} + onEditorReady={(ed) => { editorRef.current = ed; setEditorReady(true); }} /> )} @@ -2657,8 +2709,53 @@ export function EmailComposer({ - {/* Right side - Discard + Send (desktop) */} + {/* Right side - Signature selector + Discard + Send (desktop) */}
+ {signatures.length > 0 && ( +
+ + +
+ )}
+ {/* Signature Store Mapping (per-identity) */} + {isEditing && signatures.length > 0 && ( +
+

{t('signature_store_mapping')}

+
+ + +
+
+ + +
+
+ )} + {/* Text Signature */}
+ + ); } diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index cd65425d..755aef07 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -49,6 +49,10 @@ interface EventModalProps { onPreviewChange?: (preview: PendingEventPreview | null) => void; currentUserEmails?: string[]; isMobile?: boolean; + prefillTitle?: string; + prefillDescription?: string; + prefillParticipants?: { name?: string; email: string }[]; + prefillDate?: string; } function formatDateInput(d: Date): string { @@ -178,6 +182,10 @@ export function EventModal({ onPreviewChange, currentUserEmails = [], isMobile = false, + prefillTitle, + prefillDescription, + prefillParticipants, + prefillDate, }: EventModalProps) { const t = useTranslations("calendar"); const locale = useLocale(); @@ -228,6 +236,10 @@ export function EventModal({ d.setHours(now.getHours() + 1, 0, 0, 0); return d; } + if (prefillDate) { + const d = new Date(prefillDate); + if (!isNaN(d.getTime())) return d; + } const d = new Date(); d.setHours(d.getHours() + 1, 0, 0, 0); return d; @@ -244,8 +256,8 @@ export function EventModal({ return addHours(getInitialStart(), 1); }; - const [title, setTitle] = useState(event?.title || ""); - const [description, setDescription] = useState(event?.description || ""); + const [title, setTitle] = useState(event?.title || prefillTitle || ""); + const [description, setDescription] = useState(event?.description || prefillDescription || ""); const [location, setLocation] = useState( event?.locations ? Object.values(event.locations)[0]?.name || "" : "" ); @@ -328,7 +340,12 @@ export function EventModal({ const [isSaving, setIsSaving] = useState(false); const [attendees, setAttendees] = useState<{ name: string; email: string }[]>(() => { - if (!event?.participants) return []; + if (!event?.participants) { + if (prefillParticipants && prefillParticipants.length > 0) { + return prefillParticipants.map(p => ({ name: p.name || "", email: p.email })); + } + return []; + } return existingParticipants .filter(p => !p.isOrganizer) .map(p => ({ name: p.name, email: p.email })); diff --git a/components/calendar/mini-calendar-dashlet.tsx b/components/calendar/mini-calendar-dashlet.tsx new file mode 100644 index 00000000..52e5248f --- /dev/null +++ b/components/calendar/mini-calendar-dashlet.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { useState, useMemo, useCallback, useEffect } from "react"; +import { useTranslations } from "next-intl"; +import { useRouter } from "@/i18n/navigation"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { + startOfMonth, + endOfMonth, + startOfWeek, + endOfWeek, + eachDayOfInterval, + format, + isToday, + isSameDay, + addMonths, + subMonths, + isSameMonth, +} from "date-fns"; +import { cn } from "@/lib/utils"; +import { useSettingsStore } from "@/stores/settings-store"; +import { useCalendarStore } from "@/stores/calendar-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { getEventDayBounds } from "@/lib/calendar-utils"; + +interface MiniCalendarDashletProps { + events?: { date: string; color?: string }[]; + onDayClick?: (date: Date) => void; + selectedDate?: Date; +} + +const ALL_DAY_KEYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const; + +export function MiniCalendarDashlet({ + events: propEvents, + onDayClick, + selectedDate: propSelectedDate, +}: MiniCalendarDashletProps) { + const t = useTranslations("calendar"); + const router = useRouter(); + const firstDayOfWeek = useSettingsStore((s) => s.firstDayOfWeek); + const storeSelectedDate = useCalendarStore((s) => s.selectedDate); + const storeEvents = useCalendarStore((s) => s.events); + const selectedDate = propSelectedDate ?? storeSelectedDate; + const client = useAuthStore((s) => s.client); + + const [displayMonth, setDisplayMonth] = useState(() => new Date()); + + const weekStartsOn = useMemo(() => { + if (firstDayOfWeek === 0) return 0 as const; + if (firstDayOfWeek === 6) return 6 as const; + return 1 as const; + }, [firstDayOfWeek]); + + useEffect(() => { + if (!client) return; + const start = format(startOfMonth(displayMonth), "yyyy-MM-dd'T'00:00:00"); + const end = format(endOfMonth(displayMonth), "yyyy-MM-dd'T'23:59:59"); + const { dateRange } = useCalendarStore.getState(); + if (dateRange?.start === start && dateRange?.end === end) return; + useCalendarStore.getState().fetchEvents(client, start, end); + }, [displayMonth, client]); + + const days = useMemo(() => { + const monthStart = startOfMonth(displayMonth); + const monthEnd = endOfMonth(displayMonth); + const calStart = startOfWeek(monthStart, { weekStartsOn }); + const calEnd = endOfWeek(monthEnd, { weekStartsOn }); + return eachDayOfInterval({ start: calStart, end: calEnd }); + }, [displayMonth, weekStartsOn]); + + const eventDates = useMemo(() => { + const set = new Set(); + for (const e of storeEvents) { + try { + const { startDay, endDay } = getEventDayBounds(e); + const cursor = new Date(startDay); + while (cursor <= endDay) { + set.add(format(cursor, "yyyy-MM-dd")); + cursor.setDate(cursor.getDate() + 1); + } + } catch { + /* skip */ + } + } + if (propEvents) { + for (const e of propEvents) { + set.add(e.date); + } + } + return set; + }, [storeEvents, propEvents]); + + const dayHeaders = useMemo( + () => [...ALL_DAY_KEYS.slice(weekStartsOn), ...ALL_DAY_KEYS.slice(0, weekStartsOn)], + [weekStartsOn], + ); + + const handlePrevMonth = useCallback(() => { + setDisplayMonth((prev) => subMonths(prev, 1)); + }, []); + + const handleNextMonth = useCallback(() => { + setDisplayMonth((prev) => addMonths(prev, 1)); + }, []); + + const handleGoToToday = useCallback(() => { + setDisplayMonth(new Date()); + }, []); + + const handleDayClick = useCallback( + (day: Date) => { + useCalendarStore.getState().setSelectedDate(day); + if (onDayClick) { + onDayClick(day); + } else { + router.push("/calendar"); + } + }, + [onDayClick, router], + ); + + return ( +
+
+ + + +
+ +
+ {dayHeaders.map((dh) => ( +
+ {t(`days.${dh}`)} +
+ ))} +
+ +
+ {days.map((day) => { + const inMonth = isSameMonth(day, displayMonth); + const selected = isSameDay(day, selectedDate); + const today = isToday(day); + const dateStr = format(day, "yyyy-MM-dd"); + const hasEvent = eventDates.has(dateStr); + const dotColor = + propEvents?.find((e) => e.date === dateStr && e.color)?.color ?? + undefined; + + return ( + + ); + })} +
+
+ ); +} diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx index 0d9f23c7..6dbdb6ad 100644 --- a/components/contacts/contact-list.tsx +++ b/components/contacts/contact-list.tsx @@ -1,13 +1,14 @@ "use client"; -import { useMemo, useState } from "react"; +import { useMemo, useState, useCallback } from "react"; import { useTranslations, useLocale } from "next-intl"; -import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu } from "lucide-react"; +import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu, Pencil } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { ContactListItem } from "./contact-list-item"; import { ContactContextMenu } from "./contact-context-menu"; import { useContextMenu } from "@/hooks/use-context-menu"; +import { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu"; import { cn } from "@/lib/utils"; import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types"; import { getContactDisplayName, getContactPhotoUri } from "@/stores/contact-store"; @@ -142,6 +143,63 @@ export function ContactList({ const density = useSettingsStore((state) => state.density); const groupByLetter = useSettingsStore((state) => state.groupContactsByLetter); const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu(); + + // Radial menu state + const [radialMenuOpen, setRadialMenuOpen] = useState(false); + const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 }); + const [radialMenuContact, setRadialMenuContact] = useState(null); + + const openRadialMenu = useCallback((e: React.MouseEvent, contact: ContactCard) => { + e.preventDefault(); + setRadialMenuPos({ x: e.clientX, y: e.clientY }); + setRadialMenuContact(contact); + setRadialMenuOpen(true); + }, []); + + const closeRadialMenu = useCallback(() => { + setRadialMenuOpen(false); + }, []); + + const radialMenuItems = useMemo(() => { + if (!radialMenuContact) return []; + const c = radialMenuContact; + const items: RadialMenuItem[] = []; + items.push({ + id: "edit", + icon: , + label: t("edit"), + onClick: () => { onEditContact(c.id); }, + }); + items.push({ + id: "delete", + icon: , + label: t("delete"), + onClick: () => { onDeleteContact(c); }, + destructive: true, + }); + if (c.emails && Object.keys(c.emails).length > 0) { + const contactEmails = c.emails; + items.push({ + id: "send-email", + icon: , + label: t("send_email"), + onClick: () => { + const values = Object.values(contactEmails); + if (values[0]?.address) { + window.location.href = `mailto:${values[0].address}`; + } + }, + }); + } + items.push({ + id: "export", + icon: , + label: t("export"), + onClick: () => { onBulkExport(); }, + }); + return items; + }, [radialMenuContact, t, onEditContact, onDeleteContact, onBulkExport]); + const [filtersOpen, setFiltersOpen] = useState(false); const [filters, setFilters] = useState(EMPTY_FILTERS); const activeFilters = countActiveFilters(filters); @@ -571,7 +629,7 @@ export function ContactList({ e.stopPropagation(); onToggleSelection(contact.id); }} - onContextMenu={(e, c) => openContextMenu(e, c)} + onContextMenu={(e, c) => { openContextMenu(e, c); openRadialMenu(e, c); }} /> ); return groupByLetter ? ( @@ -592,6 +650,14 @@ export function ContactList({ )} + {/* Radial Action Menu */} + + {contextMenu.data && ( | null>(null); const [attachments, setAttachments] = useState(() => { - if (mode === 'forward' && replyTo?.attachments?.length) { - return replyTo.attachments + if (replyTo?.attachments?.length) { + let atts = replyTo.attachments; + if (mode === 'forward') { // Skip inline cid-referenced images - they're embedded in the forwarded HTML body // (matches the viewer's hideInlineImageAttachments logic). - .filter(att => !(att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/'))) - .map(att => ({ - name: att.name || 'attachment', - type: att.type || 'application/octet-stream', - size: att.size, - blobId: att.blobId, - })); + atts = atts.filter(att => !(att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/'))); + } + return atts.map(att => ({ + name: att.name || 'attachment', + type: att.type || 'application/octet-stream', + size: att.size, + blobId: att.blobId, + })); } return []; }); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 9251f78c..eb3c198e 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -15,6 +15,8 @@ import { useUIStore } from "@/stores/ui-store"; import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils"; import { useContextMenu } from "@/hooks/use-context-menu"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; +import { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu"; +import { Reply, ReplyAll, Forward, Star, Archive, FolderOpen } from "lucide-react"; import { useTranslations } from "next-intl"; import { useVirtualizer } from "@tanstack/react-virtual"; import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display"; @@ -141,6 +143,101 @@ export function EmailList({ const contextMenuEmail = contextMenu.data ? emails.find((email) => email.id === contextMenu.data!.id) ?? contextMenu.data : null; + + // Radial menu state + const [radialMenuOpen, setRadialMenuOpen] = useState(false); + const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 }); + const [radialMenuEmail, setRadialMenuEmail] = useState(null); + + const openRadialMenu = useCallback((e: React.MouseEvent, email: Email) => { + e.preventDefault(); + setRadialMenuPos({ x: e.clientX, y: e.clientY }); + setRadialMenuEmail(email); + setRadialMenuOpen(true); + }, []); + + const closeRadialMenu = useCallback(() => { + setRadialMenuOpen(false); + }, []); + + const radialMenuItems = useMemo(() => { + if (!radialMenuEmail) return []; + const email = radialMenuEmail; + const isUnread = !email.keywords?.$seen; + const isStarred = email.keywords?.$flagged; + + const act = (fn?: (email: Email) => void) => fn ? () => { fn(email); } : undefined; + + const items: RadialMenuItem[] = []; + + if (onReply) { + items.push({ + id: "reply", + icon: , + label: t("../context_menu.reply"), + onClick: () => { act(onReply)!(); }, + }); + } + if (onReplyAll) { + items.push({ + id: "reply-all", + icon: , + label: t("../context_menu.reply_all"), + onClick: () => { act(onReplyAll)!(); }, + }); + } + if (onForward) { + items.push({ + id: "forward", + icon: , + label: t("../context_menu.forward"), + onClick: () => { act(onForward)!(); }, + }); + } + if (onToggleStar) { + items.push({ + id: "star", + icon: , + label: isStarred ? t("../context_menu.unstar") : t("../context_menu.star"), + onClick: () => { act(onToggleStar)!(); }, + }); + } + if (onMarkAsRead) { + items.push({ + id: "mark-read", + icon: isUnread ? : , + label: isUnread ? t("../context_menu.mark_read") : t("../context_menu.mark_unread"), + onClick: () => { onMarkAsRead(email, !isUnread); }, + }); + } + if (onArchive) { + items.push({ + id: "archive", + icon: , + label: t("../context_menu.archive"), + onClick: () => { act(onArchive)!(); }, + }); + } + if (onDelete) { + items.push({ + id: "delete", + icon: , + label: t("../context_menu.delete"), + onClick: () => { act(onDelete)!(); }, + destructive: true, + }); + } + if (onMoveToMailbox) { + items.push({ + id: "move", + icon: , + label: t("../context_menu.move_to"), + onClick: () => { openContextMenu({ preventDefault: () => {}, stopPropagation: () => {}, clientX: radialMenuPos.x, clientY: radialMenuPos.y } as React.MouseEvent, email); }, + }); + } + + return items; + }, [radialMenuEmail, radialMenuPos, t, onReply, onReplyAll, onForward, onToggleStar, onMarkAsRead, onArchive, onDelete, onMoveToMailbox, openContextMenu]); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const [isProcessing, setIsProcessing] = useState(false); @@ -549,7 +646,7 @@ export function EmailList({ onEmailSelect?.(email); }} onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined} - onContextMenu={openContextMenu} + onContextMenu={(e, email) => { openContextMenu(e, email); openRadialMenu(e, email); }} onOpenConversation={onOpenConversation} onToggleStar={onToggleStar ? (email) => onToggleStar(email) : undefined} onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined} @@ -581,6 +678,14 @@ export function EmailList({ )} + {/* Radial Action Menu */} + + {/* Context Menu */} {contextMenuEmail && ( s.isFeatureEnabled('calendarEnabled')); + const createAppointmentVisible = !isScheduled && !isDraft && calendarEnabled && !!email; + // Tablet list visibility const { isTablet, isMobile } = useDeviceDetection(); @@ -1029,6 +1035,34 @@ export function EmailViewer({ const { isMobile: isMobileDevice } = useDeviceDetection(); const router = useRouter(); + const handleCreateAppointment = useCallback(() => { + if (!email) return; + const subject = email.subject ? `Re: ${email.subject}` : ""; + const body = email.htmlBody?.[0]?.partId + ? email.bodyValues?.[email.htmlBody[0].partId]?.value || "" + : ""; + const participants: { name?: string; email: string }[] = []; + const seen = new Set(); + const addParticipant = (p?: { name?: string; email?: string }) => { + if (!p?.email) return; + const normalized = p.email.toLowerCase(); + if (!seen.has(normalized)) { + seen.add(normalized); + participants.push({ name: p.name, email: p.email }); + } + }; + if (email.from) email.from.forEach(addParticipant); + if (email.to) email.to.forEach(addParticipant); + if (email.cc) email.cc.forEach(addParticipant); + useCalendarStore.getState().setNewEventPrefill({ + title: subject, + description: body, + participants, + date: email.receivedAt, + }); + router.push('/calendar'); + }, [email, router]); + const handleViewContactSidebar = (contact: ContactCard | null, recipientEmail: string) => { if (isMobileDevice) { // No room for a sidebar on mobile - send the user to the contacts page @@ -2909,6 +2943,20 @@ export function EmailViewer({ {showToolbarLabels && {t('forward')}} + {createAppointmentVisible && ( + + )} )} diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index 10fbd330..d714f391 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -11,7 +11,7 @@ import { AlertCircle, Star, Clock, FolderUp, FileArchive, FileSpreadsheet, Presentation, FileCode, Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon, - Menu, Users, Share2, + Menu, Users, Share2, MailPlus, Paperclip, } from "lucide-react"; import { useIsDesktop } from "@/hooks/use-media-query"; import { Button } from "@/components/ui/button"; @@ -27,6 +27,7 @@ import { Avatar } from "@/components/ui/avatar"; import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils"; import type { FileResource } from "@/stores/file-store"; import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog"; +import { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; import type { FileNodeRights } from "@/lib/jmap/types"; @@ -106,6 +107,8 @@ interface FileBrowserProps { sharingEnabled?: boolean; /** Add/update/remove a principal's share on a node. Set null rights to revoke. */ onShare?: (id: string, principalId: string, rights: FileNodeRights | null) => Promise; + /** Send selected files as email attachments - opens the composer with files pre-attached. */ + onSendAsAttachment?: (names: string[]) => void; } const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]); @@ -384,6 +387,7 @@ export function FileBrowser({ ownAccountId, sharingEnabled, onShare, + onSendAsAttachment, }: FileBrowserProps) { const t = useTranslations("files"); const [showNewFolder, setShowNewFolder] = useState(false); @@ -401,6 +405,60 @@ export function FileBrowser({ [sharingEnabled, onShare, client]); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null); const [emptyContextMenu, setEmptyContextMenu] = useState<{ x: number; y: number } | null>(null); + + // Radial menu state + const [radialMenuOpen, setRadialMenuOpen] = useState(false); + const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 }); + const [radialMenuResourceName, setRadialMenuResourceName] = useState(null); + + const closeRadialMenu = useCallback(() => { + setRadialMenuOpen(false); + }, []); + + const radialMenuItems = useMemo(() => { + if (!radialMenuResourceName) return []; + const name = radialMenuResourceName; + const resource = resources.find((r) => r.name === name); + const items: RadialMenuItem[] = []; + items.push({ + id: "rename", + icon: , + label: t("rename"), + onClick: () => { setRenameTarget(name); }, + }); + items.push({ + id: "delete", + icon: , + label: t("delete"), + onClick: () => { onDelete(name); }, + destructive: true, + }); + if (resource && !resource.isDirectory) { + items.push({ + id: "download", + icon: , + label: t("download"), + onClick: () => { onDownload(name); }, + }); + } + if (canShare(resource)) { + items.push({ + id: "share", + icon: , + label: t("share"), + onClick: () => { if (resource?.id) setShareTargetId(resource.id); }, + }); + } + if (resource && !resource.isDirectory) { + items.push({ + id: "send-as-attachment", + icon: , + label: t("send_as_attachment"), + onClick: () => {}, + }); + } + return items; + }, [radialMenuResourceName, resources, t, onDelete, onDownload, canShare]); const [showNewTextFile, setShowNewTextFile] = useState(false); const [isUploading, setIsUploading] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -765,6 +823,9 @@ export function FileBrowser({ const handleContextMenu = (e: React.MouseEvent, name: string) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, name }); + setRadialMenuPos({ x: e.clientX, y: e.clientY }); + setRadialMenuResourceName(name); + setRadialMenuOpen(true); }; // Adjust context menu position to stay within viewport @@ -974,28 +1035,49 @@ export function FileBrowser({ {/* Action buttons */}
- {selectedResources.size > 1 && ( - <> - - - - )} + {selectedResources.size > 0 && (() => { + const fileNames = [...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory); + const hasFiles = fileNames.length > 0; + const showBatch = selectedResources.size > 1; + if (!showBatch && !hasFiles) return null; + return ( + <> + {showBatch && ( + <> + + + + )} + {hasFiles && onSendAsAttachment && ( + + )} + + ); + })()} {clipboard && (
diff --git a/components/ui/radial-menu.tsx b/components/ui/radial-menu.tsx new file mode 100644 index 00000000..35f74c78 --- /dev/null +++ b/components/ui/radial-menu.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export interface RadialMenuItem { + id: string; + icon: React.ReactNode; + label: string; + onClick: () => void; + disabled?: boolean; + destructive?: boolean; +} + +interface RadialMenuProps { + items: RadialMenuItem[]; + isOpen: boolean; + position: { x: number; y: number }; + onClose: () => void; + size?: number; +} + +export function RadialMenu({ + items, + isOpen, + position, + onClose, + size = 200, +}: RadialMenuProps) { + const [mounted, setMounted] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + const [animatingIn, setAnimatingIn] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (isOpen) { + requestAnimationFrame(() => requestAnimationFrame(() => setAnimatingIn(true))); + } else { + setAnimatingIn(false); + } + }, [isOpen]); + + useEffect(() => { + if (!isOpen) return; + setActiveIndex(-1); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + return; + } + if (e.key === "Enter" && activeIndex >= 0 && activeIndex < items.length) { + e.preventDefault(); + const item = items[activeIndex]; + if (!item.disabled) { + item.onClick(); + onClose(); + } + return; + } + if (e.key === "ArrowRight" || e.key === "ArrowDown") { + e.preventDefault(); + setActiveIndex((prev) => { + let next = prev + 1; + if (next >= items.length) next = 0; + let loops = 0; + while (items[next]?.disabled && loops < items.length) { + next = next + 1 >= items.length ? 0 : next + 1; + loops++; + } + return next; + }); + return; + } + if (e.key === "ArrowLeft" || e.key === "ArrowUp") { + e.preventDefault(); + setActiveIndex((prev) => { + let next = prev - 1; + if (next < 0) next = items.length - 1; + let loops = 0; + while (items[next]?.disabled && loops < items.length) { + next = next - 1 < 0 ? items.length - 1 : next - 1; + loops++; + } + return next; + }); + return; + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, activeIndex, items, onClose]); + + const radius = size / 2 - 28; + const center = size / 2; + + if (!mounted) return null; + + return createPortal( + <> +
+ +
+
+ +
+ + {items.map((item, index) => { + const angle = (index / items.length) * 2 * Math.PI - Math.PI / 2; + const x = center + radius * Math.cos(angle); + const y = center + radius * Math.sin(angle); + const itemSize = 40; + + return ( +
+ +
+ ); + })} +
+ , + document.body + ); +} diff --git a/locales/en/common.json b/locales/en/common.json index 5ab934c2..55187ebf 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -301,6 +301,7 @@ "view_source": "View source", "export_email": "Export as .eml", "forward_as_attachment": "Forward as attachment", + "create_appointment": "Create Appointment", "import_email": "Import .eml or .zip", "keyboard_shortcuts": "Keyboard shortcuts (?)", "email_source": "Email Source", @@ -3080,6 +3081,7 @@ "delete_confirm_title": "Delete resource", "delete_confirm_message": "Are you sure you want to delete \"{name}\"? This cannot be undone.", "download": "Download", + "send_as_attachment": "Send as Attachment", "name": "Name", "size": "Size", "modified": "Modified", diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index a831e026..a4be3f98 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -251,6 +251,9 @@ interface CalendarStore { refreshICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise; refreshAllSubscriptions: (client: IJMAPClient) => Promise; isSubscriptionCalendar: (calendarId: string) => boolean; + + newEventPrefill: { title?: string; description?: string; participants?: { name?: string; email: string }[]; date?: string } | null; + setNewEventPrefill: (prefill: { title?: string; description?: string; participants?: { name?: string; email: string }[]; date?: string } | null) => void; } const initialState = { @@ -265,6 +268,7 @@ const initialState = { error: null as string | null, dateRange: null as { start: string; end: string } | null, icalSubscriptions: [] as ICalSubscription[], + newEventPrefill: null as { title?: string; description?: string; participants?: { name?: string; email: string }[]; date?: string } | null, }; function getSafeCalendarViewMode(value: unknown): CalendarViewMode { @@ -1009,6 +1013,8 @@ export const useCalendarStore = create()( return get().icalSubscriptions.some(s => s.calendarId === calendarId); }, + setNewEventPrefill: (prefill) => set({ newEventPrefill: prefill }), + addICalSubscription: async (client, url, name, color, refreshInterval = 60) => { // Normalize webcal(s):// → https:// so the server-side fetcher // (which only accepts http/https) doesn't reject every refresh. From e7acf567537477d6be6d90eaa3b5a9b0176f4dc2 Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Fri, 7 Aug 2026 13:32:33 +0200 Subject: [PATCH 6/8] feat: P2.3 Folder Sharing + P2.5 Email Import + P2.6 Contact Import + P2.7 Free/Busy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P2.3: Folder sharing system — ShareFolderDialog, sharing-store, sharing-settings - P2.5: Email import (.eml, .tgz, .zip) with dedup and progress - P2.6: Contact import (vCard + CSV) with auto-mapping - P2.7: Free/Busy view grid with color-coded slots --- app/(main)/[locale]/settings/page.tsx | 16 + app/api/sharing/route.ts | 361 ++++++++++++ components/calendar/event-modal.tsx | 42 +- components/calendar/free-busy-view.tsx | 296 ++++++++++ components/contacts/contact-import-dialog.tsx | 303 +++++++++- components/layout/mailbox-context-menu.tsx | 9 + components/layout/sidebar.tsx | 3 + components/settings/contacts-settings.tsx | 2 + components/settings/import-settings.tsx | 245 ++++++++ components/settings/sharing-settings.tsx | 279 ++++++++++ components/sharing/share-folder-dialog.tsx | 383 +++++++++++++ lib/calendar-freebusy.ts | 180 ++++++ lib/contact-csv-import.ts | 290 ++++++++++ lib/demo/demo-client.ts | 2 + lib/email-import.ts | 249 +++++++++ lib/eml-import.ts | 96 +++- lib/jmap/client-interface.ts | 3 +- lib/jmap/client.ts | 30 +- lib/jmap/types.ts | 26 +- locales/en/common.json | 26 +- stores/sharing-store.ts | 523 ++++++++++++++++++ 21 files changed, 3321 insertions(+), 43 deletions(-) create mode 100644 app/api/sharing/route.ts create mode 100644 components/calendar/free-busy-view.tsx create mode 100644 components/settings/import-settings.tsx create mode 100644 components/settings/sharing-settings.tsx create mode 100644 components/sharing/share-folder-dialog.tsx create mode 100644 lib/calendar-freebusy.ts create mode 100644 lib/contact-csv-import.ts create mode 100644 lib/email-import.ts create mode 100644 stores/sharing-store.ts diff --git a/app/(main)/[locale]/settings/page.tsx b/app/(main)/[locale]/settings/page.tsx index 59eef68b..7682950e 100644 --- a/app/(main)/[locale]/settings/page.tsx +++ b/app/(main)/[locale]/settings/page.tsx @@ -35,6 +35,8 @@ import { SwatchBook, Download, Sparkles, + Upload, + Share2, X, type LucideIcon, } from 'lucide-react'; @@ -71,6 +73,8 @@ import { PluginsSettings } from '@/components/settings/plugins-settings'; import { AiAssistantSettings } from '@/components/settings/ai-assistant-settings'; import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot'; import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry'; +import { ImportSettings } from '@/components/settings/import-settings'; +import { SharingSettings } from '@/components/settings/sharing-settings'; import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings'; import { useAuthStore, redirectToLogin } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; @@ -115,6 +119,8 @@ type Tab = | 'about_data' | 'themes' | 'plugins' + | 'import' + | 'sharing' | 'ai_assistant' | 'debug'; @@ -159,6 +165,8 @@ const tabIcons: Record = { about_data: Info, themes: SwatchBook, plugins: Puzzle, + import: Upload, + sharing: Share2, ai_assistant: Sparkles, debug: Bug, }; @@ -243,6 +251,8 @@ const tabSearchPaths: Record = { themes: [], plugins: [], ai_assistant: [], + import: ['settings.importer'], + sharing: ['sharing'], debug: ['settings.advanced'], }; @@ -275,6 +285,8 @@ const tabKeywords: Record = { themes: 'custom theme css skin appearance', plugins: 'extensions addons', ai_assistant: 'assistant ask model llm ollama chatbot', + import: 'import email eml zip tgz mbox csv vcard contacts', + sharing: 'share shared folder calendar address book permission', debug: 'logs developer console diagnostic', }; @@ -624,6 +636,7 @@ export default function SettingsPage() { { id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' }, { id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' }, { id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' }, + { id: 'sharing', label: t('tabs.sharing'), icon: tabIcons.sharing, group: 'general' }, { id: 'protocol_handlers', label: t('tabs.protocol_handlers'), icon: tabIcons.protocol_handlers, group: 'general' }, // Appearance @@ -641,6 +654,7 @@ export default function SettingsPage() { ...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []), ...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'mail' as TabGroup }] : []), { id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'mail' }, + { id: 'import', label: t('tabs.import'), icon: tabIcons.import, group: 'mail' }, ...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'mail' as TabGroup }] : []), // Privacy & Security @@ -772,6 +786,8 @@ export default function SettingsPage() { {effectiveActiveTab === 'filters' && } {effectiveActiveTab === 'templates' && } {effectiveActiveTab === 'folders' && } + {effectiveActiveTab === 'import' && } + {effectiveActiveTab === 'sharing' && } {effectiveActiveTab === 'keywords' && } {effectiveActiveTab === 'security' && } {effectiveActiveTab === 'content_senders' && } diff --git a/app/api/sharing/route.ts b/app/api/sharing/route.ts new file mode 100644 index 00000000..166f1098 --- /dev/null +++ b/app/api/sharing/route.ts @@ -0,0 +1,361 @@ +import type { NextRequest } from "next/server"; + +type JmapMethodCall = [string, Record, string]; + +async function jmapRequest( + serverUrl: string, + authHeader: string, + methodCalls: JmapMethodCall[], + using?: string[], +) { + const sessionResp = await fetch(`${serverUrl}/.well-known/jmap`, { + headers: { Authorization: authHeader }, + }); + if (!sessionResp.ok) { + return { error: `Session fetch failed: ${sessionResp.status}` }; + } + const session = await sessionResp.json(); + const apiUrl = session.apiUrl; + if (!apiUrl) { + return { error: "No API URL in JMAP session" }; + } + + const body = { + using: using || [ + "urn:ietf:params:jmap:core", + "urn:ietf:params:jmap:mail", + "urn:ietf:params:jmap:principals", + ], + methodCalls, + }; + + const resp = await fetch(apiUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: authHeader, + }, + body: JSON.stringify(body), + }); + + if (!resp.ok) { + return { error: `JMAP request failed: ${resp.status}` }; + } + + return await resp.json(); +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const action = searchParams.get("action"); + const serverUrl = request.headers.get("X-JMAP-Server-Url"); + const authHeader = request.headers.get("Authorization"); + + if (!serverUrl || !authHeader) { + return Response.json( + { error: "Missing server URL or auth header" }, + { status: 400 }, + ); + } + + if (action !== "principals") { + return Response.json( + { error: "Invalid action" }, + { status: 400 }, + ); + } + + const result = await jmapRequest(serverUrl, authHeader, [ + ["Principal/query", { accountId: "" }, "0"], + ["Principal/get", { + accountId: "", + "#ids": { + resultOf: "0", + name: "Principal/query", + path: "/ids", + }, + }, "1"], + ]); + + if ("error" in result) { + return Response.json(result, { status: 502 }); + } + + const getResp = (result as Record).methodResponses as Array<[string, Record, string]> | undefined; + const principals = getResp?.find((r) => r[0] === "Principal/get")?.[1] + ?.list ?? []; + + return Response.json({ principals }); +} + +export async function POST(request: NextRequest) { + const serverUrl = request.headers.get("X-JMAP-Server-Url"); + const authHeader = request.headers.get("Authorization"); + + if (!serverUrl || !authHeader) { + return Response.json( + { error: "Missing server URL or auth header" }, + { status: 400 }, + ); + } + + let body: Record; + try { + body = await request.json(); + } catch { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { kind, resourceId, principalId, role } = body; + + if (!kind || !resourceId || !principalId) { + return Response.json( + { error: "Missing required fields: kind, resourceId, principalId" }, + { status: 400 }, + ); + } + + let method: string; + let shareProperty: string; + + switch (kind) { + case "mailbox": + method = "Mailbox/set"; + shareProperty = "shareWith"; + break; + case "calendar": + method = "Calendar/set"; + shareProperty = "shareWith"; + break; + case "addressBook": + method = "AddressBook/set"; + shareProperty = "shareWith"; + break; + case "file": + method = "FileNode/set"; + shareProperty = "shareWith"; + break; + default: + return Response.json( + { error: `Invalid kind: ${kind}` }, + { status: 400 }, + ); + } + + const patchValue = role === null ? null : buildRights(kind as string, role as string); + + const methodCalls: JmapMethodCall[] = [ + [ + method, + { + accountId: "", + update: { + [resourceId as string]: { + [`${shareProperty}/${principalId}`]: patchValue, + }, + }, + }, + "0", + ], + ]; + + const result = await jmapRequest( + serverUrl, + authHeader, + methodCalls, + ); + + if ("error" in result) { + return Response.json(result, { status: 502 }); + } + + const responses = (result as Record).methodResponses as Array<[string, Record, string]> | undefined; + const setResult = responses?.[0]?.[1]; + + if ( + setResult && + typeof setResult === "object" && + "notUpdated" in setResult && + setResult.notUpdated && + typeof setResult.notUpdated === "object" && + (resourceId as string) in setResult.notUpdated + ) { + const err = (setResult.notUpdated as Record>)[resourceId as string]; + return Response.json( + { error: err.description || "Failed to update share" }, + { status: 400 }, + ); + } + + return Response.json({ ok: true }); +} + +function buildRights( + kind: string, + role: string, +): Record | null { + if (role === null) return null; + + switch (kind) { + case "mailbox": + return mailboxRights(role); + case "calendar": + return calendarRights(role); + case "addressBook": + return addressBookRights(role); + case "file": + return fileRights(role); + default: + return readRights(); + } +} + +function mailboxRights(role: string): Record { + switch (role) { + case "read": + return { + mayReadItems: true, + mayAddItems: false, + mayRemoveItems: false, + maySetSeen: false, + maySetKeywords: false, + mayCreateChild: false, + mayRename: false, + mayDelete: false, + maySubmit: false, + }; + case "readWrite": + return { + mayReadItems: true, + mayAddItems: true, + mayRemoveItems: false, + maySetSeen: true, + maySetKeywords: true, + mayCreateChild: false, + mayRename: false, + mayDelete: false, + maySubmit: true, + }; + case "manager": + return { + mayReadItems: true, + mayAddItems: true, + mayRemoveItems: true, + maySetSeen: true, + maySetKeywords: true, + mayCreateChild: true, + mayRename: true, + mayDelete: true, + maySubmit: true, + mayShare: true, + }; + default: + return mailboxRights("read"); + } +} + +function calendarRights(role: string): Record { + switch (role) { + case "read": + return { + mayReadFreeBusy: true, + mayReadItems: true, + mayWriteAll: false, + mayWriteOwn: false, + mayUpdatePrivate: false, + mayRSVP: false, + mayShare: false, + mayDelete: false, + }; + case "readWrite": + return { + mayReadFreeBusy: true, + mayReadItems: true, + mayWriteAll: true, + mayWriteOwn: true, + mayUpdatePrivate: true, + mayRSVP: true, + mayShare: false, + mayDelete: false, + }; + case "manager": + return { + mayReadFreeBusy: true, + mayReadItems: true, + mayWriteAll: true, + mayWriteOwn: true, + mayUpdatePrivate: true, + mayRSVP: true, + mayShare: true, + mayDelete: true, + }; + default: + return calendarRights("read"); + } +} + +function addressBookRights(role: string): Record { + switch (role) { + case "read": + return { + mayRead: true, + mayWrite: false, + mayShare: false, + mayDelete: false, + }; + case "readWrite": + return { + mayRead: true, + mayWrite: true, + mayShare: false, + mayDelete: false, + }; + case "manager": + return { + mayRead: true, + mayWrite: true, + mayShare: true, + mayDelete: true, + }; + default: + return addressBookRights("read"); + } +} + +function fileRights(role: string): Record { + switch (role) { + case "read": + return { + mayRead: true, + mayAddChildren: false, + mayRename: false, + mayDelete: false, + mayModifyContent: false, + mayShare: false, + }; + case "readWrite": + return { + mayRead: true, + mayAddChildren: true, + mayRename: true, + mayDelete: true, + mayModifyContent: true, + mayShare: false, + }; + case "manager": + return { + mayRead: true, + mayAddChildren: true, + mayRename: true, + mayDelete: true, + mayModifyContent: true, + mayShare: true, + }; + default: + return fileRights("read"); + } +} + +function readRights(): Record { + return { mayRead: true }; +} diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 755aef07..b245b1bb 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -4,13 +4,14 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useTranslations, useLocale } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react"; +import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus, Eye, EyeOff } from "lucide-react"; import { format, parseISO, addHours, addDays, isSameDay } from "date-fns"; import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types"; import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor"; import { parseDuration, getEventColor } from "./event-card"; import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; import { ParticipantInput, type ParticipantInputHandle } from "./participant-input"; +import { FreeBusyView } from "./free-busy-view"; import { isOrganizer, getUserParticipantId, @@ -351,6 +352,7 @@ export function EventModal({ .map(p => ({ name: p.name, email: p.email })); }); const [sendInvitations, setSendInvitations] = useState(true); + const [showFreeBusy, setShowFreeBusy] = useState(false); const participantInputRef = useRef(null); // Plugin transform: collect conflict warnings for the current event form. @@ -1074,6 +1076,44 @@ export function EventModal({ onAdd={handleAddAttendee} onRemove={handleRemoveAttendee} /> + {attendees.length > 0 && !allDay && ( +
+ + {showFreeBusy && ( +
+ { + const d = new Date(`${startDate}T${startTime}:00`); + return isNaN(d.getTime()) ? new Date() : d; + })()} + endDate={(() => { + const d = new Date(`${endDate}T${endTime}:00`); + return isNaN(d.getTime()) ? addHours(new Date(`${startDate}T${startTime}:00`), 8) : d; + })()} + onTimeSelect={(start, end) => { + setStartDate(formatDateInput(start)); + setStartTime(formatTimeInput(start)); + setEndDate(formatDateInput(end)); + setEndTime(formatTimeInput(end)); + }} + /> +
+ )} +
+ )} {isEdit && statusCounts && (existingParticipants.length > 0) && (

{t("participants.status_summary", { diff --git a/components/calendar/free-busy-view.tsx b/components/calendar/free-busy-view.tsx new file mode 100644 index 00000000..69fa5f5b --- /dev/null +++ b/components/calendar/free-busy-view.tsx @@ -0,0 +1,296 @@ +"use client"; + +import { useState, useEffect, useMemo, useCallback } from "react"; +import { useTranslations } from "next-intl"; +import { addMinutes, differenceInMinutes, format } from "date-fns"; +import { Avatar } from "@/components/ui/avatar"; +import { useAuthStore } from "@/stores/auth-store"; +import { cn } from "@/lib/utils"; +import { fetchFreeBusy, type FreeBusySlot, isWorkingHour as isWorkingHourFn } from "@/lib/calendar-freebusy"; + +export interface FreeBusyViewProps { + participants: { name?: string; email: string }[]; + startDate: Date; + endDate: Date; + onTimeSelect?: (start: Date, end: Date) => void; +} + +const SLOT_MINUTES = 30; +const WORK_START_HOUR = 8; +const WORK_END_HOUR = 18; + +const statusColors: Record = { + free: "bg-emerald-100 dark:bg-emerald-900/40 border-emerald-200 dark:border-emerald-800", + busy: "bg-red-100 dark:bg-red-900/40 border-red-200 dark:border-red-800", + tentative: "bg-amber-100 dark:bg-amber-900/40 border-amber-200 dark:border-amber-800", + unavailable: "bg-purple-100 dark:bg-purple-900/40 border-purple-200 dark:border-purple-800", + unknown: "bg-muted border-muted-foreground/20", +}; + +const statusHoverColors: Record = { + free: "hover:bg-emerald-200 dark:hover:bg-emerald-800/60", + busy: "hover:bg-red-200 dark:hover:bg-red-800/60", + tentative: "hover:bg-amber-200 dark:hover:bg-amber-800/60", + unavailable: "hover:bg-purple-200 dark:hover:bg-purple-800/60", + unknown: "hover:bg-muted-foreground/20", +}; + +function clampToSlot(d: Date): Date { + const clone = new Date(d); + clone.setSeconds(0, 0); + const mins = clone.getMinutes(); + const remainder = mins % SLOT_MINUTES; + if (remainder !== 0) { + clone.setMinutes(mins - remainder, 0, 0); + } + return clone; +} + +function buildHourSlots(start: Date, end: Date): { label: string; slots: FreeBusySlot[] }[] { + const hours: { label: string; slots: FreeBusySlot[] }[] = []; + let cursor = clampToSlot(start); + while (cursor < end) { + const hourEnd = new Date(cursor); + hourEnd.setHours(hourEnd.getHours() + 1, 0, 0, 0); + const hourSlots: FreeBusySlot[] = []; + let slotCursor = new Date(cursor); + while (slotCursor < hourEnd && slotCursor < end) { + const slotEnd = addMinutes(slotCursor, SLOT_MINUTES); + hourSlots.push({ + start: new Date(slotCursor), + end: slotEnd > end ? new Date(end) : slotEnd, + status: "unknown", + }); + slotCursor = slotEnd; + } + hours.push({ label: format(cursor, "HH:mm"), slots: hourSlots }); + cursor = hourEnd; + } + return hours; +} + +function isWorkingHour(hour: number): boolean { + return isWorkingHourFn(hour, WORK_START_HOUR, WORK_END_HOUR); +} + +export function FreeBusyView({ + participants, + startDate, + endDate, + onTimeSelect, +}: FreeBusyViewProps) { + const t = useTranslations("calendar"); + const client = useAuthStore((s) => s.client); + const [freeBusyData, setFreeBusyData] = useState | null>(null); + const [loading, setLoading] = useState(false); + const [hoveredSlot, setHoveredSlot] = useState<{ + participant: string; + slotIndex: number; + } | null>(null); + + const hourSlots = useMemo(() => buildHourSlots(startDate, endDate), [startDate, endDate]); + const totalHalfHourSlots = useMemo(() => { + let c = 0; + for (const h of hourSlots) c += h.slots.length; + return c; + }, [hourSlots]); + + const now = new Date(); + const showNowLine = + now >= startDate && now <= endDate; + const nowPositionPercent = showNowLine + ? Math.max(0, Math.min(100, (differenceInMinutes(now, startDate) / differenceInMinutes(endDate, startDate)) * 100)) + : null; + + useEffect(() => { + if (!client || participants.length === 0) return; + let cancelled = false; + setLoading(true); + fetchFreeBusy(client, participants, startDate, endDate) + .then((data) => { + if (!cancelled) { + setFreeBusyData(data); + setLoading(false); + } + }) + .catch(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [client, participants, startDate, endDate]); + + const handleSlotClick = useCallback( + (slot: FreeBusySlot) => { + if (slot.status === "free" && onTimeSelect) { + onTimeSelect(new Date(slot.start), new Date(slot.end)); + } + }, + [onTimeSelect] + ); + + const timezone = useMemo( + () => Intl.DateTimeFormat().resolvedOptions().timeZone, + [] + ); + + if (participants.length === 0) { + return ( +

+ {t("freeBusy.no_participants")} +

+ ); + } + + return ( +
+
+
+ {t("freeBusy.timezone")}: {timezone} +
+ {loading && ( +
+ {t("freeBusy.loading")} +
+ )} +
+ +
+
+ + + + + {hourSlots.map((hour, i) => ( + + ))} + + + + {participants.map((p) => { + const key = p.email.toLowerCase(); + const slots = freeBusyData?.get(key); + return ( + + + {hourSlots.map((hour) => + hour.slots.map((hourSlot, si) => { + const globalSlotIndex = + hourSlots + .slice(0, hourSlots.indexOf(hour)) + .reduce((acc, h) => acc + h.slots.length, 0) + si; + + const slot = slots?.[globalSlotIndex]; + const status = slot?.status ?? "unknown"; + const isFree = status === "free"; + const isHovered = + hoveredSlot?.participant === key && + hoveredSlot?.slotIndex === globalSlotIndex; + + return ( + + ); + }) + )} + + ); + })} + +
+ {t("participants.title")} + + {hour.label} +
+
+ +
+
+ {p.name || p.email} +
+ {p.name && ( +
+ {p.email} +
+ )} +
+
+
+ isFree ? handleSlotClick(slot!) : undefined + } + onMouseEnter={() => + setHoveredSlot({ + participant: key, + slotIndex: globalSlotIndex, + }) + } + onMouseLeave={() => setHoveredSlot(null)} + > + {status === "free" && ( +   + )} +
+
+
+ + {showNowLine && nowPositionPercent !== null && ( +
+ )} + +
+ + + {t("freeBusy.free")} + + + + {t("freeBusy.busy")} + + + + {t("freeBusy.tentative")} + + + + {t("freeBusy.unavailable")} + + + + {t("freeBusy.unknown")} + +
+
+ ); +} diff --git a/components/contacts/contact-import-dialog.tsx b/components/contacts/contact-import-dialog.tsx index 8e8331a1..159ba111 100644 --- a/components/contacts/contact-import-dialog.tsx +++ b/components/contacts/contact-import-dialog.tsx @@ -2,26 +2,39 @@ import { useState, useRef, useCallback } from "react"; import { useTranslations } from "next-intl"; -import { Upload, FileText, AlertTriangle, X, Check } from "lucide-react"; +import { Upload, FileText, AlertTriangle, X, Check, ChevronDown } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { parseVCard, detectDuplicates } from "@/lib/vcard"; -import type { ContactCard } from "@/lib/jmap/types"; +import type { ContactCard, AddressBook } from "@/lib/jmap/types"; import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; +import { + parseCSV, + autoMapColumns, + mapRowToContact, + detectDuplicatesByEmail, + type CsvColumnMapping, + type CsvParseResult, +} from "@/lib/contact-csv-import"; + +type FileType = "vcf" | "csv" | null; interface ContactImportDialogProps { existingContacts: ContactCard[]; + addressBooks?: AddressBook[]; onImport: (contacts: ContactCard[]) => Promise; onClose: () => void; } export function ContactImportDialog({ existingContacts, + addressBooks, onImport, onClose, }: ContactImportDialogProps) { const t = useTranslations("contacts"); const fileRef = useRef(null); + const [fileType, setFileType] = useState(null); const [parsed, setParsed] = useState([]); const [selected, setSelected] = useState>(new Set()); const [duplicates, setDuplicates] = useState>(new Map()); @@ -29,41 +42,111 @@ export function ContactImportDialog({ const [result, setResult] = useState(null); const [error, setError] = useState(null); + const [csvData, setCsvData] = useState(null); + const [mapping, setMapping] = useState(null); + const [targetBookId, setTargetBookId] = useState(""); + const [showPreview, setShowPreview] = useState(false); + + const ALLOWED_ACCEPT = ".vcf,.vcard,.csv,text/csv,text/vcard"; + + const books = addressBooks || []; + const defaultBookId = + books.find((b) => b.isDefault)?.id || books[0]?.id || ""; + const effectiveBookId = targetBookId || defaultBookId; + const bookOptions = books.map((b) => ({ + value: b.id, + label: b.name, + })); + const handleFileChange = useCallback(async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; setError(null); setResult(null); + setFileType(null); + setParsed([]); + setSelected(new Set()); + setDuplicates(new Map()); + setCsvData(null); + setMapping(null); + setShowPreview(false); + setTargetBookId(""); - if (file.size > 5 * 1024 * 1024) { + if (file.size > 10 * 1024 * 1024) { setError(t("import.file_too_large")); return; } + const name = file.name.toLowerCase(); + try { - const text = await file.text(); - const contacts = parseVCard(text); + if (name.endsWith(".csv") || file.type === "text/csv") { + setFileType("csv"); + const text = await file.text(); + const result = parseCSV(text); - if (contacts.length === 0) { - setError(t("import.no_contacts")); - return; + if (result.rows.length === 0) { + setError(t("import.no_contacts")); + return; + } + + setCsvData(result); + setMapping(autoMapColumns(result.headers)); + setTargetBookId(defaultBookId); + } else { + setFileType("vcf"); + const text = await file.text(); + const contacts = parseVCard(text); + + if (contacts.length === 0) { + setError(t("import.no_contacts")); + return; + } + + const dupes = detectDuplicates(existingContacts, contacts); + setParsed(contacts); + setDuplicates(dupes); + + const initialSelected = new Set(); + contacts.forEach((_, idx) => { + if (!dupes.has(idx)) initialSelected.add(idx); + }); + setSelected(initialSelected); } - - const dupes = detectDuplicates(existingContacts, contacts); - setParsed(contacts); - setDuplicates(dupes); - - const initialSelected = new Set(); - contacts.forEach((_, idx) => { - if (!dupes.has(idx)) initialSelected.add(idx); - }); - setSelected(initialSelected); - } catch (error) { - console.error('Failed to parse vCard:', error); + } catch (err) { + console.error("Failed to parse file:", err); setError(t("import.parse_error")); } - }, [existingContacts, t]); + }, [existingContacts, t, defaultBookId]); + + const applyCsvMapping = useCallback(() => { + if (!csvData || !mapping) return; + + const bookIds = effectiveBookId ? { [effectiveBookId]: true } : {}; + const contacts: ContactCard[] = []; + + for (const row of csvData.rows) { + const contact = mapRowToContact(row, mapping, bookIds); + if (contact) contacts.push(contact); + } + + if (contacts.length === 0) { + setError(t("import.no_contacts")); + return; + } + + const dupes = detectDuplicatesByEmail(existingContacts, contacts); + setParsed(contacts); + setDuplicates(dupes); + + const initialSelected = new Set(); + contacts.forEach((_, idx) => { + if (!dupes.has(idx)) initialSelected.add(idx); + }); + setSelected(initialSelected); + setShowPreview(true); + }, [csvData, mapping, effectiveBookId, existingContacts, t]); const toggleSelect = (idx: number) => { const next = new Set(selected); @@ -91,14 +174,160 @@ export function ContactImportDialog({ try { const count = await onImport(toImport); setResult(count); - } catch (error) { - console.error('Failed to import contacts:', error); + } catch (err) { + console.error("Failed to import contacts:", err); setError(t("import.failed")); } finally { setIsImporting(false); } }; + const renderCsvMapping = () => { + if (!csvData || !mapping) return null; + + const fields: Array<{ key: keyof CsvColumnMapping; label: string }> = [ + { key: "firstName", label: t("import.csv_first_name") }, + { key: "lastName", label: t("import.csv_last_name") }, + { key: "email", label: t("import.csv_email") }, + { key: "phone", label: t("import.csv_phone") }, + { key: "company", label: t("import.csv_company") }, + { key: "jobTitle", label: t("import.csv_job_title") }, + { key: "address", label: t("import.csv_address") }, + { key: "city", label: t("import.csv_city") }, + { key: "region", label: t("import.csv_region") }, + { key: "postcode", label: t("import.csv_postcode") }, + { key: "country", label: t("import.csv_country") }, + { key: "website", label: t("import.csv_website") }, + { key: "note", label: t("import.csv_note") }, + { key: "nickname", label: t("import.csv_nickname") }, + ]; + + const headerOptions = csvData.headers.map((h, i) => ({ + value: String(i), + label: h, + })); + + return ( +
+

{t("import.csv_map_columns")}

+
+ {fields.map(({ key, label }) => ( +
+ + +
+ ))} +
+ + {books.length > 0 && ( +
+ + +
+ )} + +
+ + +
+
+ ); + }; + + const renderCsvPreview = () => { + if (!csvData || !mapping || !showPreview) return null; + const previewRows = csvData.rows.slice(0, 5); + + return ( +
+
+

{t("import.csv_preview_title", { count: parsed.length })}

+ +
+
+ + + + {csvData.headers.map((h, i) => ( + + ))} + + + + {previewRows.map((row, ri) => ( + + {row.map((cell, ci) => ( + + ))} + + ))} + +
+ {h} +
+ {cell} +
+
+
+ +
+
+ ); + }; + return (
@@ -119,12 +348,12 @@ export function ContactImportDialog({ {t("import.close")}
- ) : parsed.length === 0 ? ( + ) : fileType === null ? ( <> @@ -141,7 +370,7 @@ export function ContactImportDialog({ >

{t("import.drop_hint")}

-

{t("import.file_types")}

+

{t("import.file_types_csv")}

{error && ( @@ -151,6 +380,10 @@ export function ContactImportDialog({
)} + ) : fileType === "csv" && csvData && !showPreview ? ( + renderCsvMapping() + ) : fileType === "csv" && csvData && showPreview ? ( + renderCsvPreview() ) : ( <> {error && ( @@ -217,7 +450,23 @@ export function ContactImportDialog({ )}
- {parsed.length > 0 && result === null && ( + {parsed.length > 0 && result === null && fileType !== "csv" && ( +
+

+ {t("import.selected", { count: selected.size })} +

+
+ + +
+
+ )} + + {fileType === "csv" && showPreview && parsed.length > 0 && result === null && (

{t("import.selected", { count: selected.size })} diff --git a/components/layout/mailbox-context-menu.tsx b/components/layout/mailbox-context-menu.tsx index 5807d085..e3882fc8 100644 --- a/components/layout/mailbox-context-menu.tsx +++ b/components/layout/mailbox-context-menu.tsx @@ -21,6 +21,7 @@ import { FolderX, RefreshCw, Upload, + Share2, } from "lucide-react"; interface Position { @@ -86,6 +87,7 @@ interface MailboxContextMenuProps { onRenameFolder?: (mailboxId: string) => void; onDeleteFolder?: (mailboxId: string) => void; onImportEmail?: (mailboxId: string) => void; + onShareFolder?: (mailboxId: string) => void; onRefresh?: () => void; } @@ -105,6 +107,7 @@ export function MailboxContextMenu({ onRenameFolder, onDeleteFolder, onImportEmail, + onShareFolder, onRefresh, }: MailboxContextMenuProps) { const t = useTranslations("mailbox_context_menu"); @@ -191,6 +194,12 @@ export function MailboxContextMenu({ onClick={() => handleAction(() => onRenameFolder?.(mailbox.id))} disabled={!onRenameFolder || !canRename} /> + handleAction(() => onShareFolder?.(mailbox.id))} + disabled={!onShareFolder || mailbox.isShared} + /> diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index a7999b58..ab87f6a8 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -90,6 +90,7 @@ interface SidebarProps { onDeleteFolder?: (mailboxId: string) => void; onImportEmail?: (mailboxId: string) => void; onRefreshMailboxes?: () => void; + onShareFolder?: (mailboxId: string) => void; scheduledTotal?: number; showScheduledMailbox?: boolean; /** True when the unified view spans multiple login accounts (cross-account). @@ -779,6 +780,7 @@ export function Sidebar({ onDeleteFolder, onImportEmail, onRefreshMailboxes, + onShareFolder, scheduledTotal = 0, showScheduledMailbox = false, crossAccountActive = false, @@ -1452,6 +1454,7 @@ export function Sidebar({ onRenameFolder={onRenameFolder} onDeleteFolder={onDeleteFolder} onImportEmail={onImportEmail} + onShareFolder={onShareFolder} onRefresh={onRefreshMailboxes} />

diff --git a/components/settings/contacts-settings.tsx b/components/settings/contacts-settings.tsx index 8661979f..14fd2b56 100644 --- a/components/settings/contacts-settings.tsx +++ b/components/settings/contacts-settings.tsx @@ -18,6 +18,7 @@ export function ContactsSettings() { const { client } = useAuthStore(); const { contacts, + addressBooks, supportsSync, importContacts, } = useContactStore(); @@ -46,6 +47,7 @@ export function ContactsSettings() {
setShowImport(false)} /> diff --git a/components/settings/import-settings.tsx b/components/settings/import-settings.tsx new file mode 100644 index 00000000..461e8582 --- /dev/null +++ b/components/settings/import-settings.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { useState, useRef, useCallback, useEffect } from "react"; +import { useTranslations } from "next-intl"; +import { Upload, FolderOpen, Download, AlertTriangle, Check, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { SettingsSection, SettingItem, RadioGroup, Select } from "./settings-section"; +import { importEmails, type ConflictResolution, type ImportProgress, type ImportResult } from "@/lib/email-import"; +import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; +import { EML_IMPORT_ACCEPT } from "@/lib/eml-import"; +import { toast } from "@/stores/toast-store"; +import { cn } from "@/lib/utils"; + +export function ImportSettings() { + const t = useTranslations("settings.importer"); + const { client } = useAuthStore(); + const { mailboxes } = useEmailStore(); + const fileRef = useRef(null); + const [files, setFiles] = useState([]); + const [destination, setDestination] = useState(""); + const [conflict, setConflict] = useState("skip"); + const [progress, setProgress] = useState(null); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [importing, setImporting] = useState(false); + const abortRef = useRef(null); + + useEffect(() => { + if (mailboxes.length > 0 && !destination) { + const inbox = mailboxes.find((m) => m.role === "inbox") || mailboxes[0]; + if (inbox) setDestination(inbox.id); + } + }, [mailboxes, destination]); + + const folderOptions = mailboxes.map((m) => ({ + value: m.id, + label: m.name, + })); + + const handleFileChange = useCallback((e: React.ChangeEvent) => { + const selected = e.target.files; + if (!selected || selected.length === 0) return; + setError(null); + setResult(null); + setProgress(null); + setFiles(Array.from(selected)); + }, []); + + const handleImport = useCallback(async () => { + if (!client || files.length === 0 || !destination) return; + + setImporting(true); + setError(null); + setResult(null); + const controller = new AbortController(); + abortRef.current = controller; + + try { + const res = await importEmails({ + client, + files, + destinationMailboxId: destination, + conflictResolution: conflict, + onProgress: (p) => setProgress({ ...p }), + signal: controller.signal, + }); + setResult(res); + if (res.imported > 0) { + toast.success(t("success", { count: res.imported })); + } + } catch (err) { + if (!controller.signal.aborted) { + const msg = err instanceof Error ? err.message : t("fail"); + setError(msg); + toast.error(msg); + } + } finally { + setImporting(false); + abortRef.current = null; + } + }, [client, files, destination, conflict, t]); + + const handleCancel = () => { + abortRef.current?.abort(); + setImporting(false); + }; + + const reset = () => { + setFiles([]); + setResult(null); + setProgress(null); + setError(null); + if (fileRef.current) fileRef.current.value = ""; + }; + + const progressPercent = progress && progress.total > 0 + ? Math.round((progress.processed / progress.total) * 100) + : 0; + + return ( + + +
+ + + {files.length > 0 && !importing && ( + + )} +
+
+ + + handleChangeRole(share, e.target.value)} + className="appearance-none rounded-md border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-ring" + > + + + + + +
+ ))} +
+ )} + + )} + + {!loading && activeTab === "withMe" && ( + <> + {sharedWithMe.length === 0 ? ( +
+ {tSharing("no_shares_with_me")} +
+ ) : ( +
+ {sharedWithMe.map((share) => ( +
+ +
+
+ {share.resourceName} +
+
+ + | + + {tSharing("shared_by")}: {share.principalName} + +
+
+ + {tSharing(`preset.${share.role}`)} + + {share.pending ? ( +
+ + +
+ ) : ( + + )} +
+ ))} +
+ )} + + )} + + ); +} diff --git a/components/sharing/share-folder-dialog.tsx b/components/sharing/share-folder-dialog.tsx new file mode 100644 index 00000000..11da414a --- /dev/null +++ b/components/sharing/share-folder-dialog.tsx @@ -0,0 +1,383 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { Avatar } from "@/components/ui/avatar"; +import { + X, + Loader2, + UserPlus, + Trash2, + Users, + ChevronDown, +} from "lucide-react"; +import type { IJMAPClient } from "@/lib/jmap/client-interface"; +import type { Principal } from "@/lib/jmap/types"; +import { useSharingStore, type SharedResourceKind } from "@/stores/sharing-store"; + +export interface ShareFolderDialogProps { + client: IJMAPClient; + resourceId: string; + resourceName: string; + resourceKind: SharedResourceKind; + onClose: () => void; +} + +const PRESET_OPTIONS: Record = { + mailbox: ["read", "readWrite", "manager"], + calendar: ["read", "readWrite", "manager"], + addressBook: ["read", "readWrite", "manager"], + file: ["read", "readWrite", "manager"], +}; + +export function ShareFolderDialog({ + client, + resourceId, + resourceName, + resourceKind, + onClose, +}: ShareFolderDialogProps) { + const t = useTranslations("sharing"); + const tCommon = useTranslations("common"); + const modalRef = useRef(null); + + const sharedByMe = useSharingStore((s) => s.sharedByMe); + const loadPrincipals = useSharingStore((s) => s.loadPrincipals); + const shareFolder = useSharingStore((s) => s.shareFolder); + const revokeShare = useSharingStore((s) => s.revokeShare); + const changeRole = useSharingStore((s) => s.changeRole); + + const [allPrincipals, setAllPrincipals] = useState([]); + const [loadingPrincipals, setLoadingPrincipals] = useState(true); + const [search, setSearch] = useState(""); + const [savingId, setSavingId] = useState(null); + const [showAdd, setShowAdd] = useState(false); + const [message, setMessage] = useState(""); + + useEffect(() => { + let cancelled = false; + setLoadingPrincipals(true); + loadPrincipals(client) + .then((list) => { + if (cancelled) return; + setAllPrincipals(list); + setLoadingPrincipals(false); + }) + .catch(() => { + if (!cancelled) setLoadingPrincipals(false); + }); + return () => { + cancelled = true; + }; + }, [client, loadPrincipals]); + + const ownAccountId = client.getAccountId(); + + const allPrincipalsById = useMemo(() => { + const map = new Map(); + for (const p of allPrincipals) map.set(p.id, p); + return map; + }, [allPrincipals]); + + const currentShares = sharedByMe.filter( + (f) => f.resourceId === resourceId && f.resourceKind === resourceKind, + ); + + const principals = useMemo(() => { + const existing = new Set(currentShares.map((s) => s.principalId)); + return allPrincipals.filter( + (p) => p.id !== ownAccountId && !existing.has(p.id), + ); + }, [allPrincipals, ownAccountId, currentShares]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [onClose]); + + const handleRemove = async (principalId: string) => { + setSavingId(principalId); + try { + await revokeShare(client, resourceId, resourceKind, principalId); + } catch { + /* error toast comes from store */ + } finally { + setSavingId(null); + } + }; + + const handleChangeRole = async (principalId: string, role: string) => { + setSavingId(principalId); + try { + await changeRole(client, resourceId, resourceKind, principalId, role); + } catch { + /* error toast comes from store */ + } finally { + setSavingId(null); + } + }; + + const handleAdd = async (principal: Principal) => { + setSavingId(principal.id); + try { + await shareFolder( + client, + resourceId, + resourceName, + resourceKind, + principal.id, + "read", + message || undefined, + ); + setShowAdd(false); + setSearch(""); + setMessage(""); + } catch { + /* error toast comes from store */ + } finally { + setSavingId(null); + } + }; + + const filteredPrincipals = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return principals; + return principals.filter( + (p) => + p.name.toLowerCase().includes(q) || + p.email?.toLowerCase().includes(q) || + p.description?.toLowerCase().includes(q), + ); + }, [principals, search]); + + const presetOptions = PRESET_OPTIONS[resourceKind]; + + const kindLabels: Record = { + mailbox: "Mail folder", + calendar: "Calendar", + addressBook: "Address book", + file: "File folder", + }; + + return ( +
+