Merge remote-tracking branch 'origin/dev' into sync-github-and-ci-fix
This commit is contained in:
@@ -99,7 +99,9 @@ export default function CalendarPage() {
|
|||||||
refreshAllSubscriptions, icalSubscriptions,
|
refreshAllSubscriptions, icalSubscriptions,
|
||||||
} = useCalendarStore();
|
} = useCalendarStore();
|
||||||
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
|
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
|
||||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
const calendarTasksEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarTasksEnabled'));
|
||||||
|
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks: userTasksEnabled, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
||||||
|
const enableCalendarTasks = userTasksEnabled && calendarTasksEnabled;
|
||||||
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
||||||
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
|
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
|
||||||
const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor);
|
const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor);
|
||||||
|
|||||||
Binary file not shown.
@@ -19,6 +19,7 @@ import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
|||||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
||||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||||
|
import { checkUserAuthRateLimit } from '@/lib/admin/rate-limit';
|
||||||
|
|
||||||
function sessionCookieOptions() {
|
function sessionCookieOptions() {
|
||||||
return {
|
return {
|
||||||
@@ -48,6 +49,15 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
||||||
|
const rateLimit = checkUserAuthRateLimit(ip, username);
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Too many login attempts', retryAfterMs: rateLimit.retryAfterMs },
|
||||||
|
{ status: 429 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Pin the upstream URL to a configured JMAP server so an unauthenticated
|
// Pin the upstream URL to a configured JMAP server so an unauthenticated
|
||||||
// caller cannot point this route at internal hosts. We accept the global
|
// caller cannot point this route at internal hosts. We accept the global
|
||||||
// `jmapServerUrl` and any entry from `jmapServers`. When neither matches,
|
// `jmapServerUrl` and any entry from `jmapServers`. When neither matches,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oaut
|
|||||||
import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
|
import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||||
|
import { checkUserAuthRateLimit } from '@/lib/admin/rate-limit';
|
||||||
|
|
||||||
function getSlot(request: NextRequest): number {
|
function getSlot(request: NextRequest): number {
|
||||||
const raw = request.nextUrl.searchParams.get('slot');
|
const raw = request.nextUrl.searchParams.get('slot');
|
||||||
@@ -16,6 +17,15 @@ function getSlot(request: NextRequest): number {
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
||||||
|
const rateLimit = checkUserAuthRateLimit(ip, 'oauth-token');
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Too many token requests', retryAfterMs: rateLimit.retryAfterMs },
|
||||||
|
{ status: 429 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { code, code_verifier, redirect_uri, slot: bodySlot, server_id: bodyServerId } = await request.json();
|
const { code, code_verifier, redirect_uri, slot: bodySlot, server_id: bodyServerId } = await request.json();
|
||||||
|
|
||||||
if (!code || !code_verifier || !redirect_uri) {
|
if (!code || !code_verifier || !redirect_uri) {
|
||||||
|
|||||||
@@ -456,8 +456,8 @@ describe('expandRecurringEvents', () => {
|
|||||||
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily' })],
|
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily' })],
|
||||||
});
|
});
|
||||||
const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00');
|
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[0].id).toBe('evt1::occurrence::2025-01-06T09:00:00');
|
||||||
expect(result[1].id).toBe('evt1:2025-01-07T09:00:00');
|
expect(result[1].id).toBe('evt1::occurrence::2025-01-07T09:00:00');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves originalId pointing to master', () => {
|
it('preserves originalId pointing to master', () => {
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ function verifyPassword(password: string, stored: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isHashed(value: string): boolean {
|
function isHashed(value: string): boolean {
|
||||||
return value.startsWith('$scrypt$') || value.startsWith('$2a$') || value.startsWith('$2b$');
|
return value.startsWith('$scrypt$');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Disk I/O ───────────────────────────────────────────────────────────────
|
// ─── Disk I/O ───────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+31
-9
@@ -1,9 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* In-memory rate limiter for admin login.
|
* In-memory rate limiter for admin login and user authentication.
|
||||||
* Max 5 attempts per IP per 15 minutes.
|
* Admin: max 5 attempts per IP per 15 minutes.
|
||||||
|
* User auth: max 10 attempts per (IP + username) per 15 minutes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const MAX_ATTEMPTS = 5;
|
const MAX_ADMIN_ATTEMPTS = 5;
|
||||||
|
const MAX_USER_ATTEMPTS = 10;
|
||||||
const WINDOW_MS = 15 * 60 * 1000; // 15 minutes
|
const WINDOW_MS = 15 * 60 * 1000; // 15 minutes
|
||||||
|
|
||||||
interface RateLimitEntry {
|
interface RateLimitEntry {
|
||||||
@@ -28,18 +30,38 @@ setInterval(() => {
|
|||||||
*/
|
*/
|
||||||
export function checkRateLimit(ip: string): { allowed: boolean; remaining: number; retryAfterMs: number } {
|
export function checkRateLimit(ip: string): { allowed: boolean; remaining: number; retryAfterMs: number } {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const entry = attempts.get(ip);
|
const entry = attempts.get(`admin:${ip}`);
|
||||||
|
|
||||||
if (!entry || entry.resetAt <= now) {
|
if (!entry || entry.resetAt <= now) {
|
||||||
// New window
|
attempts.set(`admin:${ip}`, { count: 1, resetAt: now + WINDOW_MS });
|
||||||
attempts.set(ip, { count: 1, resetAt: now + WINDOW_MS });
|
return { allowed: true, remaining: MAX_ADMIN_ATTEMPTS - 1, retryAfterMs: 0 };
|
||||||
return { allowed: true, remaining: MAX_ATTEMPTS - 1, retryAfterMs: 0 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.count >= MAX_ATTEMPTS) {
|
if (entry.count >= MAX_ADMIN_ATTEMPTS) {
|
||||||
return { allowed: false, remaining: 0, retryAfterMs: entry.resetAt - now };
|
return { allowed: false, remaining: 0, retryAfterMs: entry.resetAt - now };
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.count++;
|
entry.count++;
|
||||||
return { allowed: true, remaining: MAX_ATTEMPTS - entry.count, retryAfterMs: 0 };
|
return { allowed: true, remaining: MAX_ADMIN_ATTEMPTS - entry.count, retryAfterMs: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check rate limit for user authentication, keyed by IP + username.
|
||||||
|
*/
|
||||||
|
export function checkUserAuthRateLimit(ip: string, username: string): { allowed: boolean; remaining: number; retryAfterMs: number } {
|
||||||
|
const now = Date.now();
|
||||||
|
const key = `user:${ip}:${username}`;
|
||||||
|
const entry = attempts.get(key);
|
||||||
|
|
||||||
|
if (!entry || entry.resetAt <= now) {
|
||||||
|
attempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
|
||||||
|
return { allowed: true, remaining: MAX_USER_ATTEMPTS - 1, retryAfterMs: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.count >= MAX_USER_ATTEMPTS) {
|
||||||
|
return { allowed: false, remaining: 0, retryAfterMs: entry.resetAt - now };
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count++;
|
||||||
|
return { allowed: true, remaining: MAX_USER_ATTEMPTS - entry.count, retryAfterMs: 0 };
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-3
@@ -11,6 +11,13 @@ const TAG_LENGTH = 16;
|
|||||||
|
|
||||||
const MIN_SECRET_LENGTH = 32;
|
const MIN_SECRET_LENGTH = 32;
|
||||||
|
|
||||||
|
const revokedTokens = new Map<string, number>(); // jti → expiry timestamp
|
||||||
|
|
||||||
|
function isHttpsRequest(req: { headers: Headers }): boolean {
|
||||||
|
const proto = req.headers.get('x-forwarded-proto');
|
||||||
|
return proto === 'https';
|
||||||
|
}
|
||||||
|
|
||||||
function getKey(): Buffer {
|
function getKey(): Buffer {
|
||||||
const secret = getSessionSecret();
|
const secret = getSessionSecret();
|
||||||
if (!secret) throw new Error('SESSION_SECRET not configured');
|
if (!secret) throw new Error('SESSION_SECRET not configured');
|
||||||
@@ -37,10 +44,12 @@ export function createAdminSession(): string {
|
|||||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const exp = now + getSessionTTL();
|
||||||
const payload: AdminSessionPayload = {
|
const payload: AdminSessionPayload = {
|
||||||
role: 'admin',
|
role: 'admin',
|
||||||
iat: now,
|
iat: now,
|
||||||
exp: now + getSessionTTL(),
|
exp,
|
||||||
|
jti: randomBytes(16).toString('hex'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const json = JSON.stringify(payload);
|
const json = JSON.stringify(payload);
|
||||||
@@ -74,12 +83,29 @@ export function verifyAdminSession(token: string): AdminSessionPayload | null {
|
|||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
if (payload.exp < now) return null;
|
if (payload.exp < now) return null;
|
||||||
|
|
||||||
|
// Clean up expired revocations while we're here
|
||||||
|
for (const [jti, expiry] of revokedTokens) {
|
||||||
|
if (expiry < now) revokedTokens.delete(jti);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.jti && revokedTokens.has(payload.jti)) return null;
|
||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke an admin session token so it cannot be used again.
|
||||||
|
*/
|
||||||
|
export function revokeAdminSession(token: string): void {
|
||||||
|
const payload = verifyAdminSession(token);
|
||||||
|
if (payload?.jti) {
|
||||||
|
revokedTokens.set(payload.jti, payload.exp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSRF gate for cookie-authed admin requests.
|
* CSRF gate for cookie-authed admin requests.
|
||||||
*
|
*
|
||||||
@@ -146,12 +172,12 @@ export async function requireAdminAuth(request: Request): Promise<{ payload: Adm
|
|||||||
/**
|
/**
|
||||||
* Set the admin session cookie.
|
* Set the admin session cookie.
|
||||||
*/
|
*/
|
||||||
export async function setAdminSessionCookie(): Promise<void> {
|
export async function setAdminSessionCookie(request?: { headers: Headers }): Promise<void> {
|
||||||
const token = createAdminSession();
|
const token = createAdminSession();
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set(ADMIN_SESSION_COOKIE, token, {
|
cookieStore.set(ADMIN_SESSION_COOKIE, token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: request ? isHttpsRequest(request) : process.env.NODE_ENV === 'production',
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
path: '/',
|
path: '/',
|
||||||
maxAge: getSessionTTL(),
|
maxAge: getSessionTTL(),
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface AdminSessionPayload {
|
|||||||
role: 'admin';
|
role: 'admin';
|
||||||
iat: number;
|
iat: number;
|
||||||
exp: number;
|
exp: number;
|
||||||
|
jti?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SettingRestriction {
|
export interface SettingRestriction {
|
||||||
|
|||||||
+33
-2
@@ -3,10 +3,21 @@ import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
|||||||
import type { IJMAPClient } from "./client-interface";
|
import type { IJMAPClient } from "./client-interface";
|
||||||
import { toWildcardQuery } from "./search-utils";
|
import { toWildcardQuery } from "./search-utils";
|
||||||
import { batched, itemsPerRequest } from "./request-limits";
|
import { batched, itemsPerRequest } from "./request-limits";
|
||||||
import { noteTransportFailure, noteTransportSuccess } from "./transport-health";
|
import { noteTransportFailure, noteTransportSuccess, transportFailureCount } from "./transport-health";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization";
|
import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization";
|
||||||
|
|
||||||
|
export class TransportError extends Error {
|
||||||
|
constructor(message = 'Network transport failure') {
|
||||||
|
super(message);
|
||||||
|
this.name = 'TransportError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wasTransportFailure(beforeCount: number): boolean {
|
||||||
|
return transportFailureCount() > beforeCount;
|
||||||
|
}
|
||||||
|
|
||||||
/** Parse a recipient string that may be "Name <email>" or bare "email" into { name?, email }. */
|
/** Parse a recipient string that may be "Name <email>" or bare "email" into { name?, email }. */
|
||||||
function parseRecipientString(s: string): { name?: string; email: string } {
|
function parseRecipientString(s: string): { name?: string; email: string } {
|
||||||
const trimmed = s.trim();
|
const trimmed = s.trim();
|
||||||
@@ -1220,6 +1231,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
|
async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
|
||||||
|
const tcBefore = transportFailureCount();
|
||||||
try {
|
try {
|
||||||
const targetAccountId = accountId || this.accountId;
|
const targetAccountId = accountId || this.accountId;
|
||||||
const simple: { inMailbox?: string; hasKeyword?: string } = {};
|
const simple: { inMailbox?: string; hasKeyword?: string } = {};
|
||||||
@@ -1290,6 +1302,9 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
return { emails: [], hasMore: false, total: 0 };
|
return { emails: [], hasMore: false, total: 0 };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (wasTransportFailure(tcBefore)) {
|
||||||
|
throw new TransportError('Failed to get emails: network transport failure');
|
||||||
|
}
|
||||||
console.error('Failed to get emails:', error);
|
console.error('Failed to get emails:', error);
|
||||||
return { emails: [], hasMore: false, total: 0 };
|
return { emails: [], hasMore: false, total: 0 };
|
||||||
}
|
}
|
||||||
@@ -2099,6 +2114,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
|
async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
|
||||||
|
const tcBefore = transportFailureCount();
|
||||||
try {
|
try {
|
||||||
const targetAccountId = accountId || this.accountId;
|
const targetAccountId = accountId || this.accountId;
|
||||||
|
|
||||||
@@ -2154,6 +2170,9 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
return { emails, hasMore, total };
|
return { emails, hasMore, total };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (wasTransportFailure(tcBefore)) {
|
||||||
|
throw new TransportError('Search failed: network transport failure');
|
||||||
|
}
|
||||||
console.error('Search failed:', error);
|
console.error('Search failed:', error);
|
||||||
return { emails: [], hasMore: false, total: 0 };
|
return { emails: [], hasMore: false, total: 0 };
|
||||||
}
|
}
|
||||||
@@ -6041,6 +6060,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
private lastSSEActivity: number = 0;
|
private lastSSEActivity: number = 0;
|
||||||
private visibilityHandler: (() => void) | null = null;
|
private visibilityHandler: (() => void) | null = null;
|
||||||
private onlineHandler: (() => void) | null = null;
|
private onlineHandler: (() => void) | null = null;
|
||||||
|
private offlineHandler: (() => void) | null = null;
|
||||||
|
|
||||||
// JMAP-over-WebSocket (RFC 8887) push - preferred over SSE when the server
|
// JMAP-over-WebSocket (RFC 8887) push - preferred over SSE when the server
|
||||||
// advertises it (getWebSocketUrl()), since it's the transport the desktop
|
// advertises it (getWebSocketUrl()), since it's the transport the desktop
|
||||||
@@ -6076,6 +6096,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
private wsReconnectAttempts: number = 0;
|
private wsReconnectAttempts: number = 0;
|
||||||
private wsConsecutiveFailures: number = 0;
|
private wsConsecutiveFailures: number = 0;
|
||||||
private wsPermanentlyDisabled: boolean = false;
|
private wsPermanentlyDisabled: boolean = false;
|
||||||
|
private _statesPromise: Promise<void> | null = null;
|
||||||
private wsHeartbeatTimer: NodeJS.Timeout | null = null;
|
private wsHeartbeatTimer: NodeJS.Timeout | null = null;
|
||||||
private lastWSActivity: number = 0;
|
private lastWSActivity: number = 0;
|
||||||
|
|
||||||
@@ -6157,7 +6178,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
// entirely: SSE only streams changes from the moment it connects
|
// entirely: SSE only streams changes from the moment it connects
|
||||||
// onward (no catch-up on connect), so the one thing that CAN catch up
|
// 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.
|
// 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
|
// Not confirmed either way whether this server's WebSocket push fans
|
||||||
// out to shared/secondary accounts or, like Stalwart's SSE, covers the
|
// out to shared/secondary accounts or, like Stalwart's SSE, covers the
|
||||||
// primary account only - keep the same secondary poll running under
|
// primary account only - keep the same secondary poll running under
|
||||||
@@ -6301,6 +6322,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
* original 31s-worst-case ladder did.
|
* original 31s-worst-case ladder did.
|
||||||
*/
|
*/
|
||||||
private async reconcileAfterWebSocketFallback(): Promise<void> {
|
private async reconcileAfterWebSocketFallback(): Promise<void> {
|
||||||
|
await this._statesPromise;
|
||||||
await this.checkForStateChanges();
|
await this.checkForStateChanges();
|
||||||
|
|
||||||
const eventSourceUrl = this.getEventSourceUrl();
|
const eventSourceUrl = this.getEventSourceUrl();
|
||||||
@@ -6781,6 +6803,11 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
this.offlineHandler = () => {
|
||||||
|
this.closePushNotifications();
|
||||||
|
};
|
||||||
|
window.addEventListener('offline', this.offlineHandler);
|
||||||
|
|
||||||
this.onlineHandler = () => {
|
this.onlineHandler = () => {
|
||||||
// Network reconnected - reconnect WS/SSE or force a poll. Don't
|
// Network reconnected - reconnect WS/SSE or force a poll. Don't
|
||||||
// make the user wait through whatever backoff delay was already in
|
// make the user wait through whatever backoff delay was already in
|
||||||
@@ -6814,6 +6841,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
document.removeEventListener('visibilitychange', this.visibilityHandler);
|
document.removeEventListener('visibilitychange', this.visibilityHandler);
|
||||||
this.visibilityHandler = null;
|
this.visibilityHandler = null;
|
||||||
}
|
}
|
||||||
|
if (this.offlineHandler && typeof window !== 'undefined') {
|
||||||
|
window.removeEventListener('offline', this.offlineHandler);
|
||||||
|
this.offlineHandler = null;
|
||||||
|
}
|
||||||
if (this.onlineHandler && typeof window !== 'undefined') {
|
if (this.onlineHandler && typeof window !== 'undefined') {
|
||||||
window.removeEventListener('online', this.onlineHandler);
|
window.removeEventListener('online', this.onlineHandler);
|
||||||
this.onlineHandler = null;
|
this.onlineHandler = null;
|
||||||
|
|||||||
@@ -800,6 +800,7 @@ export interface StateChange {
|
|||||||
Calendar?: string;
|
Calendar?: string;
|
||||||
CalendarEvent?: string;
|
CalendarEvent?: string;
|
||||||
SieveScript?: string;
|
SieveScript?: string;
|
||||||
|
FileNode?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,7 +228,10 @@ export class MailIndex {
|
|||||||
let version = opened.version;
|
let version = opened.version;
|
||||||
|
|
||||||
if (version !== null && version !== SCHEMA_VERSION) {
|
if (version !== null && version !== SCHEMA_VERSION) {
|
||||||
// Rebuildable derived data: drop, don't migrate.
|
console.warn(
|
||||||
|
`[MailIndex] Schema version mismatch (stored=${version}, current=${SCHEMA_VERSION}). ` +
|
||||||
|
'Dropping all tables — a full reindex will run on the next push event or /api/offline/reindex call.',
|
||||||
|
);
|
||||||
db.exec('DROP TABLE IF EXISTS doc_fts; DROP TABLE IF EXISTS doc; DROP TABLE IF EXISTS meta;');
|
db.exec('DROP TABLE IF EXISTS doc_fts; DROP TABLE IF EXISTS doc; DROP TABLE IF EXISTS meta;');
|
||||||
version = null;
|
version = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,8 @@ export async function exchangeCodeForTokens(
|
|||||||
const tokens = await tokenResponse.json();
|
const tokens = await tokenResponse.json();
|
||||||
|
|
||||||
if (!tokens.access_token) {
|
if (!tokens.access_token) {
|
||||||
logger.error('Token response missing access_token', { response: JSON.stringify(tokens).substring(0, 500) });
|
const { access_token: _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');
|
throw new Error('Invalid token response');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ function createOccurrence(
|
|||||||
return {
|
return {
|
||||||
...master,
|
...master,
|
||||||
...(override || {}),
|
...(override || {}),
|
||||||
id: `${master.id}:${recurrenceId}`,
|
id: `${master.id}::occurrence::${recurrenceId}`,
|
||||||
originalId: master.originalId || master.id,
|
originalId: master.originalId || master.id,
|
||||||
uid: master.uid,
|
uid: master.uid,
|
||||||
calendarIds: master.calendarIds,
|
calendarIds: master.calendarIds,
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export function importTemplates(json: string): ImportResult {
|
|||||||
id: generateUUID(),
|
id: generateUUID(),
|
||||||
name: sanitizeText(t.name),
|
name: sanitizeText(t.name),
|
||||||
subject: sanitizeText(t.subject),
|
subject: sanitizeText(t.subject),
|
||||||
body: t.isHTML ? String(t.body || '') : sanitizeText(t.body),
|
body: t.isHTML ? DOMPurify.sanitize(String(t.body || ''), { ALLOWED_TAGS: ['b', 'i', 'u', 'strong', 'em', 'a', 'p', 'br', 'ul', 'ol', 'li', 'div', 'span', 'table', 'tr', 'td', 'th', 'thead', 'tbody', 'img', 'h1', 'h2', 'h3', 'blockquote'], ALLOWED_ATTR: ['href', 'src', 'alt', 'style', 'class', 'target'] }) : sanitizeText(t.body),
|
||||||
isHTML: Boolean(t.isHTML),
|
isHTML: Boolean(t.isHTML),
|
||||||
category: sanitizeText(t.category),
|
category: sanitizeText(t.category),
|
||||||
defaultRecipients: recipients && typeof recipients === 'object'
|
defaultRecipients: recipients && typeof recipients === 'object'
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "vncmail-plus",
|
||||||
"version": "1.7.8",
|
"version": "1.7.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bulwark-webmail",
|
"name": "vncmail-plus",
|
||||||
"version": "1.7.8",
|
"version": "1.7.8",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,514 @@
|
|||||||
|
# VNCmail+ v1.7.8 → v1.8.0 Development Plan
|
||||||
|
|
||||||
|
**Date:** 2026-08-07
|
||||||
|
**Target:** `brvncde-dotcom/vncmail-plus` (Next.js 16)
|
||||||
|
**Baseline:** VNCmailgraph audit (82 findings) + Bugs VNCmail+.docx (12 missing features)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase Structure
|
||||||
|
|
||||||
|
| Phase | Focus | Features + Fixes | Autonomy |
|
||||||
|
|-------|-------|-----------------|----------|
|
||||||
|
| **P1** | Critical fixes | 8 CRITICAL + 10 HIGH audit findings | Full autonomous |
|
||||||
|
| **P2** | Missing features | 12 docx features, code reuse from Angular | Full autonomous (board decided VNCtalk/Collabora/ActionWheel) |
|
||||||
|
| **P3** | Security hardening | Auth, cookies, rate limiting, encryption | Full autonomous |
|
||||||
|
| **P4** | Polish & sync | Remaining HIGH + cross-feature substrate | Full autonomous |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Critical Bug Fixes (Autonomous)
|
||||||
|
|
||||||
|
### P1.1 — Error Swallowing (CRITICAL C1)
|
||||||
|
**Issue:** `getEmails`/`searchEmails` return empty on transport failure. Network-down = empty folder.
|
||||||
|
|
||||||
|
**Fix:** Sample `transportHealth().transportFailureCount()` before/after JMAP reads. If incremented, throw `TransportError` instead of returning `{ emails: [], ... }`. Store catches and shows connectivity banner.
|
||||||
|
- Files: `lib/jmap/client.ts`, `stores/email-store.ts`, `lib/jmap/transport-health.ts`
|
||||||
|
- Reuse: None
|
||||||
|
- Effort: 2h
|
||||||
|
|
||||||
|
### P1.2 — FTS5 Schema-Drop Rebuild (CRITICAL C6)
|
||||||
|
**Issue:** Schema version bump drops all tables without auto-rebuild.
|
||||||
|
|
||||||
|
**Fix:** After `DROP TABLE IF EXISTS`, trigger automatic `catchUpAll()` in the same operation. Add user-facing "Rebuilding search index..." indicator.
|
||||||
|
- Files: `lib/mail-index/store.ts`, `lib/mail-index/reindex.ts`
|
||||||
|
- Reuse: None
|
||||||
|
- Effort: 2h
|
||||||
|
|
||||||
|
### P1.3 — Auth Credentials in localStorage (CRITICAL C5)
|
||||||
|
**Issue:** `auth-storage` + `account-storage` contain server URLs and usernames in plaintext.
|
||||||
|
|
||||||
|
**Fix:** Encrypt the Zustand persist payload for these two stores using a key derived from session secret or a per-device key. Scope: `auth-store.ts` and `account-store.ts` persist middleware.
|
||||||
|
- Files: `stores/auth-store.ts`, `stores/account-store.ts`, new `lib/auth/local-storage-crypto.ts`
|
||||||
|
- Reuse: AES-256-GCM pattern from `lib/auth/crypto.ts`
|
||||||
|
- Effort: 4h
|
||||||
|
|
||||||
|
### P1.4 — Settings Lock Bypass (CRITICAL C7)
|
||||||
|
**Issue:** `updateSetting()` has no policy check. Admin locks bypassable via store.
|
||||||
|
|
||||||
|
**Fix:** Gate `updateSetting(key, value)` with `isSettingLocked(key)`. Add `{ force: true }` opt-in for legitimate bypassers (auth bootstrap, settings sync). Audit all `updateSetting` call sites.
|
||||||
|
- Files: `stores/settings-store.ts`, `stores/auth-store.ts`, `lib/settings-sync.ts`
|
||||||
|
- Reuse: None
|
||||||
|
- Effort: 3h
|
||||||
|
|
||||||
|
### P1.5 — Offline Push Detection (CRITICAL C8)
|
||||||
|
**Issue:** No `offline` event listener. Transports retry blindly, draining battery.
|
||||||
|
|
||||||
|
**Fix:** Add `window.addEventListener('offline', ...)` that pauses all push transports. Add `navigator.onLine` gate on each transport cycle. Wire `transportHealth().likelyOffline` into push lifecycle.
|
||||||
|
- Files: `lib/jmap/client.ts`
|
||||||
|
- Reuse: None
|
||||||
|
- Effort: 1h
|
||||||
|
|
||||||
|
### P1.6 — Admin Session Revocation (CRITICAL C4)
|
||||||
|
**Issue:** AES-256-GCM token has no server-side revocation.
|
||||||
|
|
||||||
|
**Fix:** Add in-memory token blacklist (Set with TTL) in admin session middleware. Logout adds token `jti` to blacklist. Cleanup expired entries on verification.
|
||||||
|
- Files: `lib/admin/session.ts`, `app/api/admin/auth/route.ts`
|
||||||
|
- Reuse: None
|
||||||
|
- Effort: 3h
|
||||||
|
|
||||||
|
### P1.7 — Calendar ID Collision (CRITICAL C2)
|
||||||
|
**Issue:** Recurrence expansion (`:` delimiter) collides with shared event prefix.
|
||||||
|
|
||||||
|
**Fix:** Change expansion delimiter from `:` to `::occurrence::`. Update `stripLocalAccountPrefix` and all ID parsing.
|
||||||
|
- Files: `lib/recurrence-expansion.ts`, `stores/calendar-store.ts`
|
||||||
|
- Reuse: None
|
||||||
|
- Effort: 1h
|
||||||
|
|
||||||
|
### P1.8 — Calendar Cross-Account Dedup (CRITICAL C3)
|
||||||
|
**Issue:** Multi-account event aggregation has no UID dedup.
|
||||||
|
|
||||||
|
**Fix:** After `Promise.all(...).flat()`, run `uniqueBy(events, e => e.uid + e.recurrenceId)` pass.
|
||||||
|
- Files: `stores/calendar-store.ts`
|
||||||
|
- Reuse: None
|
||||||
|
- Effort: 1h
|
||||||
|
|
||||||
|
### P1.9 — HIGH Fixes Batch
|
||||||
|
- **H4:** Strip `access_token` from OAuth error logs (`lib/oauth/token-exchange.ts`) — 30min
|
||||||
|
- **H9:** Fix bcrypt in `isHashed()` (`lib/admin/password.ts`) — 1h
|
||||||
|
- **H18:** Default `autoSelectReplyIdentity` to `true` (`stores/settings-store.ts`) — 30min
|
||||||
|
- **H7:** DOMPurify HTML template body on import (`lib/template-utils.ts`) — 1h
|
||||||
|
- **H13:** `calendarTasksEnabled` runtime enforcement (`stores/task-store.ts`, `components/calendar/`) — 2h
|
||||||
|
- **H14:** try/catch + toast on task mutations (`stores/task-store.ts`) — 1h
|
||||||
|
- **H8:** Derive `secure` cookie from `x-forwarded-proto` (`lib/admin/session.ts`) — 1h
|
||||||
|
- **H3:** Rate-limit user auth endpoints (`app/api/auth/*`) — 3h
|
||||||
|
- **H2:** Await state snapshot in `reconcileAfterWebSocketFallback` (`lib/jmap/client.ts`) — 2h
|
||||||
|
- **H1:** Push handler: add ContactCard/FileNode branches (`stores/email-store.ts`) — 3h
|
||||||
|
|
||||||
|
**P1 Total:** ~32h
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Missing Features (Autonomous, Code Reuse)
|
||||||
|
|
||||||
|
### P2.1 — Extended Signatures (Multiple per Identity + HTML Editor)
|
||||||
|
**Docx:** "Add option to create various email signatures and to select standard signature for new emails and for replies"
|
||||||
|
|
||||||
|
**Reuse from Angular:**
|
||||||
|
- Models: `signature.model.ts` (Signature interface), `identity.model.ts` (zimbraPrefDefaultSignatureId)
|
||||||
|
- API: Signature CRUD endpoints, `modifySignaturePrefs()` pattern
|
||||||
|
- UI logic: Quill editor toolbar config → `react-quill-new` equivalent
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
1. New `stores/signature-store.ts` — Signature[] with CRUD + identity assignment
|
||||||
|
2. New `components/settings/signature-settings.tsx` — list management page
|
||||||
|
3. New `components/settings/signature-editor-modal.tsx` — Quill-based HTML editor
|
||||||
|
4. Extend `components/identity/identity-form.tsx` — default/forward-reply signature picker
|
||||||
|
5. Extend `components/email/email-composer.tsx` — signature selector dropdown in compose
|
||||||
|
6. JMAP/Sieve integration: if the server stores signatures as Sieve or Identity properties, map accordingly
|
||||||
|
|
||||||
|
**Effort:** 12h
|
||||||
|
|
||||||
|
### P2.2 — Create Appointment from Email
|
||||||
|
**Docx:** "create a calendar entry from an email with recipients as participants and email text in description"
|
||||||
|
|
||||||
|
**Reuse from Angular:**
|
||||||
|
- `Appointment` interface from `appoinment.model.ts`
|
||||||
|
- Calendar compose pre-fill logic from `calendar-compose.component.ts`
|
||||||
|
- API payload shapes for event creation
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
1. New `components/email/create-appointment-button.tsx` — button in email toolbar
|
||||||
|
2. Extend `components/calendar/event-modal.tsx` — accept pre-fill props (title=subject, description=body, participants=from+to+cc)
|
||||||
|
3. Wire "Create Appointment" action into email-viewer toolbar + context menu
|
||||||
|
|
||||||
|
**Effort:** 6h
|
||||||
|
|
||||||
|
### P2.3 — Folder Sharing (Mail, Calendar, Contacts, Files)
|
||||||
|
**Docx:** "Share Folder feature" + "Sharing to see all folders shared by me and with me"
|
||||||
|
|
||||||
|
**Reuse from Angular:**
|
||||||
|
- `ShareFolderComponent` (564 lines) — full sharing dialog with email autocomplete, role selection
|
||||||
|
- `PreferencesSharingComponent` (556 lines) — "shared by me" / "shared with me" views
|
||||||
|
- `AllSharingFoldersDialogComponent` — tree-based folder browser
|
||||||
|
- API: share/revoke/accept/decline endpoints and payload shapes
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
1. New `components/sharing/share-folder-dialog.tsx` — modal with email autocomplete, role picker (read/read-write/admin), message
|
||||||
|
2. New `components/sharing/share-folder-revoke-dialog.tsx` — revoke confirmation
|
||||||
|
3. New `components/sharing/accept-share-dialog.tsx` — accept incoming share
|
||||||
|
4. New `components/settings/sharing-settings.tsx` — "Shared by me" / "Shared with me" tabs with folder tree
|
||||||
|
5. Extend folder context menus in email sidebar, calendar sidebar, contacts sidebar, files sidebar with "Share Folder..." action
|
||||||
|
6. New `stores/sharing-store.ts` — tracking shares state
|
||||||
|
7. API routes: `app/api/sharing/*` — share/revoke/accept/decline/find
|
||||||
|
|
||||||
|
**Effort:** 18h
|
||||||
|
|
||||||
|
### P2.4 — Calendar Dashlet (Mini Calendar in Mail View)
|
||||||
|
**Docx:** "Show Calendar in a dashlet in the bottom left corner"
|
||||||
|
|
||||||
|
**Reuse from Angular:**
|
||||||
|
- `sidebar-mini-calendar.component.ts` (701 lines) — month grid, swipe navigation, tooltip
|
||||||
|
- Tooltip directive logic for fetching day events
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
1. New `components/calendar/mini-calendar-dashlet.tsx` — compact month grid widget
|
||||||
|
2. Integrate into mail sidebar or bottom-left overlay in the mail page layout
|
||||||
|
3. Show date dots for days with events, today highlight, click to navigate to calendar
|
||||||
|
4. Optionally: toggleable via user setting "Show calendar dashlet"
|
||||||
|
|
||||||
|
**Effort:** 8h
|
||||||
|
|
||||||
|
### P2.5 — Email Import (.eml, tgz, zip)
|
||||||
|
**Docx:** "Missing: feature to import emails"
|
||||||
|
|
||||||
|
**Reuse from Angular:**
|
||||||
|
- `ImportExportComponent` (513 lines) — import types, destination folder, resolve settings
|
||||||
|
- `preferenceService.importFromFile()` — API request shape
|
||||||
|
- CSV type auto-detection logic
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
1. Extend existing `.eml` import (already partial — see `lib/eml-import.ts` and `components/email/` for `.eml` preview)
|
||||||
|
2. Add ZIP/TGZ/TAR archive import — extract, iterate, import each `.eml`
|
||||||
|
3. New `components/settings/import-settings.tsx` — import UI with file picker, destination folder, conflict resolution
|
||||||
|
4. API route: extend `app/api/account/*` or new `app/api/import/*`
|
||||||
|
|
||||||
|
**Effort:** 10h
|
||||||
|
|
||||||
|
### P2.6 — Contact Import (vCard/CSV)
|
||||||
|
**Docx:** "Missing: feature to import contacts"
|
||||||
|
|
||||||
|
**Reuse from Angular:**
|
||||||
|
- `contact-file-import-dialog.component.ts` (228 lines) — CSV upload, folder selector
|
||||||
|
- `contactService.importContacts()` — API endpoint
|
||||||
|
- vCard parsing: `lib/vcard.ts` already exists in codebase
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
1. Extend existing `components/contacts/contact-import-dialog.tsx` to support CSV (currently vCard-only)
|
||||||
|
2. Add CSV column mapping UI (map CSV columns to contact fields)
|
||||||
|
3. Folder selection for import destination
|
||||||
|
4. Dedup handling
|
||||||
|
|
||||||
|
**Effort:** 6h
|
||||||
|
|
||||||
|
### P2.7 — Free/Busy View
|
||||||
|
**Docx:** "Missing: free/busy view"
|
||||||
|
|
||||||
|
**Reuse from Angular:**
|
||||||
|
- `scheduler.component.ts` (1303 lines) — full free/busy grid
|
||||||
|
- `scheduler-utils.ts` — free-busy status constants (`fba` values, `FBA_TO_PTST` mapping)
|
||||||
|
- `schedule-assistant.component.ts` (1081 lines) — suggestion engine algorithm
|
||||||
|
- Free/busy URL format: `{serverURL}/home/{email}?fmt=freebusy`
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
1. New `components/calendar/free-busy-view.tsx` — attendee-row × time-slot grid
|
||||||
|
2. Color-coded slots: free (white), busy (red), tentative (yellow), out-of-office (purple)
|
||||||
|
3. Integration into event-modal when adding participants — show availability inline
|
||||||
|
4. Also queries `resources_bookings` table (see P2.8) to show resource availability in the same grid
|
||||||
|
5. New `lib/calendar-freebusy.ts` — fetch and parse free/busy data (user calendars + resource bookings)
|
||||||
|
|
||||||
|
**Effort:** 16h (was 14h, added resource booking integration)
|
||||||
|
|
||||||
|
### P2.8 — Resources/Equipment Booking (VNCdirectory-backed PostgreSQL)
|
||||||
|
**Docx:** "Missing: resources/equipment"
|
||||||
|
**Architecture decision:** Resources managed in **separate PostgreSQL table**, mapped to **VNCdirectory** for centralized cross-application management (rooms, cars, equipment, etc.). Independent from mail client — this is a platform-level resource system.
|
||||||
|
|
||||||
|
**Reuse from Angular:**
|
||||||
|
- `calendar-equipment-dialog.component.ts` (373 lines) — equipment browser UI patterns
|
||||||
|
- `calendar-equipment-autocomplete.component.ts` (266 lines) — autocomplete UX
|
||||||
|
- GAL query: `zimbraCalResType === "Equipment"` → adapt to VNCdirectory resource type filter
|
||||||
|
|
||||||
|
**Backend (New):**
|
||||||
|
1. **PostgreSQL migration** — `resources` table:
|
||||||
|
- `id` UUID PK
|
||||||
|
- `tenant_id` UUID (VNCdirectory tenant scope)
|
||||||
|
- `name` text
|
||||||
|
- `type` enum (room, vehicle, equipment, other)
|
||||||
|
- `location` text (building/floor/room)
|
||||||
|
- `capacity` integer nullable
|
||||||
|
- `description` text
|
||||||
|
- `contact_email` text (responsible person)
|
||||||
|
- `is_active` boolean
|
||||||
|
- `metadata` jsonb (extensible: photo URL, amenities, access hours, etc.)
|
||||||
|
- `created_at`, `updated_at` timestamps
|
||||||
|
|
||||||
|
2. **`resources` table → VNCdirectory sync** — VNCdirectory is the canonical source. Options:
|
||||||
|
- **Pull model:** VNCdirectory writes to this table via API/webhook
|
||||||
|
- **Push model:** This service syncs changes back to VNCdirectory
|
||||||
|
- **Read-through:** Query VNCdirectory directly for resource listings; cache in PostgreSQL for availability booking
|
||||||
|
- Decision needed: which direction is authoritative? (Assume VNCdirectory → this table for Phase 1)
|
||||||
|
|
||||||
|
3. **New API routes:** `app/api/resources/`
|
||||||
|
- `GET /api/resources` — list/search resources (type filter, location filter, tenant scoped)
|
||||||
|
- `GET /api/resources/[id]` — single resource detail
|
||||||
|
- `GET /api/resources/[id]/availability?start=&end=` — free/busy for a resource
|
||||||
|
- `POST /api/resources/[id]/book` — create booking for a resource
|
||||||
|
- `DELETE /api/resources/[id]/book/[bookingId]` — cancel booking
|
||||||
|
|
||||||
|
4. **New `lib/resources/` service layer:**
|
||||||
|
- `lib/resources/client.ts` — fetch/query resources from VNCdirectory or local DB
|
||||||
|
- `lib/resources/availability.ts` — check resource availability for time range
|
||||||
|
- `lib/resources/booking.ts` — book/cancel resource
|
||||||
|
- `lib/resources/sync.ts` — sync with VNCdirectory (if push-model needed)
|
||||||
|
|
||||||
|
5. **Conflict checking:**
|
||||||
|
- When booking, check `resources_bookings` table for overlapping time ranges
|
||||||
|
- Return conflicts + alternative slots
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
1. Extend `components/calendar/event-modal.tsx` — "Resources" tab with:
|
||||||
|
- Resource type filter (room, vehicle, equipment)
|
||||||
|
- Searchable autocomplete (name, location, capacity)
|
||||||
|
- Availability indicator (free/busy for event time range)
|
||||||
|
2. New `components/calendar/resource-picker.tsx` — reusable resource selection component
|
||||||
|
3. Booked resources appear in event detail, participant list, and email invitation
|
||||||
|
|
||||||
|
**Effort:** 20h (was 10h, doubled for PostgreSQL + VNCdirectory integration)
|
||||||
|
|
||||||
|
### P2.9 — VNCtalk Video Meeting Integration
|
||||||
|
**Docx:** "Missing: integration with VNCtalk -> create Videomeeting"
|
||||||
|
|
||||||
|
**Reuse from Angular:**
|
||||||
|
- `app.service.ts` → `createNewMeeting()` / `updateScheduledMeeting()` API calls
|
||||||
|
- Payload: `POST /api/createnewmeeting { name, start, end, invitees, password, description, invid?, rev?, ms? }`
|
||||||
|
- `createOrUpdateMeeting()` in `edit-appointment-dialog.component.ts` — builds payload from appointment
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
1. New `lib/vnctalk/client.ts` — VNCtalk API client (create/update meeting)
|
||||||
|
2. Extend `components/calendar/event-modal.tsx` — "Create VNCtalk Meeting" toggle/button
|
||||||
|
3. Store meeting JID on CalendarEvent for updates
|
||||||
|
4. Add meeting link to event detail popover and email invitation body
|
||||||
|
|
||||||
|
**Effort:** 8h
|
||||||
|
|
||||||
|
### P2.10 — Collabora Online Editing
|
||||||
|
**Docx:** "Add feature to collaborate with Collabora"
|
||||||
|
|
||||||
|
**Reuse from Angular:**
|
||||||
|
- `owncloud.service.ts` → `getDocumentUrl(fileId, useCollabora)` with RichDocuments API
|
||||||
|
- `POST ocs/v2.php/apps/richdocuments/api/v1/document?format=json`
|
||||||
|
- Config: `collaboraBaseUrl` from admin config
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
1. New `lib/collabora/client.ts` — fetch editing URL from Collabora server
|
||||||
|
2. New `components/files/collabora-editor.tsx` — iframe-based editor embedding
|
||||||
|
3. Extend `components/files/file-browser.tsx` — "Edit with Collabora" action for office files
|
||||||
|
4. Admin config: `collaboraBaseUrl` in policy/config
|
||||||
|
|
||||||
|
**Effort:** 10h
|
||||||
|
|
||||||
|
### P2.11 — Calendar Enhancements Batch
|
||||||
|
**Docx:** Multiple calendar improvements
|
||||||
|
|
||||||
|
**2.11a — Clickable links in emails**
|
||||||
|
- Already partially done (TipTap Link extension). Verify link rendering in calendar event descriptions.
|
||||||
|
- Effort: 2h
|
||||||
|
|
||||||
|
**2.11b — Contact details of participants**
|
||||||
|
- Add popover on participant names in event-modal showing contact card. Reuse `components/contacts/contact-detail.tsx` data.
|
||||||
|
- Effort: 4h
|
||||||
|
|
||||||
|
**2.11c — Reply / Reply to All in meetings**
|
||||||
|
- Extend event-modal with "Reply" and "Reply to All" buttons that open composer pre-filled with participant emails.
|
||||||
|
- Effort: 3h
|
||||||
|
|
||||||
|
**2.11d — Timezone support**
|
||||||
|
- Add timezone picker to event-modal. Use `date-fns-tz` (already a dependency). Display times in event timezone with user timezone conversion.
|
||||||
|
- Effort: 6h
|
||||||
|
|
||||||
|
**2.11e — Map links**
|
||||||
|
- Extract address from event location, generate Google Maps / OpenStreetMap link.
|
||||||
|
- Effort: 2h
|
||||||
|
|
||||||
|
### P2.12 — Action Wheel (Custom Radial Menu)
|
||||||
|
**Docx:** "Recreate Action Wheel or better functionality"
|
||||||
|
|
||||||
|
**Board decision:** Build custom radial menu.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
1. New `components/ui/radial-menu.tsx` — SVG-based radial menu with configurable items
|
||||||
|
2. Supports: mail actions (reply, forward, delete, archive, mark read, move, tag), contact actions, file actions
|
||||||
|
3. Trigger: long-press on mobile, right-click on desktop, or dedicated button
|
||||||
|
4. Animations: CSS rotate + scale transitions
|
||||||
|
5. Keyboard accessible
|
||||||
|
|
||||||
|
**Effort:** 10h
|
||||||
|
|
||||||
|
### P2.13 — IDP Integration — VNCdirectory Admin Configuration Panel
|
||||||
|
**Docx:** "IDP integration will be towards VNCdirectory (openldap, simplesamlphp, 2fa etc.)"
|
||||||
|
**Architecture decision:** Add admin UI for configuring VNCdirectory connection settings. Reuse auth patterns from VNCmail-analysis `api/auth-proxy/`.
|
||||||
|
|
||||||
|
**Reuse from Angular (api/auth-proxy/):**
|
||||||
|
- `config/config.js.example` — full auth configuration schema (SAML, LDAP, VNCdirectory, 2FA, hybrid auth)
|
||||||
|
- `config/passport.js` — SAML strategy + JWT custom strategy setup
|
||||||
|
- `routes/index.js` — login/logout/SAML callback/LDAP search/2FA/TOTP routes
|
||||||
|
- `utils/common.js` — JWT verification, Zimbra preauth token creation
|
||||||
|
- Auth dependencies: `@node-saml/passport-saml`, `passport`, `jsonwebtoken`, `ldapjs`
|
||||||
|
|
||||||
|
**Current VNCmail+ auth stack vs Angular auth stack:**
|
||||||
|
| Feature | Angular (Zimbra) | VNCmail+ (Stalwart) |
|
||||||
|
|---------|-----------------|---------------------|
|
||||||
|
| Primary auth | SAML 2.0 via Passport | OAuth/OIDC via Stalwart |
|
||||||
|
| Identity source | Zimbra LDAP + VNCdirectory | Stalwart internal + OIDC |
|
||||||
|
| 2FA | VNCdirectory TOTP/DUO | TOTP via Stalwart admin API |
|
||||||
|
| Directory integration | Redmine API (`contactsApiUrl`) | ❌ None |
|
||||||
|
| LDAP backend | `ldapjs` → Zimbra LDAP | ❌ None |
|
||||||
|
| SSO/Federation | JWT deeplinks + SAML | Cookie-based + OAuth |
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
1. **New `lib/vncdirectory/` service layer:**
|
||||||
|
- `lib/vncdirectory/client.ts` — API client for VNCdirectory REST endpoints (port auth patterns from `routes/index.js`)
|
||||||
|
- `lib/vncdirectory/config.ts` — VNCdirectory connection settings (URL, API key, LDAP bind, SAML IDP metadata)
|
||||||
|
- `lib/vncdirectory/auth.ts` — SAML 2.0 SP implementation using `@node-saml/passport-saml` via Next.js API routes
|
||||||
|
- `lib/vncdirectory/ldap.ts` — LDAP client using `ldapjs` for user/group/GAL queries
|
||||||
|
- `lib/vncdirectory/2fa.ts` — TOTP enrollment + verification via VNCdirectory
|
||||||
|
|
||||||
|
2. **New API routes:** `app/api/vncdirectory/`
|
||||||
|
- `GET /api/vncdirectory/status` — connection health check
|
||||||
|
- `POST /api/vncdirectory/saml/login` — initiate SAML login flow
|
||||||
|
- `POST /api/vncdirectory/saml/callback` — SAML assertion consumer
|
||||||
|
- `POST /api/vncdirectory/saml/logout` — SAML single logout
|
||||||
|
- `GET /api/vncdirectory/users` — search users (LDAP + VNCdirectory)
|
||||||
|
- `POST /api/vncdirectory/2fa/enroll` — generate TOTP secret
|
||||||
|
- `POST /api/vncdirectory/2fa/verify` — verify TOTP code
|
||||||
|
- `GET /api/vncdirectory/2fa/status` — check 2FA enrollment status
|
||||||
|
- `GET /api/vncdirectory/tags` — directory contact tags
|
||||||
|
- `POST /api/vncdirectory/tags` — create/update directory tags
|
||||||
|
|
||||||
|
3. **New admin configuration page:**
|
||||||
|
- `app/(main)/admin/vncdirectory/page.tsx` — VNCdirectory settings panel
|
||||||
|
- Sections:
|
||||||
|
- **Connection:** VNCdirectory URL, API key, LDAP URI, bind credentials
|
||||||
|
- **SAML/IDP:** Identity Provider URL (SimpleSAMLphp), SP certificate, issuer
|
||||||
|
- **Authentication:** Toggle SAML login, toggle 2FA enforcement, OIDC settings
|
||||||
|
- **Directory sync:** LDAP type (OpenLDAP/MS-AD), search base, attribute mapping
|
||||||
|
- **Federated apps:** Configure SSO URLs for VNCtalk, VNCtask, VNCcontacts
|
||||||
|
- Add to admin navigation sidebar
|
||||||
|
|
||||||
|
4. **New `stores/vncdirectory-store.ts`** — client-side config state
|
||||||
|
|
||||||
|
5. **Extend existing auth:**
|
||||||
|
- Add SAML login as alternative to existing Basic/OAuth flows
|
||||||
|
- Add VNCdirectory as identity source alongside Stalwart
|
||||||
|
- Wire 2FA through VNCdirectory (currently uses Stalwart admin API)
|
||||||
|
|
||||||
|
**Effort:** 24h
|
||||||
|
|
||||||
|
### P2.14 — Share Files by Email as Attachment
|
||||||
|
**Docx:** "share by email as attachment"
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
1. Extend `components/files/file-browser.tsx` — "Send as Email Attachment" action
|
||||||
|
2. Opens composer with selected files attached (reuse existing attachment upload in composer)
|
||||||
|
3. Effort: 4h
|
||||||
|
|
||||||
|
**P2 Total:** ~127h
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Security Hardening (Autonomous)
|
||||||
|
|
||||||
|
### P3.1 — Feature Gate Server-Side Enforcement
|
||||||
|
**Issue:** Feature gates are UI-only. Disabled features remain accessible via direct API calls.
|
||||||
|
|
||||||
|
**Fix:** Add policy checks to API routes. For each feature-gated route, add `isFeatureEnabled()` check returning 403.
|
||||||
|
- Routes: `app/api/smime/*`, `app/api/calendar-agenda/*`, `app/api/offline/*`, `app/api/plugins/*` (plugin disabled)
|
||||||
|
- Effort: 4h
|
||||||
|
|
||||||
|
### P3.2 — Unified Auth Error Interceptor
|
||||||
|
**Issue:** 401/403 errors silently swallowed in data fetches.
|
||||||
|
|
||||||
|
**Fix:** Create `lib/auth-error-handler.ts` — global fetch wrapper that detects 401 and triggers re-auth flow. Wire into JMAP client `authenticatedFetch`.
|
||||||
|
- Effort: 6h
|
||||||
|
|
||||||
|
### P3.3 — Store-Level State Isolation on Account Switch
|
||||||
|
**Issue:** Manual `clearAllStores()` misses new fields and stores.
|
||||||
|
|
||||||
|
**Fix:** Define per-store `snapshot(): Partial<S>` and `clear(): Partial<S>` contract. Auto-discover registered stores via a registry.
|
||||||
|
- Effort: 8h
|
||||||
|
|
||||||
|
### P3.4 — Push Event Bus Extraction
|
||||||
|
**Issue:** Email store is the push dispatch hub for 5+ stores.
|
||||||
|
|
||||||
|
**Fix:** Extract `lib/push-event-bus.ts` — stores subscribe to JMAP type names. Email store stops importing calendar/filter/task stores.
|
||||||
|
- Effort: 8h
|
||||||
|
|
||||||
|
**P3 Total:** ~26h
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Polish & Remaining HIGH Items
|
||||||
|
|
||||||
|
### P4.1 — Offline Write Queue
|
||||||
|
**Issue:** No offline write capability. Cannot compose/send while offline.
|
||||||
|
|
||||||
|
**Fix:** Add `replica_pending_ops` table. Stage mutations offline, replay on connectivity return. Start with email send only, then extend.
|
||||||
|
- Effort: 16h
|
||||||
|
|
||||||
|
### P4.2 — Identity Spoofing Protection
|
||||||
|
**Issue:** From override accepts arbitrary addresses.
|
||||||
|
|
||||||
|
**Fix:** Client-side validation — restrict `fromOverrideEmail` to domains matching user's identities.
|
||||||
|
- Effort: 2h
|
||||||
|
|
||||||
|
### P4.3 — WebSocket Push for Electron
|
||||||
|
**Issue:** Browser WebSocket push permanently disabled.
|
||||||
|
|
||||||
|
**Fix:** Implement main-process WebSocket bridge in Electron via IPC. Renderer sends token, main process connects WS with auth header.
|
||||||
|
- Effort: 8h
|
||||||
|
|
||||||
|
### P4.4 — Remaining MEDIUM audit findings
|
||||||
|
- Calendar: recurrence cap warning, prefix scheme unification, read-only calendar filter, bulk delete batching
|
||||||
|
- Contacts: cross-account move race, autocomplete indexing, import dedup
|
||||||
|
- Files: folder tree cache reuse, upload parallelism
|
||||||
|
- Search: `toWildcardQuery` quote handling, `searchEmails` AbortController
|
||||||
|
- Effort: ~20h
|
||||||
|
|
||||||
|
**P4 Total:** ~46h
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Phase | Hours | Description |
|
||||||
|
|-------|-------|-------------|
|
||||||
|
| P1 | 32h | Critical + HIGH bug fixes (18 items) |
|
||||||
|
| P2 | 167h | Missing features from docx (14 features) |
|
||||||
|
| P3 | 26h | Security hardening (4 items) |
|
||||||
|
| P4 | 46h | Polish + remaining fixes (4 items) |
|
||||||
|
| **Total** | **~271h** | |
|
||||||
|
|
||||||
|
### New Infrastructure Dependencies (P2)
|
||||||
|
- **PostgreSQL database** — `resources` + `resources_bookings` tables for VNCdirectory-backed resource management
|
||||||
|
- **VNCdirectory** — canonical source for resources + IDP identity provider (SAML 2.0, LDAP, 2FA/TOTP, directory tags)
|
||||||
|
- **SimpleSAMLphp** — SAML 2.0 Identity Provider (`vncidp.dev.vnc.de`) for web SSO
|
||||||
|
- **OpenLDAP** — LDAP directory for user/group queries and GAL (via `ldapjs`)
|
||||||
|
- **Collabora server** — `collaboraBaseUrl` admin config for online document editing
|
||||||
|
- **VNCtalk API** — `/api/createnewmeeting` endpoint for video meeting integration
|
||||||
|
|
||||||
|
### Autonomy Level
|
||||||
|
- **100% autonomous** — No further board decisions needed.
|
||||||
|
- **Sync direction (VNCdirectory ↔ PostgreSQL)**: Assumed VNCdirectory → PostgreSQL (pull) for Phase 1. Can be swapped if VNCdirectory expects push updates.
|
||||||
|
- **Code reuse:** 12 areas from VNCmail-analysis Angular codebase (models, API patterns, business logic). Must be adapted from Angular DI/services to plain TS functions + React hooks.
|
||||||
|
- **Repository access:** `brvncde-dotcom/vncmail-plus` (target), `brvncde-dotcom/VNCmail-analysis` (reuse).
|
||||||
|
|
||||||
|
### Deploy Flow
|
||||||
|
Per policy: P1 → deploy to `dev` → QA → fix → promote to `main`. Then P2 → dev → QA → main. Repeat for P3, P4.
|
||||||
|
|
||||||
|
### First Sprint Scope
|
||||||
|
**Phase 1 only** — ship all 8 CRITICAL + 10 HIGH fixes (~32h). This brings health from 7.2 to ~8.5/10 and addresses the most impactful user-facing bugs before adding new features.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Do you want me to start Phase 1 immediately, or adjust the plan?
|
||||||
@@ -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`).
|
||||||
@@ -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: <feature / substrate / type>
|
||||||
|
- Location: path/file:line
|
||||||
|
- Evidence: short quoted snippet or precise description
|
||||||
|
- Impact: concrete failure scenario (inputs → wrong behavior); cross-feature if applicable
|
||||||
|
- Recommendation: correct approach
|
||||||
|
- Confidence: measured | reasoned-not-measured
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Codebase Anchors
|
||||||
|
|
||||||
|
- **No secrets in reports** (§B.0.2) — redact all keys, tokens, secrets
|
||||||
|
- **`rg` landmine** — use `grep -REn` / Read, never `rg` (mangles `electron`→`n`)
|
||||||
|
- **Cursor provenance** (`lib/offline-replica/states.ts`) — branded `ChangesState` / `SnapshotState`
|
||||||
|
- **Deploy policy** — dev first, never prod-direct
|
||||||
|
- **AI-cost rule** — ≥30% above baseline → report immediately
|
||||||
|
- **Security anchors** — DOMPurify sanitization, CSP with per-request nonce, SSRF guard, contextIsolation/sandbox for Electron, plugin bundle integrity + signing
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
# Wave 0 — Feature Inventory
|
||||||
|
|
||||||
|
**Run:** 2026-08-07-v1.7.8-baseline
|
||||||
|
**Skill:** VNCmailgraph (adapted for Next.js/Zustand)
|
||||||
|
**Commit:** d8bebb531f86cab3507aed2113e8d0e6a03c1aa8
|
||||||
|
**Version:** vnc-v0.3.0-94-gd8bebb53 (VERSION=1.7.8)
|
||||||
|
**Codebase:** ~188K LOC across 745 TS/TSX files (excluding node_modules, .git)
|
||||||
|
**Framework:** Next.js 16 (App Router) + React 19 + Zustand 5
|
||||||
|
**Backend:** Stalwart Mail Server (JMAP protocol)
|
||||||
|
**Targets:** Web (PWA), Electron Desktop, Native (planned via Capacitor/RN)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Method Adaptation
|
||||||
|
|
||||||
|
The original VNCmailgraph methodology assumes Angular + NgRx + `isCordova`/`isElectron` gates. This codebase uses:
|
||||||
|
- **Next.js 16 App Router** (not Angular) — routes defined via file-system routing
|
||||||
|
- **Zustand** (not NgRx) — stores in `stores/*.ts`, accessed via hooks
|
||||||
|
- **`isElectronShell()`** (not `isCordova`/`isElectron`) — platform detection via `window.vnc`
|
||||||
|
- **No `zimbra-features.ts`** — feature flags are in `lib/admin/types.ts` (`FeatureGates`, `SettingsPolicy`)
|
||||||
|
- **JMAP protocol** (not Zimbra SOAP) — `lib/jmap/client.ts` (7446 lines)
|
||||||
|
|
||||||
|
The audit method translates cleanly: feature domains = page routes + component trees; shared substrate = stores + lib services; coupling = cross-store imports.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Domains (derived from code)
|
||||||
|
|
||||||
|
### F1 — Mail / Email (CORE)
|
||||||
|
- **Routes:** `/app/(main)/[locale]/page.tsx` (3758 lines — main mail client)
|
||||||
|
- **Components:** `components/email/` (24 files): email-list, email-viewer (5102 lines), email-composer (3432 lines), thread-conversation-view, rich-text-editor (TipTap), tag-picker, email-hover-actions, recipient-popover, calendar-invitation-banner, unsubscribe-banner, read-receipt-banner, message-list-tabs
|
||||||
|
- **Store:** `stores/email-store.ts` (4087 lines) — largest store, manages emails, mailboxes, threads, search, tags, push connection, cross-account views
|
||||||
|
- **Protocol:** `lib/jmap/client.ts` (7446 lines) — the JMAP client powering all mail operations
|
||||||
|
- **Sub-features:** Compose/drafts, threading, unified mailbox, cross-account views, search, tags/keywords, attachments, scheduled send, read receipts (MDN), archive modes, virtual scrolling, TNEF extraction, .eml import, print
|
||||||
|
|
||||||
|
### F2 — Calendar
|
||||||
|
- **Routes:** `/app/(main)/[locale]/calendar/page.tsx`
|
||||||
|
- **Components:** `components/calendar/` (22 files): month/week/day/agenda views, event-modal (1375 lines), mini-calendar, task-modal, recurrence-editor, ical-import/subscription modals, participant-input
|
||||||
|
- **Store:** `stores/calendar-store.ts` (1289 lines) — calendars, events, multi-account aggregation, recurrence expansion, iMIP, CalDAV
|
||||||
|
- **Sub-features:** Month/week/day/agenda views, drag-to-reschedule, recurring events, iMIP invitations, .ics import, birthday calendar, virtual locations, tasks, CalDAV shared calendars
|
||||||
|
|
||||||
|
### F3 — Contacts
|
||||||
|
- **Routes:** `/app/(main)/[locale]/contacts/page.tsx`
|
||||||
|
- **Components:** `components/contacts/` (17 files): contact-list, contact-detail, contact-form, import-dialog, groups
|
||||||
|
- **Store:** `stores/contact-store.ts` (1146 lines) — contact cards, address books, multi-account, vCard import/export
|
||||||
|
- **Sub-features:** JMAP sync (RFC 9553/9610), address books, groups, vCard import/export, trusted senders, autocomplete
|
||||||
|
|
||||||
|
### F4 — Files / Briefcase
|
||||||
|
- **Routes:** `/app/(main)/[locale]/files/page.tsx`
|
||||||
|
- **Components:** `components/files/` (10 files): file-browser (2022 lines), folder-tree, upload-area, preview-modal
|
||||||
|
- **Stores:** `stores/file-store.ts` (1050 lines) + `stores/webdav-store.ts` (480 lines) — dual storage backends
|
||||||
|
- **Sub-features:** JMAP FileNode browsing, WebDAV upload with progress, grid/list views, preview, cut/copy/paste, favorites, sharing (RFC 9670)
|
||||||
|
|
||||||
|
### F5 — Tasks
|
||||||
|
- **Store:** `stores/task-store.ts` (95 lines) — VTODO CRUD, filter by pending/completed/overdue
|
||||||
|
- **Components:** `components/calendar/task-modal.tsx`, `task-list-view.tsx`, `task-toolbar.tsx`
|
||||||
|
- **Feature gate:** `calendarTasksEnabled` in admin policy
|
||||||
|
|
||||||
|
### F6 — Settings / Preferences
|
||||||
|
- **Routes:** `/app/(main)/[locale]/settings/page.tsx`
|
||||||
|
- **Components:** `components/settings/` (33 files) — account, appearance, themes, layout, reading, composing, notification, folder, filter, vacation, identity, template, keyword, calendar, contacts, files, downloads, sidebar-apps, plugins, language, protocol-handler, AI, debug, about
|
||||||
|
- **Store:** `stores/settings-store.ts` (1221 lines) — all user settings with cross-device encrypted sync
|
||||||
|
- **Sub-features:** Theme/density/font, layout preferences, reply/signature behavior, notification preferences, mailto:/webcal: protocol handling, settings sync (AES-256-GCM)
|
||||||
|
|
||||||
|
### F7 — Filters / Sieve
|
||||||
|
- **Components:** `components/filters/` (2 files): filter-rule-modal (534 lines), sieve-editor-modal
|
||||||
|
- **Store:** `stores/filter-store.ts` (254 lines) — Sieve script management
|
||||||
|
- **Lib:** `lib/sieve/` (parser + generator) — round-trip for external scripts
|
||||||
|
- **Sub-features:** Visual rule builder, raw Sieve editor, vacation responder, opaque script preservation
|
||||||
|
|
||||||
|
### F8 — Templates
|
||||||
|
- **Components:** `components/templates/` (4 files): template-picker, template-form, manager-modal, placeholder-fill
|
||||||
|
- **Store:** `stores/template-store.ts` (153 lines) — CRUD, favorites, import/export
|
||||||
|
- **Feature gate:** `templatesEnabled`
|
||||||
|
|
||||||
|
### F9 — Identity / Aliases
|
||||||
|
- **Components:** `components/identity/` (3 files): identity-manager-modal (407 lines), identity-form, sub-address-helper
|
||||||
|
- **Store:** `stores/identity-store.ts` (140 lines) — sender identities, sub-addressing
|
||||||
|
- **Sub-features:** Multiple sender identities per account, sub-addressing (`user+tag@domain`)
|
||||||
|
|
||||||
|
### F10 — AI Assistant
|
||||||
|
- **Components:** `components/ai/ai-ask-button.tsx` (264 lines)
|
||||||
|
- **Lib:** `lib/ai/` (9 files + retrieval/): local-client, local-discovery, opencode, entitlement, key-store, retrieval/fusion
|
||||||
|
- **API:** `app/api/ai/` (server/chat, server/models, retrieve, opencode/chat, opencode/models, policy)
|
||||||
|
- **Sub-features:** Local LLM (Ollama), server-hosted AI, BYOK, OpenCode runtime, mail retrieval with RRF fusion, FTS5 search index
|
||||||
|
- **Feature gate:** `aiAssistantEnabled` (default=true)
|
||||||
|
|
||||||
|
### F11 — Admin / Management
|
||||||
|
- **Routes:** 14 admin pages (dashboard, settings, branding, auth, password, policy, plugins, themes, marketplace, version, telemetry, logs)
|
||||||
|
- **Lib:** `lib/admin/` (18 files): config-manager, session, password, audit, plugin-registry, plugin-approvals, plugin-signing, bundled-plugins, domain-branding, CSP
|
||||||
|
- **Store:** `stores/admin-tab-store.ts` (42 lines)
|
||||||
|
|
||||||
|
### F12 — Plugin System
|
||||||
|
- **Components:** `components/plugins/` (7 files): plugin-slot, iframe-slot, dialog-host, consent-dialog, error-boundary
|
||||||
|
- **Store:** `stores/plugin-store.ts` (596 lines) — lifecycle management
|
||||||
|
- **Lib:** `lib/plugin-sandbox/` (13 files): runtime, host-bridge, host-api, loader, tier, protocol, registry, bundle-integrity, bundle-signing, consent, shortcuts
|
||||||
|
- **Sub-features:** Sandboxed iframe execution, postMessage RPC, tiered permissions (untrusted/privileged), admin approval gates, marketplace, dev-mode loading
|
||||||
|
|
||||||
|
### F13 — Pro Multi-Tab Shell
|
||||||
|
- **Routes:** `/app/(main)/[locale]/pro/page.tsx`
|
||||||
|
- **Components:** `components/pro/` (4 files): tab-bar, email-tab-body, compose-tab-body, interface-redirect
|
||||||
|
- **Store:** `stores/pro-tab-store.ts` (576 lines) — multi-tab management, split pane layout
|
||||||
|
|
||||||
|
### F14 — Search
|
||||||
|
- **Components:** `components/search/` (2 files): search-chips, advanced-search-panel
|
||||||
|
- **Backend:** `lib/mail-index/` (8 files) — encrypted SQLite/FTS5 index (Electron-only)
|
||||||
|
- **API:** `app/api/offline/search`, `app/api/ai/retrieve`
|
||||||
|
- **Coupling:** Heavily coupled with email-store (search state lives there)
|
||||||
|
|
||||||
|
### F15 — Authentication
|
||||||
|
- **Lib:** `lib/auth/` (7 files): crypto, session-cookie, session-secret, verify-jmap-auth, pair-reauth, pairing-store, active-account-slot
|
||||||
|
- **Store:** `stores/auth-store.ts` (2033 lines) — THE hub; manages login, logout, token refresh, multi-account sessions
|
||||||
|
- **Sub-features:** Basic auth, OAuth/OIDC with PKCE, demo mode, device pairing, TOTP 2FA, impersonation (JWT)
|
||||||
|
- **API:** `app/api/auth/` (10 routes)
|
||||||
|
|
||||||
|
### F16 — Setup Wizard
|
||||||
|
- **Routes:** `/app/(main)/setup/*`
|
||||||
|
- **Lib:** `lib/setup/` (3 files): session, state, token
|
||||||
|
- **API:** `app/api/setup/` (6 routes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared Substrate
|
||||||
|
|
||||||
|
### S1 — State Management (Zustand stores)
|
||||||
|
- 28 store files in `stores/` — Zustand with `persist` middleware
|
||||||
|
- **Central hub:** `auth-store.ts` imports 7 other stores; bootstraps all feature stores after login
|
||||||
|
- **Bidirectional cycle:** `email-store` ↔ `message-list-tabs-store`
|
||||||
|
- **Anti-cycle pattern:** `client-registry.ts` — utility indirection to avoid auth-store cycles
|
||||||
|
- **Clean leaves:** `locale-store`, `policy-store`, `toast-store`, `ui-store` (0 dependencies)
|
||||||
|
|
||||||
|
### S2 — JMAP Protocol Layer
|
||||||
|
- `lib/jmap/client.ts` (7446 lines) — the communication backbone for ALL features
|
||||||
|
- `lib/jmap/types.ts` (938 lines) — shared type definitions
|
||||||
|
- `lib/jmap/client-interface.ts` (358 lines) — `IJMAPClient` interface (real + demo implementations)
|
||||||
|
- `lib/jmap/transport-health.ts` — failure counter for offline fallback gating
|
||||||
|
- `lib/jmap/request-limits.ts` — request batching utilities
|
||||||
|
- `lib/jmap/search-utils.ts` — search query building
|
||||||
|
|
||||||
|
### S3 — Auth / Session / Multi-Account
|
||||||
|
- `lib/auth/*` — AES-256-GCM cookie encryption, session lifecycle, device pairing
|
||||||
|
- `lib/oauth/*` — OAuth2/OIDC discovery, PKCE, token exchange
|
||||||
|
- `lib/impersonation/*` — JWT platform auth
|
||||||
|
- `lib/stalwart/*` — server-side Stalwart auth context, JMAP passthrough
|
||||||
|
- `stores/account-store.ts` — multi-account registry
|
||||||
|
- `stores/account-security-store.ts` — TOTP 2FA, app passwords, API keys
|
||||||
|
|
||||||
|
### S4 — Offline Replica (encrypted delta-sync mail store)
|
||||||
|
- `lib/offline-replica/` (13 files): engine, sync (1122 lines), store (997 lines), apply, jmap, states, read, retention, errors, schema, types
|
||||||
|
- SQLCipher-encrypted SQLite; delta sync via JMAP `/changes`
|
||||||
|
- Two-tier storage (envelope + body); cursor provenance with branded types
|
||||||
|
- **Fallback only** — consulted after live JMAP read fails; online session never sees replica data
|
||||||
|
|
||||||
|
### S5 — Local Search Index (encrypted FTS5)
|
||||||
|
- `lib/mail-index/` (8 files): store (534 lines), jmap (388 lines), extract (311 lines), reindex (385 lines), key (203 lines), paths, binding
|
||||||
|
- SQLCipher-encrypted SQLite with FTS5, **Electron/desktop only**
|
||||||
|
- Event-driven (triggered by JMAP push changes via API routes)
|
||||||
|
- Key transport uses inherited fd (pipe), not env var
|
||||||
|
|
||||||
|
### S6 — Platform Bridge
|
||||||
|
- `lib/electron-bridge.ts` (55 lines) — `isElectronShell()`, `showElectronNotification()`
|
||||||
|
- `lib/platform-capabilities.ts` (29 lines) — single source of truth for platform feature availability
|
||||||
|
- `lib/web-push.ts` — web push for PWA
|
||||||
|
- `electron/main.ts` (424 lines) — Electron main process (spawns Next.js server)
|
||||||
|
- `electron/preload.ts` (27 lines) — contextBridge, nodeIntegration=false, sandbox=true
|
||||||
|
|
||||||
|
### S7 — Plugin Sandbox
|
||||||
|
- `lib/plugin-sandbox/` (13 files) — iframe isolation, postMessage RPC, tier gating
|
||||||
|
- `lib/plugin-types.ts` (1092 lines) — massive type definitions
|
||||||
|
- `lib/plugin-hooks.ts` — hook registration for email/calendar/composer/sidebar slots
|
||||||
|
- `lib/plugin-loader.ts`, `lib/plugin-validator.ts`, `lib/plugin-storage.ts`
|
||||||
|
|
||||||
|
### S8 — HTTP Proxy / API Gateway
|
||||||
|
- `proxy.ts` (211 lines) — Next.js middleware: CSP, nonce, setup-state gating, intl routing
|
||||||
|
- `app/api/*` — 24 API endpoint groups (auth, admin, AI, offline, setup, etc.)
|
||||||
|
|
||||||
|
### S9 — Security / Sanitization
|
||||||
|
- `lib/security/url-guard.ts` (67 lines) — SSRF prevention, loopback/private-IP blocking
|
||||||
|
- `lib/email-sanitization.ts` — DOMPurify HTML sanitization
|
||||||
|
- `lib/smime-ca/` (5 files) — S/MIME certificate authority (EJBCA + local dev CA)
|
||||||
|
|
||||||
|
### S10 — Theme System
|
||||||
|
- `stores/theme-store.ts` (593 lines) — light/dark/system, custom theme installation
|
||||||
|
- `lib/theme-compiler.ts`, `lib/theme-loader.ts`, `lib/theme-logo.ts`
|
||||||
|
- `lib/builtin-themes.ts` — 8 built-in themes (2 shipping, 6 hidden)
|
||||||
|
- `lib/color-transform.ts` — luminance-based color remapping for dark mode
|
||||||
|
|
||||||
|
### S11 — Internationalization
|
||||||
|
- `stores/locale-store.ts` (19 lines)
|
||||||
|
- `i18n/routing.ts` — next-intl configuration
|
||||||
|
- `locales/` — 24 language translations
|
||||||
|
- `lib/jalali-utils.ts` — Persian calendar support
|
||||||
|
|
||||||
|
### S12 — Cross-Device Settings Sync
|
||||||
|
- `lib/settings-sync.ts` — encrypted settings sync (AES-256-GCM)
|
||||||
|
|
||||||
|
### S13 — Telemetry
|
||||||
|
- `lib/telemetry/` (7 files) — anonymous heartbeat, opt-in, HMAC-hashed logins
|
||||||
|
|
||||||
|
### S14 — Version Check
|
||||||
|
- `lib/version-check/` (5 files) — polls version server, daily jittered schedule
|
||||||
|
- `stores/update-store.ts` (118 lines)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Flag Registry (from lib/admin/types.ts)
|
||||||
|
|
||||||
|
| Flag | Default | Description |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| `pluginsEnabled` | false | Plugin system on/off |
|
||||||
|
| `pluginsUploadEnabled` | true | Allow manual plugin upload |
|
||||||
|
| `requirePluginApproval` | true | Admin must approve plugins |
|
||||||
|
| `themesEnabled` | true | Theme system on/off |
|
||||||
|
| `sidebarAppsEnabled` | true | Sidebar app visibility/order |
|
||||||
|
| `userThemesEnabled` | true | Users can install custom themes |
|
||||||
|
| `settingsExportEnabled` | true | Settings export |
|
||||||
|
| `customKeywordsEnabled` | true | Custom keyword/tag creation |
|
||||||
|
| `templatesEnabled` | true | Email templates |
|
||||||
|
| `calendarEnabled` | true | Calendar feature |
|
||||||
|
| `calendarTasksEnabled` | true | Calendar tasks |
|
||||||
|
| `smimeEnabled` | true | S/MIME |
|
||||||
|
| `externalContentEnabled` | true | External content in emails |
|
||||||
|
| `debugModeEnabled` | true | Debug tools |
|
||||||
|
| `folderIconsEnabled` | true | Custom folder icons |
|
||||||
|
| `hoverActionsConfigEnabled` | true | Hover action configuration |
|
||||||
|
| `filesEnabled` | true | File browser |
|
||||||
|
| `contactsEnabled` | true | Contacts |
|
||||||
|
| `crossAllViewEnabled` | false | Cross-account All Mail |
|
||||||
|
| `crossUnreadViewEnabled` | false | Cross-account Unread |
|
||||||
|
| `crossStarredViewEnabled` | false | Cross-account Starred |
|
||||||
|
| `unifiedCrossAccountEnabled` | false | Cross-account unified inbox |
|
||||||
|
| `aiAssistantEnabled` | true | AI assistant (visible by default — local ships free) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependency / Coupling DAG (Store Layer)
|
||||||
|
|
||||||
|
```
|
||||||
|
auth-store (HUB) ──→ identity-store
|
||||||
|
├──→ account-store
|
||||||
|
├──→ calendar-store
|
||||||
|
├──→ contact-store
|
||||||
|
├──→ filter-store
|
||||||
|
├──→ settings-store
|
||||||
|
└──→ vacation-store
|
||||||
|
|
||||||
|
email-store ──→ calendar-store
|
||||||
|
├──→ auth-store
|
||||||
|
├──→ account-store
|
||||||
|
├──→ settings-store
|
||||||
|
└──→ message-list-tabs-store ←── (BIDIRECTIONAL CYCLE)
|
||||||
|
|
||||||
|
settings-store ──→ theme-store
|
||||||
|
└──→ locale-store
|
||||||
|
|
||||||
|
plugin-store ──→ locale-store
|
||||||
|
└──→ policy-store
|
||||||
|
|
||||||
|
theme-store ──→ policy-store
|
||||||
|
|
||||||
|
account-security-store ──→ auth-store
|
||||||
|
|
||||||
|
Standalone (14): file-store, task-store, filter-store, identity-store,
|
||||||
|
account-store, pro-tab-store, ui-store, vacation-store, totp-reauth-store,
|
||||||
|
calendar-notification-store, managed-account-store, update-store,
|
||||||
|
toast-store, admin-tab-store, template-store, webdav-store
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical coupling paths to audit in Wave 2:**
|
||||||
|
1. `auth-store` → bootstraps 7 feature stores on login → any bootstrap failure cascades
|
||||||
|
2. `email-store` ↔ `message-list-tabs-store` — bidirectional cycle
|
||||||
|
3. `email-store` → `calendar-store` — cross-feature coupling (invitation banners, birthday calendar)
|
||||||
|
4. `settings-store` → `theme-store` → theme injection affects ALL components
|
||||||
|
5. JMAP client (`lib/jmap/client.ts`) → used by ALL feature stores via `client-registry`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Platform Parity Surface
|
||||||
|
|
||||||
|
| Feature | Web (PWA) | Electron Desktop | Native (planned) |
|
||||||
|
|---------|-----------|------------------|------------------|
|
||||||
|
| Mail | Full | Full | Full (planned) |
|
||||||
|
| Calendar | Full | Full | Full (planned) |
|
||||||
|
| Contacts | Full | Full | Full (planned) |
|
||||||
|
| Files | Full | Full | Full (planned) |
|
||||||
|
| Local Search Index | ❌ | SQLCipher FTS5 | ❌ (planned) |
|
||||||
|
| Offline Replica | ❌ | SQLCipher SQLite | ❌ (planned) |
|
||||||
|
| Native Notifications | Web Push | Electron Notification | Native (planned) |
|
||||||
|
| AI Local LLM | Browser CORS | No CORS (loopback) | Native (planned) |
|
||||||
|
| S/MIME | Full | Full | Full (planned) |
|
||||||
@@ -387,7 +387,16 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
set({ events: results.flat(), isLoadingEvents: false, dateRange: { start, end } });
|
const allEvents = results.flat();
|
||||||
|
// Deduplicate cross-account events by uid + recurrenceId
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const deduped = allEvents.filter(e => {
|
||||||
|
const key = `${e.uid || e.id}::${e.recurrenceId || ''}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
set({ events: deduped, isLoadingEvents: false, dateRange: { start, end } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('Failed to fetch all-account events:', error);
|
debug.error('Failed to fetch all-account events:', error);
|
||||||
set({ error: 'Failed to load events', isLoadingEvents: false });
|
set({ error: 'Failed to load events', isLoadingEvents: false });
|
||||||
|
|||||||
@@ -2940,6 +2940,25 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle ContactCard state changes - refresh contacts
|
||||||
|
if (accountChanges?.ContactCard) {
|
||||||
|
const { useContactStore } = await import('./contact-store');
|
||||||
|
const contactStore = useContactStore.getState();
|
||||||
|
contactStore.fetchContacts(client).catch((err) => {
|
||||||
|
console.error('Failed to refresh contacts on push:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle FileNode state changes - refresh current directory
|
||||||
|
if (accountChanges?.FileNode) {
|
||||||
|
const { useFileStore } = await import('./file-store');
|
||||||
|
const fileStore = useFileStore.getState();
|
||||||
|
const currentParentId = fileStore.currentParentId;
|
||||||
|
fileStore.navigate(currentParentId).catch((err) => {
|
||||||
|
console.error('Failed to refresh files on push:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Local search index last, with the refreshed ids (see above).
|
// Local search index last, with the refreshed ids (see above).
|
||||||
scheduleIndexUpdate();
|
scheduleIndexUpdate();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { create } from 'zustand';
|
|||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import { useThemeStore } from './theme-store';
|
import { useThemeStore } from './theme-store';
|
||||||
import { useLocaleStore } from './locale-store';
|
import { useLocaleStore } from './locale-store';
|
||||||
|
import { usePolicyStore } from './policy-store';
|
||||||
import type { NotificationSoundChoice } from '@/lib/notification-sound';
|
import type { NotificationSoundChoice } from '@/lib/notification-sound';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
import {
|
import {
|
||||||
@@ -484,7 +485,7 @@ const DEFAULT_SETTINGS = {
|
|||||||
autoSaveDraftInterval: 60000, // 1 minute
|
autoSaveDraftInterval: 60000, // 1 minute
|
||||||
sendConfirmation: false,
|
sendConfirmation: false,
|
||||||
defaultReplyMode: 'reply' as ReplyMode,
|
defaultReplyMode: 'reply' as ReplyMode,
|
||||||
autoSelectReplyIdentity: false,
|
autoSelectReplyIdentity: true,
|
||||||
plainTextMode: false,
|
plainTextMode: false,
|
||||||
rtlEditingSupport: false,
|
rtlEditingSupport: false,
|
||||||
subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER,
|
subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER,
|
||||||
@@ -643,7 +644,11 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
|
|
||||||
updateSetting: (key, value) => {
|
updateSetting: (key, value, opts?: { force?: boolean }) => {
|
||||||
|
if (!opts?.force) {
|
||||||
|
const locked = usePolicyStore.getState().isSettingLocked(key);
|
||||||
|
if (locked) return;
|
||||||
|
}
|
||||||
set({ [key]: value });
|
set({ [key]: value });
|
||||||
|
|
||||||
// Apply font size to document root
|
// Apply font size to document root
|
||||||
|
|||||||
@@ -65,21 +65,32 @@ export const useTaskStore = create<TaskStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateTask: async (client, id, updates) => {
|
updateTask: async (client, id, updates) => {
|
||||||
|
try {
|
||||||
await client.updateCalendarTask(id, updates);
|
await client.updateCalendarTask(id, updates);
|
||||||
set({
|
set({
|
||||||
tasks: get().tasks.map(t => t.id === id ? { ...t, ...updates, updated: new Date().toISOString() } : t),
|
tasks: get().tasks.map(t => t.id === id ? { ...t, ...updates, updated: new Date().toISOString() } : t),
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
debug.error('TaskStore/updateTask failed', error);
|
||||||
|
set({ error: 'Failed to update task' });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteTask: async (client, id) => {
|
deleteTask: async (client, id) => {
|
||||||
|
try {
|
||||||
await client.deleteCalendarTask(id);
|
await client.deleteCalendarTask(id);
|
||||||
set({
|
set({
|
||||||
tasks: get().tasks.filter(t => t.id !== id),
|
tasks: get().tasks.filter(t => t.id !== id),
|
||||||
selectedTaskId: get().selectedTaskId === id ? null : get().selectedTaskId,
|
selectedTaskId: get().selectedTaskId === id ? null : get().selectedTaskId,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
debug.error('TaskStore/deleteTask failed', error);
|
||||||
|
set({ error: 'Failed to delete task' });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleTaskComplete: async (client, task) => {
|
toggleTaskComplete: async (client, task) => {
|
||||||
|
try {
|
||||||
const newProgress = task.progress === 'completed' ? 'needs-action' : 'completed';
|
const newProgress = task.progress === 'completed' ? 'needs-action' : 'completed';
|
||||||
const updates: Partial<CalendarTask> = {
|
const updates: Partial<CalendarTask> = {
|
||||||
progress: newProgress,
|
progress: newProgress,
|
||||||
@@ -89,6 +100,10 @@ export const useTaskStore = create<TaskStore>((set, get) => ({
|
|||||||
set({
|
set({
|
||||||
tasks: get().tasks.map(t => t.id === task.id ? { ...t, ...updates, updated: new Date().toISOString() } : t),
|
tasks: get().tasks.map(t => t.id === task.id ? { ...t, ...updates, updated: new Date().toISOString() } : t),
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
debug.error('TaskStore/toggleTaskComplete failed', error);
|
||||||
|
set({ error: 'Failed to update task' });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
clearTasks: () => set({ tasks: [], selectedTaskId: null, error: null }),
|
clearTasks: () => set({ tasks: [], selectedTaskId: null, error: null }),
|
||||||
|
|||||||
Reference in New Issue
Block a user