Commits the offline-client architecture analysis doc that was sitting untracked in docs/ — its own header already warns this exact thing happened once before (~/vncmail-plus is a shared checkout; an earlier untracked copy was lost to a concurrent branch switch). Confirmed the hazard is still live: vnc/VNC-CHANGES.md itself was found deleted from disk mid-edit by this session, by something else touching the checkout concurrently, and had to be restored with `git checkout --` before this commit. Committing on sight is the only defense against that, not a process improvement for later. Also: - .DS_Store added to .gitignore (was untracked in docs/) - introduces a VNC-side feature version, separate from package.json's upstream-tracking version (1.7.8, must stay that way per the fork's own rule 4 - bumping it would turn merging upstream releases into a diffing exercise). Retroactively bucketed at the milestone boundaries the commit history already has: v0.1.0 fork bootstrap, v0.2.0 S/MIME plugin audit+fixes, v0.3.0 the internal-CA foundation just landed. Tagged vnc-v0.3.0 on this commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 KiB
Bulwark / VNCmail+ — Offline & Native Client Architecture Analysis
Date: 2026-08-04 (updated same day — see §7 for a strategy-changing discovery)
Scope: what Bulwark (upstream bulwarkmail/webmail, forked as VNCmail+) delivers today for
offline use, and what has to be built to ship Electron (desktop), Capacitor/React-Native
iOS (IPA) and Android (APK) clients with local notifications, an encrypted local search index
(SQLite/SQLCipher), and true offline mail.
Note on this file's persistence:
~/vncmail-plusis a shared checkout — other sessions actively commit and switch branches here. An earlier untracked copy of this doc was lost to a branch switch. Commit this file (or move it somewhere durable) if you want it to survive.
1. Current state of the webmail repo (verified against ~/vncmail-plus source)
| Area | Status today | Evidence |
|---|---|---|
| Service worker | Installed, but caches nothing — fetch handler is a deliberate no-op so the app is never usable offline |
public/sw.js:6-8,36 |
| Web app manifest | Present, installable PWA (icons, protocol_handlers for mailto/webcal) |
app/manifest.ts |
| Push notifications | Real Web Push: VAPID subscribe, Notification.requestPermission, SW push/notificationclick handlers, relayed through an external push relay + a preview API route |
lib/web-push.ts, public/sw.js:38-42, app/api/push/preview/route.ts |
| Local mail cache | None. IndexedDB is used only for plugin/theme blobs; localStorage only holds device IDs and Zustand UI-state (persist()), never message bodies |
lib/plugin-storage.ts, stores/account-store.ts:220 |
| Search | Server-side JMAP Email/query only, no client index |
lib/jmap/search-utils.ts |
| Local encryption | None for cached data. The one AES-256-GCM routine (lib/auth/crypto.ts) encrypts the session cookie server-side using node:crypto — unusable in a browser/WebView |
lib/auth/crypto.ts |
| Mobile/desktop packaging in this repo | Nothing exists: no capacitor.config.ts, no Electron main/electron-builder, no Tauri, no fastlane/gradle/Xcode |
confirmed via repo-wide find; .github/workflows/* |
| JMAP client portability | lib/jmap/client.ts is pure fetch(), no Node-only APIs — portable into a WebView/Electron renderer unchanged |
grep for node:/require( in lib/jmap/* = zero hits |
Multi-account scope: confirmed YES — the offline cache must support multiple simultaneous
Stalwart accounts per device (matches the webmail's existing account-registry store). This
multiplies SQLCipher key-management work (§3/§7): one isolated key per account, not one global key.
2. The fork in the road: shell strategy
Option A — Native shell over a remote WebView. Capacitor/Electron just point at the hosted Bulwark URL. Cheapest, ships an APK/IPA/desktop binary fast, gets native push — but is not offline.
Option B — True offline-first client. The client authenticates and syncs JMAP data directly, keeps a local encrypted store, and only needs connectivity to sync, not to read.
Recommendation stands: Electron first (Option-B-lite is nearly free there — see §4), mobile starts with Option A, then graduates to Option B — but see §7: for mobile, "graduate to Option B" likely means extending an existing app, not building one from scratch.
3. Build-vs-buy matrix (webmail-repo-only view — see §7 for the revised mobile view)
| Component | Off-the-shelf | What you build yourselves |
|---|---|---|
| Capacitor shell (iOS/Android project scaffolding) | Capacitor CLI generates both native projects | Splash/icons, deep-link config, capacitor.config.ts tuning |
| Local SQLite | @capacitor-community/sqlite — ships native SQLCipher support on iOS/Android; web fallback via jeep-sqlite/wa-sqlite |
Schema, JMAP→SQLite mapping, migrations |
| SQLCipher key lifecycle | Native Keychain/Keystore APIs (via Capacitor Secure Storage) store the raw key | Key derivation/rotation, per-account keys (multi-account confirmed §1), wipe-on-logout |
| Full-text search | SQLite FTS5 ships free with SQLite | Tokenizer choice, incremental indexer fed by the sync engine |
| Native push | @capacitor/push-notifications wraps FCM/APNs |
Relay extension, device-token registration, notification-tap deep-linking |
| Electron desktop | electron-builder; Electron's own cross-platform Notification API |
Main process booting the existing standalone Next.js server; auto-update wiring |
| Background sync | iOS BGTaskScheduler, Android WorkManager |
The actual poll/backoff/delta-fetch job logic |
| Biometric app-lock | capacitor-native-biometric |
UI/UX, fallback-to-passcode flow |
| Store release pipeline | Fastlane/EAS-style CI, Apple/Google developer accounts | Signing config, CI secrets, store metadata |
4. Why Electron is the cheap win
Electron has no server-dependency problem: bundle the standalone Next.js server (the same
artifact the Dockerfile already produces) inside Electron's Node runtime, open a
BrowserWindow against localhost. Reuses 100% of the existing app including app/api/**.
Native Notification API replaces Web Push entirely on desktop. Ships well before mobile
Option B.
5. Phased roadmap
- Fix the service worker — today's SW intentionally caches nothing (
sw.js:36). Add Workbox-style precaching of the app shell/static assets. Cheap, immediate PWA-offline-shell improvement, no architecture change. - Electron desktop (§4) — bundle standalone server + BrowserWindow + native Notification +
electron-builderpackaging. - Capacitor mobile, Option A (remote shell) — WebView on the hosted instance, native push
registration bridged into the relay, biometric app-lock. Ships an installable APK/IPA fast;
not offline yet. Revisit against §7 before starting — extending
vncmail-nativemay replace this step entirely rather than complement it. - JMAP sync engine + SQLite/SQLCipher store — design delta-sync via
Email/changes/Mailbox/changes; local schema, one key per account (§1); move auth off the Next-only encrypted cookie into secure storage so mobile can talk to Stalwart directly. §7:vncmail-nativealready has a cruder version of the "local cache" half of this (bulk AsyncStorage download) — the delta-sync/SQLite/SQLCipher/FTS half is still greenfield there too, but auth/JMAP wiring is not. - FTS index + offline compose/outbox — SQLite FTS5 population job; offline-composed
messages queued and replayed via JMAP
Email/seton reconnect; conflict handling. - Platform hardening — background refresh scheduling, Apple export-compliance declaration
(SQLCipher/AES in the binary triggers
ITSAppUsesNonExemptEncryption), signing/release CI.
6. Open questions — status
Does the referenced upstream React Native app already solve native push/device-pairing?RESOLVED — see §7.- Is Bulwark upstream planning native clients? Partially answered by §7: yes,
bulwarkmail/nativeis that plan, already public, beta/WIP. Still worth watching its upstream activity before diverging further, since pulling upstream improvements is cheaper than re-diverging an AGPL fork. - Multi-account scope — RESOLVED, see §1.
7. 2026-08-04 discovery: an upstream React Native app already exists — re-scope Phase 2
bulwarkmail/native (public, AGPL-3.0-only, Expo SDK 54, beta/WIP) is a React Native mobile
client for Bulwark. Forked to brvncde-dotcom/vncmail-native. It already ships:
- Multi-account JMAP sign-in against any server (e.g. Stalwart).
- QR-code cross-device pairing —
src/screens/LoginScreen.tsx+QrScanModal+redeemPairingCode/OAuth handoff insrc/lib/oauth.ts. This is the "QR-code SSO login and device pairing" feature referenced in the webmail'sCHANGELOG.md:207. - Android push notifications via FCM, dispatched through a second public upstream repo,
bulwarkmail/relay(also AGPL-3.0) — this is the actual service behind the webmail'sDEFAULT_RELAY_BASE_URL, resolving the open question from the original Phase-2 plan about where that relay's source lives. It terminates JMAPPushSubscriptionpushes and forwards to FCM (mobile) or Web Push (PWA); a single Bulwark-hosted instance serves every opted-in client so self-hosters don't need their own Firebase project — or you can self-host it (Docker compose provided) if you want push traffic to never leave VNC infrastructure. Decided: self-host. Forked tobrvncde-dotcom/vncmail-relay. Remaining: dedicated Firebase project for FCM credentials, a VAPID keypair, a microk8s deploy alongsidevncmail-plus(pervnclagoon-suite-microfrontends), and repointing bothvncmail-plus(DEFAULT_RELAY_BASE_URL) andvncmail-nativeat the self-hosted instance. Sequenced in theVNCprodbuildskill's Phase 2 step 0. - A basic offline mail cache already:
src/lib/offline-sync.ts(155 lines) bulk-downloads the last N days of mail viaEmail/query+Email/getintosrc/stores/offline-cache-store.ts(AsyncStorage-backed, size-capped, evicts oldest), with live progress UI (OfflineCacheBanner.tsx). This is not the delta-sync/SQLite/SQLCipher/FTS engine Phase 2 called for — it's a periodic bulk re-download, not incrementalEmail/changessync, and storage is plain JSON in AsyncStorage, not an encrypted database — but auth, JMAP wiring, and the UI shell around "offline mail" already exist. - Android release pipeline (
.github/workflows/release-android.yml, sideload APK from GitHub Releases) and an iOS release pipeline (release-ios.yml,docs/ios-release.md, TestFlight) already exist — iOS builds, just without push (see below).
Still genuinely missing (confirmed against its own README + source):
- iOS push notifications and client certs — Android-only so far.
- No SQLite/SQLCipher/FTS anywhere (
@react-native-async-storage/async-storage+expo-secure-storeonly) — the encrypted-local-index work is still fully greenfield. Resolved 2026-08-04: useexpo-sqlite's officialuseSQLCipherconfig-plugin option (Android/iOS/macOS) rather than a third-party binding. Unusable in Expo Go, so this forces a custom dev client for development going forward — accepted. Stay Continuous-Native-Generation (don't commitios/android, letexpo prebuildregenerate them) rather than going fully bare, since a committed native tree would conflict on every future merge from upstreambulwarkmail/native. Detail in theVNCprodbuildskill's status log. - Filters & rules, S/MIME, plugins, themes, file storage are UI stubs only.
- No Play Store distribution yet.
Strategic implication: for the mobile leg of the native-client roadmap, extending
vncmail-native is very likely cheaper than building a Capacitor wrapper around the webmail
from scratch — it already has the parts that were the most speculative/decision-heavy in the
original Phase-2 plan (auth, pairing, push wiring, multi-account, a working offline-mail UX
shell). The remaining work narrows to: iOS push, replacing the AsyncStorage bulk-cache with a
real Email/changes delta-sync engine into SQLite/SQLCipher, an FTS5 index, and an
offline-compose/outbox queue — i.e., roughly roadmap steps 4–6 above, now scoped against an
existing app instead of a blank one. This should be a formal decision gate before touching
Phase 2 further: adopt vncmail-native as the mobile client going forward (dropping/deferring
the Capacitor-wraps-webmail plan for mobile), or keep both in parallel. Recommend adopting it —
duplicating auth/pairing/push work that already exists and works has no upside.