# 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`).
