Merge branch 'claude/electron-offline-design' into dev

Encrypted SQLite/FTS5 offline search index for the Electron desktop
client: event-driven reindex (mail, calendar, contacts, files) driven
off the existing JMAP push connection, per-account keys held in OS
keychain via safeStorage, search API returns ranked context ready for
an LLM/RAG prompt.
This commit is contained in:
Bernd Rodler
2026-08-05 11:08:59 +02:00
44 changed files with 9847 additions and 33 deletions
+89
View File
@@ -0,0 +1,89 @@
name: Build Electron Desktop App
# Phase 1 of the VNCprodbuild rollout (~/.claude/skills/VNCprodbuild/SKILL.md
# on the machine that authored this - Phase 1 step 8). Builds the desktop
# shell (electron/) for macOS, Windows, and Linux on every release, or
# on-demand via workflow_dispatch for a one-off test build.
#
# Ships UNSIGNED. There's no Apple Developer ID or Windows code-signing cert
# yet (VNCprodbuild Phase 1 step 9 - both are human-owned purchases, not
# something CI can provide). CSC_IDENTITY_AUTO_DISCOVERY: "false" below stops
# electron-builder from probing for a macOS signing identity it won't find.
# Adding real certs later needs no rewrite here - just add CSC_LINK/
# CSC_KEY_PASSWORD (macOS) and/or WIN_CSC_LINK/WIN_CSC_KEY_PASSWORD (Windows)
# as repo secrets and electron-builder picks them up automatically.
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: write
jobs:
build:
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Build standalone Next.js server
run: npm run build:standalone
- name: Bundle Electron main/preload
run: npm run build:electron
# Only Linux runners lack a display server by default - macOS/Windows
# GitHub-hosted runners can launch a real (if headless) GUI session
# without one.
- name: Install Xvfb (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y xvfb
# Required gate (VNCprodbuild Phase 1 step 2) before any packaging or
# artifact-upload step below, on every OS in the matrix - a
# platform-specific regression in electron/main.ts (path handling,
# spawn behavior, etc.) should fail exactly the leg it breaks, not
# slip through because only one OS was ever smoke-tested.
- name: Run Electron smoke test (Linux, via Xvfb)
if: runner.os == 'Linux'
run: xvfb-run --auto-servernum npm run test:electron
- name: Run Electron smoke test
if: runner.os != 'Linux'
run: npm run test:electron
- name: Package
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_IDENTITY_AUTO_DISCOVERY: "false"
run: npx electron-builder --config electron-builder.config.js --publish ${{ github.event_name == 'release' && 'always' || 'never' }}
- name: Upload artifact (workflow_dispatch)
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v4
with:
name: vncmail-plus-desktop-${{ matrix.os }}
path: |
dist-electron-builds/*.dmg
dist-electron-builds/*.zip
dist-electron-builds/*.exe
dist-electron-builds/*.AppImage
dist-electron-builds/*.deb
retention-days: 7
if-no-files-found: ignore
+8
View File
@@ -38,6 +38,14 @@ yarn-error.log*
# vercel
.vercel
# electron (see electron/, scripts/build-electron.mjs, electron-builder.config.js)
/dist-electron/
/dist-electron-builds/
# playwright output
/test-results/
/playwright-report/
# typescript
*.tsbuildinfo
next-env.d.ts
+46 -1
View File
@@ -32,6 +32,7 @@ import { usePromptDialog } from "@/hooks/use-prompt-dialog";
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
import { debug } from "@/lib/debug";
import { playNotificationSound } from "@/lib/notification-sound";
import { isElectronShell, showElectronNotification } from "@/lib/electron-bridge";
import { cn } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
@@ -1062,7 +1063,30 @@ export default function Home() {
debug.log('push', `[Push] Push notifications enabled for ${cleanups.length} account(s)`);
}
// CATCH-UP for the desktop shell's local search index. The index's normal
// trigger is a push StateChange (stores/email-store.ts's handleStateChange),
// but nothing was pushed while the app was closed - and the polling
// transport has no signal for contacts or files at all (client.ts's
// buildStatePollingRequest covers Mailbox/Email/Calendar/CalendarEvent/
// SieveScript only). So backfill a bounded recent window once per session,
// after push is wired. Fire-and-forget; a no-op outside Electron.
const catchUpTimer = setTimeout(() => {
void (async () => {
try {
const { catchUpIndex } = await import('@/lib/mail-index-client');
await catchUpIndex(
useAccountStore.getState().getActiveAccount()?.cookieSlot,
);
} catch {
/* the index is optional */
}
})();
// Deliberately after the initial mailbox fetch settles: the catch-up is a
// background nicety and must not compete with first paint.
}, 4000);
return () => {
clearTimeout(catchUpTimer);
cleanups.forEach((fn) => fn());
};
}, [isAuthenticated, client, activeAccountId, connectedAccountsSignature, handleStateChange, setPushConnected, buildPopulatedUnifiedAccounts, refreshCrossCounts, refreshUnifiedCounts]);
@@ -1186,13 +1210,34 @@ export default function Home() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedEmail?.id, isScheduledView]);
// Handle new email notifications - play sound
// Handle new email notifications - play sound, and (in the Electron shell)
// fire a native OS notification. This effect is the transport-agnostic
// "genuinely new unread mail arrived" signal - stores/email-store.ts's
// refreshCurrentMailbox() already filters out sends/moves/drafts and only
// sets newEmailNotification for a real new top-of-inbox message, and it
// fires identically whether the underlying JMAP StateChange arrived over
// the WebSocket push connection (lib/jmap/client.ts's connectWebSocket),
// SSE, or the polling fallback - no need to duplicate this per transport.
useEffect(() => {
if (newEmailNotification) {
const { emailNotificationsEnabled, emailNotificationSound, notificationSoundChoice } = useSettingsStore.getState();
if (emailNotificationsEnabled && emailNotificationSound) {
playNotificationSound(notificationSoundChoice);
}
if (emailNotificationsEnabled && isElectronShell()) {
// Same fallback text public/sw.js's push handler already uses for
// its (also un-translated) system notifications - a native OS
// notification body isn't run through next-intl either way, so
// matching that existing precedent instead of introducing new
// translation keys for a rarely-hit fallback.
const sender = newEmailNotification.from?.[0];
const senderName = sender?.name || sender?.email || 'New mail';
const body = newEmailNotification.subject || newEmailNotification.preview || '(no subject)';
void showElectronNotification(senderName, {
body,
tag: `bulwark-mail:${newEmailNotification.id}`,
});
}
debug.log('email', 'New email received:', newEmailNotification.subject);
clearNewEmailNotification();
}
+110
View File
@@ -0,0 +1,110 @@
// POST /api/offline/reindex - write mail/calendar/contacts/files into the
// encrypted local search index for the calling session's account.
//
// The PRIMARY caller is the renderer's live JMAP push handler: when a
// StateChange arrives it posts the ids that changed, so indexing is reactive to
// each delivery rather than periodic. `{ catchUp: true }` (no ids) is the
// fallback used at app launch to backfill whatever changed while the app was
// closed.
//
// GATED: returns 404 unless VNCMAIL_DESKTOP_STORE_DIR is set, which only
// electron/main.ts does. The same standalone server artifact runs in the
// multi-tenant production Docker image, where this feature must not exist at
// all - 404 rather than 403 so nothing learns the route is there.
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { isSqlcipherAvailable } from '@/lib/mail-index/binding';
import { hasKeyChannel, IndexKeyError } from '@/lib/mail-index/key';
import { getStoreDir } from '@/lib/mail-index/paths';
import {
IndexSessionError, MAX_IDS_PER_CALL, resolveIndexSession, runIndex,
type IndexRequest,
} from '@/lib/mail-index/reindex';
import { CONTENT_TYPES, isContentType, type ContentType } from '@/lib/mail-index/store';
import { JmapIndexError } from '@/lib/mail-index/jmap';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
function parseIdMap(raw: unknown): Partial<Record<ContentType, string[]>> | undefined {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
const out: Partial<Record<ContentType, string[]>> = {};
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (!isContentType(key) || !Array.isArray(value)) continue;
const ids = value
.filter((v): v is string => typeof v === 'string' && v.length > 0 && v.length <= 256)
.slice(0, MAX_IDS_PER_CALL);
if (ids.length > 0) out[key] = ids;
}
return Object.keys(out).length > 0 ? out : undefined;
}
export async function POST(request: NextRequest) {
if (!getStoreDir()) {
return new NextResponse(null, { status: 404 });
}
if (!hasKeyChannel()) {
return NextResponse.json(
{ error: 'The local index has no key channel in this process.', code: 'no-key-channel' },
{ status: 503 },
);
}
if (!isSqlcipherAvailable()) {
// The native binding is an optionalDependency, so "not installed" is a
// normal state on platforms without a prebuild - not an error to log loudly.
return NextResponse.json(
{ error: 'Encrypted local index is unavailable on this platform.', code: 'no-binding' },
{ status: 503 },
);
}
let body: Record<string, unknown> = {};
try {
const text = await request.text();
if (text.trim()) body = JSON.parse(text) as Record<string, unknown>;
} catch {
return NextResponse.json({ error: 'Malformed JSON body' }, { status: 400 });
}
const rawTypes = Array.isArray(body.types) ? body.types.filter(isContentType) : [];
const req: IndexRequest = {
types: rawTypes.length > 0 ? rawTypes : undefined,
ids: parseIdMap(body.ids),
removed: parseIdMap(body.removed),
// Pruning is a catch-up concern; a single-delivery call shouldn't scan.
prune: body.catchUp === true,
};
try {
const session = await resolveIndexSession(request);
const result = await runIndex(session, req);
return NextResponse.json(
{
ok: true,
written: result.written,
skipped: result.skipped,
errors: result.errors,
durationMs: result.durationMs,
types: CONTENT_TYPES,
},
{ headers: { 'Cache-Control': 'no-store' } },
);
} catch (error) {
if (error instanceof IndexSessionError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
if (error instanceof JmapIndexError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
if (error instanceof IndexKeyError) {
// no-secure-storage is the Linux-without-a-keyring refusal: a real,
// expected outcome with a user-facing explanation, not a server fault.
const status = error.code === 'no-secure-storage' ? 503 : 500;
return NextResponse.json({ error: error.message, code: error.code }, { status });
}
logger.error('mail-index reindex failed', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json({ error: 'Reindex failed' }, { status: 500 });
}
}
+117
View File
@@ -0,0 +1,117 @@
// GET /api/offline/search?q=...&types=mail,calendar&limit=20
//
// THE RETRIEVAL SURFACE. This is what an AI/RAG feature calls to gather
// relevant context from the user's own mail, calendar, contacts and files
// before prompting a model - hence the `snippet` on every hit and the
// `contextBlock` convenience field, which is the same information already
// flattened into text a prompt can carry directly.
//
// Read-only: it never touches the network and never writes. Gated identically
// to the reindex route.
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { isSqlcipherAvailable } from '@/lib/mail-index/binding';
import { hasKeyChannel, IndexKeyError, withIndexKey } from '@/lib/mail-index/key';
import { getStoreDir } from '@/lib/mail-index/paths';
import { IndexSessionError, resolveIndexSession } from '@/lib/mail-index/reindex';
import {
isContentType, MailIndex, MailIndexUnavailableError, type ContentType, type SearchHit,
} from '@/lib/mail-index/store';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* One hit as a plain text block, ready to be concatenated into a prompt.
* Kept server-side so every caller (a chat feature, a future agent, a test)
* formats context the same way rather than each inventing its own.
*/
function toContextBlock(hit: SearchHit): string {
const label: Record<ContentType, string> = {
mail: 'EMAIL', calendar: 'CALENDAR EVENT', contact: 'CONTACT', file: 'FILE',
};
const lines = [`[${label[hit.contentType]}] ${hit.title}`];
if (hit.occurredAt) lines.push(`Date: ${hit.occurredAt}`);
if (hit.people) lines.push(`People: ${hit.people}`);
const path = hit.metadata?.path;
if (typeof path === 'string' && path) lines.push(`Path: ${path}`);
if (hit.snippet) lines.push(`Excerpt: ${hit.snippet}`);
return lines.join('\n');
}
export async function GET(request: NextRequest) {
if (!getStoreDir()) {
return new NextResponse(null, { status: 404 });
}
if (!hasKeyChannel() || !isSqlcipherAvailable()) {
return NextResponse.json(
{ error: 'Encrypted local index is unavailable in this process.', code: 'unavailable' },
{ status: 503 },
);
}
const params = request.nextUrl.searchParams;
const query = (params.get('q') ?? '').trim();
const wantStats = params.get('stats') === 'true';
if (!query && !wantStats) {
return NextResponse.json({ error: 'Missing q parameter' }, { status: 400 });
}
if (query.length > 512) {
return NextResponse.json({ error: 'Query too long' }, { status: 400 });
}
const types = (params.get('types') ?? '')
.split(',')
.map((t) => t.trim())
.filter(isContentType);
const limitRaw = Number(params.get('limit') ?? '20');
const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(Math.trunc(limitRaw), 1), 100) : 20;
try {
const session = await resolveIndexSession(request);
const storeDir = getStoreDir();
if (!storeDir) return new NextResponse(null, { status: 404 });
const payload = await withIndexKey(session.accountId, (key) => {
const index = MailIndex.open({ storeDir, accountId: session.accountId, key });
try {
const stats = index.stats();
if (!query) return { hits: [] as SearchHit[], stats };
return { hits: index.search({ query, types, limit }), stats: wantStats ? stats : undefined };
} finally {
index.close();
}
});
return NextResponse.json(
{
ok: true,
query,
types: types.length > 0 ? types : 'all',
count: payload.hits.length,
hits: payload.hits,
// Everything a prompt needs, pre-joined in rank order.
contextBlock: payload.hits.map(toContextBlock).join('\n\n---\n\n'),
...(payload.stats ? { stats: payload.stats } : {}),
},
{ headers: { 'Cache-Control': 'no-store' } },
);
} catch (error) {
if (error instanceof IndexSessionError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
if (error instanceof IndexKeyError) {
const status = error.code === 'no-secure-storage' ? 503 : 500;
return NextResponse.json({ error: error.message, code: error.code }, { status });
}
if (error instanceof MailIndexUnavailableError) {
return NextResponse.json({ error: error.message, code: 'unavailable' }, { status: 503 });
}
logger.error('mail-index search failed', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json({ error: 'Search failed' }, { status: 500 });
}
}
@@ -13,6 +13,7 @@ import { cn } from '@/lib/utils';
import { getPathPrefix } from '@/lib/browser-navigation';
import { clearCachedData } from '@/lib/clear-cached-data';
import { SpamSiegeGame } from './spam-siege-game';
import { LocalIndexSettings } from './local-index-settings';
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
@@ -218,6 +219,9 @@ export function AboutDataSettings() {
</Button>
</SettingItem>
</SettingsSection>
{/* Desktop shell only - renders nothing in the browser/PWA build. */}
<LocalIndexSettings />
</>
);
}
@@ -0,0 +1,123 @@
"use client";
// Settings panel for the desktop shell's encrypted local search index.
//
// Deliberately small: the index's PRIMARY trigger is the live push connection
// (see lib/mail-index-client.ts's indexOnStateChange, wired into
// stores/email-store.ts's handleStateChange), so this panel is a status readout
// plus a manual catch-up button - not the mechanism.
//
// Renders nothing at all outside the Electron shell, where the routes 404.
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { SettingsSection, SettingItem } from './settings-section';
import { isElectronShell } from '@/lib/electron-bridge';
import { useAccountStore } from '@/stores/account-store';
import { catchUpIndex, fetchIndexStats, type IndexStats } from '@/lib/mail-index-client';
const TYPE_LABELS: Record<string, string> = {
mail: 'Mail',
calendar: 'Calendar',
contact: 'Contacts',
file: 'Files',
};
export function LocalIndexSettings() {
const slot = useAccountStore((s) => s.accounts.find((a) => a.id === s.activeAccountId)?.cookieSlot);
const [stats, setStats] = useState<IndexStats[] | null>(null);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<string | null>(null);
// `null` until the first probe resolves, so we don't flash a panel that then
// vanishes on a non-desktop build.
const [available, setAvailable] = useState<boolean | null>(null);
const refreshStats = useCallback(async () => {
const next = await fetchIndexStats(slot);
setStats(next);
setAvailable(next !== null);
}, [slot]);
useEffect(() => {
if (!isElectronShell()) {
setAvailable(false);
return;
}
void refreshStats();
}, [refreshStats]);
const handleRebuild = async () => {
setBusy(true);
setMessage(null);
try {
const result = await catchUpIndex(slot);
if (result.unavailable) {
setAvailable(false);
setMessage(result.error ?? 'The encrypted index is unavailable on this system.');
return;
}
if (!result.ok) {
setMessage(result.error ?? 'Indexing failed.');
return;
}
const written = Object.entries(result.written ?? {})
.map(([type, n]) => `${TYPE_LABELS[type] ?? type}: ${n}`)
.join(', ');
const failed = (result.errors ?? []).map((e) => `${e.contentType} (${e.message})`).join('; ');
setMessage(
[
written ? `Indexed ${written}.` : 'Nothing to index.',
result.skipped?.length ? `Not supported: ${result.skipped.join(', ')}.` : '',
failed ? `Problems: ${failed}` : '',
]
.filter(Boolean)
.join(' '),
);
await refreshStats();
} finally {
setBusy(false);
}
};
if (available === false || available === null) return null;
const total = (stats ?? []).reduce((sum, s) => sum + s.count, 0);
return (
<SettingsSection
title="Local search index"
description={
'An encrypted index of your recent mail, calendar events, contacts and file names, ' +
'stored on this device only. It updates automatically as items arrive, and powers ' +
'local search and AI answers about your own data. Files are indexed by name and ' +
'location, not by their contents.'
}
>
<SettingItem
label="Indexed items"
description={
total > 0
? (stats ?? [])
.map((s) => `${TYPE_LABELS[s.contentType] ?? s.contentType}: ${s.count}`)
.join(' · ')
: 'Nothing indexed yet.'
}
>
<span className="text-sm text-muted-foreground tabular-nums">{total}</span>
</SettingItem>
<SettingItem
label="Update now"
description={
message ??
'Catches up on anything that changed while the app was closed. Normally not needed - ' +
'the index updates itself when mail, events, contacts or files change.'
}
>
<Button variant="outline" size="sm" onClick={handleRebuild} disabled={busy}>
{busy ? 'Indexing…' : 'Update index'}
</Button>
</SettingItem>
</SettingsSection>
);
}
File diff suppressed because it is too large Load Diff
+259
View File
@@ -0,0 +1,259 @@
> # ⚠️ SUPERSEDED — reviews a design that was not built
>
> This reviews `ELECTRON-OFFLINE-ENGINE-DESIGN.md`, which was **dropped**. Its findings were the
> direct cause: seeing them, the human narrowed the requirement from a full offline mail replica to
> *"a SQLite index we can prompt against"*, refreshed on each delivery/change event. What shipped is
> `lib/mail-index/**` + `app/api/offline/{reindex,search}` — see that doc's superseded note.
>
> **This review did its job.** Most of its severe findings were resolved by the scope change
> removing the thing they were about, which is the strongest outcome a review can have:
>
> | Finding | Outcome |
> |---|---|
> | **C1** — `@signalapp/sqlcipher` in `dependencies` breaks both Alpine `docker build`s | **FIXED as specified.** It is an `optionalDependencies` entry with a guarded runtime require (`lib/mail-index/binding.ts`). Both `docker build`s verified passing, and the require verified failing cleanly with MODULE_NOT_FOUND inside the musl image. |
> | **C2** — credentials are request-scoped, so no persistent worker can hold them | **MOOT.** There is no worker. Indexing is a normal API route using the request's own `jmap_stalwart_ctx` cookie, via the existing `lib/stalwart/credentials.ts`. |
> | **C3** — the OAuth-refresh mitigation is itself the bug | **MOOT, and avoided by construction.** The indexer never touches the refresh-token cookie; it only reads an already-minted auth header, so it cannot rotate a token into a response nobody reads. |
> | **C4** — shared `registry.json` breaks the multi-account safety premise | **MOOT.** No registry, no epochs, no concurrent workers. |
> | **H1** — a server-side engine can't read a renderer-only setting | **MOOT.** The renderer decides when to index. |
> | **H2** — key handoff sequencing, and a nonce via env is readable by same-user processes | **FIXED.** The key crosses on an **inherited file descriptor**, never env, and is fetched per job and zeroed after — not held. Sequencing is moot: the key is fetched when a job runs, not at spawn. |
> | **H3** — local unread-count arithmetic needs a coherence story | **MOOT.** A retrieval index does not need to stay coherent with live unread counts. |
> | **H4** — no cap on concurrent multi-account sync | **MOOT.** One request, one account. |
> | *medium/low:* `getSelectedStorageBackend()` is Linux-only and would crash elsewhere | **FIXED** — platform-guarded. |
> | *medium/low:* `cipher_version` check would pass vacuously on zero rows | **FIXED** — the shipped assertion requires a non-empty *string*, and a test reads the raw file bytes for a plaintext canary. |
> | *medium/low:* the two bindings are not "the same code either way" | **CONFIRMED true, the hard way.** `@signalapp/sqlcipher` rejects varargs params (`TypeError: Params must be either object or array`) where better-sqlite3 accepts them. Documented in `binding.ts`. |
>
> Reviewing this file's own accuracy: its two re-executed claims (the binding working in Electron 43,
> and `PRAGMA key` being a silent no-op) both held up and both shaped the shipped code.
# Adversarial review: `docs/ELECTRON-OFFLINE-ENGINE-DESIGN.md`
Reviewer: independent agent, fresh context, no relation to the design's author. 2026-08-04.
## Verdict
**Needs substantial rework before implementation — but narrowly scoped rework.**
The delta-sync core (everything tagged `[reused]` from M, the mobile design) is genuinely sound
and transfers; the reviewer attacked it directly and could not break it. The problem is that **all
four genuinely-new sections have an unclosed load-bearing mechanism**, and one of them breaks a
build that ships today:
- §3 (binding choice) contains a packaging decision that breaks the hosted Docker image and the
integration fixture.
- §2 (process choice) rests on a credential claim that is only true inside an HTTP request.
- §6 (key handoff) is under-specified in a way that doesn't work as sequenced, and its "unresolved
implementation choice" is not security-neutral.
- §5.3/§8.3 (multi-account) breaks the specific premise M's D6 fix relies on.
Nothing here requires re-architecting the sync engine. Stages B-G can proceed against M as
written. Stage A as currently specified would not surface most of this.
All file:line citations in the design doc that were checked resolve correctly (one trivial
miscount, noted at the end) — citation quality is high; the problems are in the reasoning built
on top.
---
## CRITICAL
### C1 — Adding `@signalapp/sqlcipher` to `dependencies` breaks the hosted Docker build *and* the integration fixture
**Where:** §3.3.1 ("entering `dependencies`"), §3.3.5, §2.4, §10.5, E2, §13 item 6.
`Dockerfile:1-4``FROM node:24-alpine`, `RUN npm ci`. `integration/webmail.Dockerfile:12-15`
same, `FROM node:24-alpine` + `npm ci`.
Verified from the published tarball that `@signalapp/sqlcipher@4.0.3`:
- ships **6** prebuilds — `darwin-{arm64,x64}`, `linux-{arm64,x64}`, `win32-{arm64,x64}`. No
`linuxmusl-*`. (The design doc's list is exactly right.)
- ships **no build sources at all** — published `files` is `dist/*`, `prebuilds`, `README.md`. No
`binding.gyp`, no `src/`, no `deps/`.
- has `install: node-gyp-build`. `node-gyp-build`'s `bin.js` runs `node-gyp-build-test`; on
failure it calls `build()` → spawns `node-gyp rebuild``process.exit(code)`.
No prebuild + no `binding.gyp``node-gyp rebuild` fails ⇒ **`npm ci` exits nonzero**. The Linux
prebuild also has a glibc ≥ 2.34 floor, so it could not load on musl even if copied.
**Concrete failure:** the next `docker build` of the production image fails at line 4.
`npm run test:integration` fails to build the webmail container. Neither is gated by
`VNCMAIL_DESKTOP_STORE_DIR` — that env var only governs *activation*, not *installation*.
§3.3.5 dismisses musl as "relevant if an Alpine-based container ever wants the engine — which,
per §2.4, it must not" — that reasoning is inverted: the Alpine container doesn't want the engine,
it just needs `npm install` to succeed regardless.
**Fix direction:** `optionalDependencies` + a guarded runtime `require` (which also delivers E2's
graceful-load-failure behavior for free), or a separate optional package, or `--omit=optional` in
both Dockerfiles. Pick one and say so explicitly; add "`docker build` of both Dockerfiles still
succeeds" to the Stage A verification list.
### C2 — Option A's central justification is only true inside an HTTP request; the Worker credential path does not exist
**Where:** §2.1-for-1, §1.2, §2.4 (Worker), §5.3, §8.1 triggers T1/T4/T5/T11, §13 item 1.
Verified in `app/api/auth/session/route.ts` and `app/api/auth/token/route.ts`: every credential
read goes through `cookies()` from `next/headers` — request-scoped. `lib/oauth/cookie-config.ts:11`
sets `httpOnly: true`. The cookies live in the renderer's cookie jar, not in the server. The
standalone server holds no session state whatsoever; it decrypts a cookie per request and
discards it.
So "the credentials are already there... No new credential path, no IPC carrying secrets, no
second copy" (§2.1-for-1) is materially overstated. What is actually there is *the ability to
decrypt a credential presented on an inbound request* — not a resident credential.
Consequences the design never addresses:
1. **T1 ("server process ready + an account's credentials resolvable") cannot fire.** At
server-ready there are no cookies anywhere. Nor can T4 (network regained), T5 (`StateChange`
on the engine's own socket), or T11 (resume from sleep) — none is an inbound renderer request.
2. **A `worker_threads` Worker is a separate execution context with no cookie access at all.**
§2.4 mandates the Worker and routes talk to it via `postMessage`, but there's no specified
point at which the Worker actually receives credentials.
3. The only workable shape is: on the first renderer request, decrypt and hand the **plaintext**
credentials to the Worker, which retains them for the process lifetime. That is a new
long-lived plaintext secret in a new location — the exact thing §2.1-for-1 claims doesn't
happen, and the same category of thing `client.ts:6055-6059` already declined once (a
resident credential copy in a process that didn't previously hold one). It also creates an
invalidation problem never addressed: password change, logout elsewhere, or a cleared cookie
leaves the Worker retrying stale credentials indefinitely (since `AuthenticationError` is
correctly never treated as a purge signal) — against a server with failed-auth lockout, this
locks the user's account.
This doesn't kill Option A, but it kills the argument that Option A is free of new secret
handling — which was the design's #1 stated reason for choosing it over the alternative. That
comparison needs to be redone with the resident-copy cost included, not dropped.
### C3 — The proposed OAuth-refresh mitigation (E11) is not just unimplementable; it *is* the bug it's meant to prevent
**Where:** E11 (failure-mode table), §1.2's note about `app/api/auth/token/route.ts:104-106`.
E11's rule: *"the engine never refreshes independently. It obtains tokens only through the
existing `PUT /api/auth/token` route, in-process, so there is exactly one refresher and one
rotation writer — the route."*
But that route: reads the refresh token from the **request's** cookie; writes the rotated token
as a `Set-Cookie` on the **response**; and on a 400/401/403 from the identity provider, **deletes**
the refresh-token cookie and returns 401.
An in-process server-side call to that route has no cookie to send (401s immediately), and even
if the engine forged one from a resident copy, the rotated token would land in a response the
engine discards. Net effect: engine refreshes → identity provider rotates the token → the new
token lands in a discarded response → the browser still holds the now-superseded token → the
next real refresh from the browser gets rejected → the route deletes the cookie → **the user is
silently logged out of that account, and the offline store's credentials are dead.**
The per-slot lock the design suggests as a fallback does not help — the problem is that cookie
state lives in the browser, not that the writes race each other.
Separately, `PUT /api/auth/session` requires three `sec-fetch-*` headers with a comment claiming
"non-browser clients cannot forge these" — a Node-side `fetch` call *can* set all three, silently
turning a security control into decoration if any engine path goes through this route. Not
discussed in the design at all.
### C4 — The shared registry file breaks the exact premise the multi-account safety fix relies on
**Where:** §5.1 (`registry.json`, epoch ownership), §2.4 ("one worker per account"), §4.3 (mutex
described as "belt-and-braces"), §7.1 (`completePendingPurges()`), §8.3 cross-account, §5.5.
The mobile design's cross-account safety guarantee depends explicitly on there being **exactly
one writer process-wide** — its own JMAP client is a renderer singleton, so multi-account
simultaneous sync was out of scope for it, and its own adversarial review never examined
concurrent multi-account execution.
This design introduces multi-account-simultaneous as "a genuine capability gain" and disposes of
the concurrency consequences with a one-line "the jitter matters more here" — but the epoch
value (the fencing token the whole safety guarantee rests on) lives in `registry.json`, a single
JSON file shared across every account. No SQLite transaction covers a plain JSON file. The
argument that a real database transaction demotes the old per-account mutex to
"belt-and-braces" is correct for state stored *inside* the SQLite file, and does not apply to
`registry.json` at all — which names no owner thread, no lock, and no atomic-write discipline.
Two concrete failures:
1. **Lost epoch bump.** Worker A read-modify-writes the registry to bump account A's epoch
(purge, clear, logout). Worker B, holding a stale parse, writes its own update and clobbers
A's bump. A's in-flight cycle's next commit now passes the epoch check and lands on top of a
wipe — an empty record store with a live, advanced cursor and `resyncRequired: false`, exactly
the unreachable-by-design state the mobile design's whole S1 fix exists to prevent.
2. **Torn read on a shared file.** Worker B is mid-write; the server's launch-time
`completePendingPurges()` reads and the parse throws or yields a partial object. The
documented rule ("unreadable → treated as a purge") means a transient concurrency artifact
triggers a full purge-and-rebootstrap for accounts that were perfectly fine — and because the
file is shared, one torn read can hit every account at once, not just one.
### Other critical-adjacent findings, condensed
- **H1** — the "sync enabled" toggle lives in the renderer's local storage; the server-side engine
(and its background triggers) has no way to read it, so it will materialize an encrypted store
and a keychain entry for accounts that never opted in — precisely the failure the design's own
lazy-materialization rule was meant to prevent.
- **H2** — the key-handoff sequencing assumes accounts exist at server-spawn time; they don't
(accounts are added later, by logging in). The two proposed handoff mechanisms are not
equivalent: one of them passes a nonce via the spawned process's environment variables, which
are readable by any other process running as the same OS user — defeating the entire point of
using the OS keychain in the first place. Needs re-sequencing plus picking the other mechanism
on security grounds, not "whichever is cleaner to implement."
- **H3** — the "no optimistic-mutation layer exists, so nothing to keep coherent" claim is false;
the webmail already does local-delta arithmetic on mailbox unread counts and totals for
mark-read/move/delete actions, with a comment referencing a prior production bug from getting
this exact kind of cutoff wrong. A read-only offline cache sitting underneath that arithmetic
needs an explicit coherence story, which the design currently declares unnecessary.
- **H4** — no cap specified on how many accounts sync simultaneously; since this is the same
process serving the live webmail UI, an unbounded background sync could contend for the same
rate-limited server connection as the user's foreground activity, throttling their visible mail
during their own multi-account first sync.
- Several medium/low findings: one proposed API call is Linux-only and would crash the app on
macOS/Windows if implemented as literally described; the Linux keychain fallback behavior is
described slightly wrong (Electron already fails safely there; the real hazard is a *different*
API a future maintainer might reach for); the claim that two SQLite bindings are "the same code
either way" doesn't hold — verified real API differences exist between them; the "single-user"
safety check for the hosted-deployment gate doesn't actually verify what it claims to.
---
## What the reviewer independently re-verified (not just re-read)
Re-ran two of the design's three "verified by execution" claims independently, in Electron 43.2.0
itself under the same execution mode the standalone server actually uses:
1. **`@signalapp/sqlcipher@4.0.3` in Electron 43 — fully re-confirmed by actual re-execution.**
Loads with no rebuild, real SQLCipher encryption confirmed (encrypted header, no plaintext
canary recoverable from raw bytes, wrong key correctly rejected). The strongest part of the
original design.
2. **`node:sqlite`'s `PRAGMA key` silent no-op — fully re-confirmed by actual re-execution.** No
throrw, mailbox left in cleartext, canary recoverable from raw bytes. The design is right to
call this the sharpest landmine found and to mandate a positive verification check after every
store open (though the exact check needs a small correction — checking for a non-empty
*string* rather than a non-empty *result set*, since the no-cipher case returns zero rows, not
an empty string, and a naive string comparison would pass vacuously).
3. **The Linux keychain-fallback claim — not independently confirmed, and partially contradicted**
by reading Electron's own source and current documentation (no Linux desktop was available to
actually execute this one). The decision made (refuse outright rather than risk a false sense
of security) stays correct regardless and costs nothing, but the specific mechanism described
needs correcting.
## Recommended gate
Do not start implementation as currently written. Resolve in this order:
1. **C1** — decide the dependency-installation shape so the existing Docker builds keep working;
add a Docker-build check to the first implementation step's own verification list.
2. **C2 + C3** — specify the credential lifecycle end to end: how a background worker actually
gets credentials, where they live, how long, how invalidation reaches them, and how token
refresh can work given rotation needs to land in the browser's cookie jar, not a discarded
response. This may change the process-architecture verdict; re-run that comparison honestly
rather than inheriting the original conclusion.
3. **C4** — name a single owner (or a real lock plus atomic write) for the shared registry file,
and re-derive the multi-account safety guarantee under concurrent writers rather than citing
the mobile design's single-writer proof as if it still applied.
4. **H1** — decide where the "sync enabled" setting needs to live (or how the engine learns it)
so lazy materialization is actually enforceable from where the engine's triggers fire.
5. **H2** — pick the handoff mechanism that doesn't leak via process environment variables, and
re-sequence it for accounts that don't exist yet at process-spawn time.
6. **H3** — add real coherence rules for the counters/totals the webmail already computes locally,
or narrow the offline read path to skip anything those computations touch.
7. **H4** — state a concurrency bound and a rule that foreground user activity isn't starved by
background multi-account sync.
8. The smaller medium/low findings should land in the same pass since they're cheap to fix once
noticed.
Everything reused from the mobile design's core sync-engine logic is safe to build against as
written — the problems are entirely in the four sections that are genuinely new to this platform.
+311
View File
@@ -0,0 +1,311 @@
# VNCmail+ Native & Desktop Client — Build Manual
Status: living document, last updated 2026-08-04. This is the canonical reference for the
program that takes VNCmail+ (Bulwark) beyond the hosted webmail: an Electron desktop client, a
React Native mobile client, and a self-hosted push relay, working toward true offline mail with
an encrypted local index. It consolidates everything decided and built so far across three
repositories, so nothing lives only in chat history or a session's memory.
Companion documents:
- `docs/OFFLINE-CLIENT-ARCHITECTURE.md` (in `~/vncmail-plus`) — the original gap analysis this
program is based on.
- `~/.claude/skills/VNCprodbuild/SKILL.md` — the step-by-step build plan this manual reports
progress against. That file is the operational checklist; this file is the narrative reference.
---
## 1. Why this program exists
Bulwark/VNCmail+ is a Next.js JMAP webmail app. As shipped, it has zero offline capability: the
service worker caches nothing by design, there's no local mail store, no local search index, and
no mobile or desktop native client. The goal of this program is to change that — ship a desktop
app, a mobile app, real push notifications, and (eventually) a true offline-first local data
layer with an encrypted search index — without re-deriving work that already exists upstream or
duplicating effort across repos.
The single most important strategic fact discovered along the way: **an upstream React Native
mobile client already exists and already solves most of what looked like the hardest problems**
(auth, multi-account, device pairing, Android push). Building a second mobile client from
scratch (e.g. wrapping the webmail in Capacitor) would have thrown that away for no reason. The
whole shape of this program reflects that discovery — see §4.
## 2. Repository map
All three repos are AGPL-3.0 forks of the upstream Bulwark project (`bulwarkmail` on GitHub),
owned by `brvncde-dotcom`:
| Repo | Forked from | Purpose | Local path |
|---|---|---|---|
| `vncmail-plus` | `bulwarkmail/webmail` | The Next.js webmail app itself — mail, calendar, contacts, files, admin, plugins. Deploys as a container on microk8s at `vncmail.sandbox.vnc.de`. | `~/vncmail-plus` (⚠️ shared checkout — see §8) |
| `vncmail-native` | `bulwarkmail/native` | React Native/Expo mobile client (Android + iOS). Beta/WIP upstream. | `~/vncmail-native` |
| `vncmail-relay` | `bulwarkmail/relay` | Push notification relay — terminates JMAP `PushSubscription` pushes, forwards to FCM (mobile) or Web Push (PWA/desktop). Self-hosted per the decision in §4. | `~/vncmail-relay` |
The webmail's Electron desktop work happens in a **dedicated worktree**, not the shared
checkout directly: `~/worktrees/vncmail-electron`, branch `claude/electron-desktop`, based off
`vncmail-plus`'s `dev` branch. This will eventually become a PR into `dev`.
Backend: Stalwart Mail Server (`stalwartlabs/mail-server`), sandbox instance at
`stalwart.sandbox.vnc.de`, speaking JMAP (mail/calendar/contacts), SMTP, IMAP, and ManageSieve.
## 3. Architecture recap
**The core blocker for "true offline":** `lib/jmap/client.ts` in the webmail is pure `fetch()`,
zero Node dependencies — it's portable into any WebView, Electron renderer, or React Native
context unchanged. But everything *around* it in the webmail — auth-cookie encryption
(`lib/auth/crypto.ts`, uses Node's `node:crypto`), the push relay wiring, and every `app/api/**`
route — is server-dependent. A native shell that just points a WebView at a bundled static
export of the webmail won't work without either:
- **Option A — remote shell.** The native wrapper loads the *hosted* URL. Fast, gets native push
and an installable binary, but requires connectivity for every screen — not offline.
- **Option B — true offline-first.** The client authenticates and syncs JMAP data directly against
Stalwart, keeps a local encrypted store, and only needs connectivity to *sync*, not to *read*.
For **desktop**, this fork-in-the-road barely matters: Electron can bundle the webmail's own
standalone Next.js server (the same artifact the `Dockerfile` already produces for the Docker
image) inside its Node runtime and point a `BrowserWindow` at `localhost`. That's Option A and B
at the same time, practically for free — see §5.
For **mobile**, the fork-in-the-road is real, which is why §4's discovery mattered so much: it
meant Option A was already mostly done upstream, letting the plan skip straight to figuring out
what Option B (the real offline engine) needs — instead of re-building Option A from scratch in
Capacitor first.
## 4. Decision log
Every entry here was an explicit `[DECISION]` gate in the `VNCprodbuild` skill — resolved either
by direct research/verification or by explicit user sign-off. Dates are when each was resolved.
| Date | Decision | Resolution | Why |
|---|---|---|---|
| 2026-08-04 | Does an offline cache need to support multiple accounts per device? | **Yes** | The webmail already has an `account-registry` store; the mobile offline cache must isolate per-account, including per-account SQLCipher keys later. |
| 2026-08-04 | Does an upstream React Native app already solve push/pairing? | **Yes — `bulwarkmail/native`** has multi-account JMAP auth, full QR cross-device pairing, and Android FCM push via `bulwarkmail/relay`. Forked to `vncmail-native`. | Duplicating working auth/pairing/push code in a fresh Capacitor wrapper has no upside. **This flipped the entire mobile strategy** — see §6. |
| 2026-08-04 | Self-host the push relay, or depend on upstream's shared hosted instance? | **Self-host.** Forked `bulwarkmail/relay``vncmail-relay`. | Keeps push metadata (FCM tokens, timing) on VNC infrastructure rather than a third party's. |
| 2026-08-04 | Which SQLite library for the eventual SQLCipher-backed local index, and what Expo workflow? | **`expo-sqlite`'s official `useSQLCipher` config-plugin option** (verified via web search — Android/iOS/macOS support, [docs](https://docs.expo.dev/versions/latest/sdk/sqlite/)), not a third-party binding. **Stay Continuous Native Generation** (don't commit `ios`/`android`, let `expo prebuild` regenerate them) rather than going fully bare. | The official plugin already covers this. SQLCipher is unusable in Expo Go — day-to-day development necessarily moves to a custom dev client, which the user explicitly accepted. Going bare would turn every future merge from upstream `bulwarkmail/native` into a native-project merge conflict, for no offsetting benefit. |
| 2026-08-04 | Electron's background/foreground notification strategy: JMAP WebSocket push, polling, or quit-to-tray? | **JMAP WebSocket push, implemented with automatic SSE fallback — see caveat below.** Confirmed live and available: the Stalwart sandbox's JMAP session resource advertises `urn:ietf:params:jmap:websocket` with `supportsPush: true` (`wss://stalwart.sandbox.vnc.de/jmap/ws`), independently confirmed by two separate build agents. | Lower latency, no polling/backoff logic to write, and it's not hypothetical — it's live on the server this build already targets. |
| 2026-08-04 | Code-signing: Apple Developer ID? Windows cert? | **Apple: yes** (also unblocks Phase 2's iOS push) — **human must actually enroll**, no agent can create the account or pay the ~$99/yr fee. **Windows: yes, eventually** — but ship unsigned for now during this build phase. | Signing is a purchase/account-creation action, categorically outside what an agent can do. |
## 5. Phase 1 — Electron desktop client
**Location:** `~/worktrees/vncmail-electron`, branch `claude/electron-desktop`, 7+ commits ahead
of `dev` as of this writing. Not pushed, no PR yet — review the worktree directly first.
### What exists
- `electron/main.ts` — boots the exact standalone Next.js server artifact the `Dockerfile`
already produces, as a child process on a random localhost port; opens a `BrowserWindow`
pointed at it. No parallel server-bundling approach was invented.
- `electron/preload.ts``contextBridge` exposing `window.vnc.isElectron` and
`window.vnc.showNotification(title, options)`, wired to Electron's native `Notification` API in
the main process.
- `e2e/electron-smoke.spec.ts` + `playwright.electron.config.ts` — the regression gate, using
Playwright's `_electron.launch()`. Run via `npm run test:electron`. Verified green, including
against a real packaged (`--dir`) build, not just the dev skeleton.
- `electron-builder.config.js` + `scripts/assemble-standalone.mjs` + `scripts/build-electron.mjs`
— packaging for macOS (`dmg`/`zip`, x64+arm64), Windows (`nsis`), Linux (`AppImage`/`deb`).
Currently unsigned.
- `electron-updater` wired against GitHub Releases (`brvncde-dotcom/vncmail-plus`), defensively
wrapped so a failed update check never crashes the app.
- `.github/workflows/electron-build.yml` — CI matrix (mac/win/linux) with `npm run test:electron`
as a required gate before packaging/upload.
### How to build and run it locally
```bash
cd ~/worktrees/vncmail-electron
npm install
npm run electron:dev # dev loop against the local Next dev server
npm run build:standalone # produces the standalone server artifact (same as Docker uses)
npm run build:electron # packages via electron-builder (unsigned)
npm run test:electron # the smoke-test regression gate
```
### Two real bugs found and fixed while building this (worth knowing about)
1. **Repo-wide eslint gap.** `vnc/plugins/smime` (an independent sub-package) was missing from
the eslint ignore list, so `npm run lint` / the husky pre-commit hook failed for *any* commit
touching that path on `dev`, regardless of what changed. Fixed alongside `repos/**`/
`examples/**`. **Flag this for whoever reviews the eventual PR** — it's a shared-config fix
unrelated to Electron, worth landing on `dev` on its own merits.
2. **electron-builder `extraResources` footgun.** electron-builder's resource-copy step
unconditionally drops any directory literally named `node_modules` when copying
`extraResources` — it was silently stripping the bundled standalone server's dependencies and
crashing on launch with `Cannot find module 'next'`. Caught only because the build was
actually launched and tested, not just configured. Worth remembering for any future
electron-builder work generally, not just this project.
### Still open
- **JMAP WebSocket push implementation** (skill steps 6-7) — **DONE.** `getWebSocketUrl()`
discovers the endpoint from the session's own capability object (never hardcoded), with
exponential-jitter reconnect (200ms base / 5s cap / 3-attempt circuit breaker) and a 30s
heartbeat, falling back to the existing SSE/polling chain on failure. A real end-to-end
integration test (`integration/tests/11-electron-notification.spec.ts`) logs into the actual
Stalwart docker fixture, injects mail over real SMTP, and asserts the native notification
fires — not a mocked path. Two real bugs were found and fixed building this: production CSP
blocked `wss:` outright (the feature was completely inert in any production build until
fixed), and the original backoff timing had a window where a real delivery could be silently
missed during a retry cycle.
**Caveat, found empirically against the real sandbox server:** `stalwart.sandbox.vnc.de`'s
`/jmap/ws` endpoint requires the same HTTP `Authorization` header as every other JMAP endpoint
*on the WebSocket handshake itself* — which the browser `WebSocket` API cannot attach (browsers
don't allow custom headers on the handshake request). Against this specific server, the client
will therefore always fail the WS handshake and fall back to SSE — correctly, by design, but it
means "live WebSocket push" is currently unreachable in practice from a browser/Electron
client, not just theoretically available. Fixing this for real would need a server-side
accommodation (e.g. a short-lived token passed as a WS subprotocol or query parameter) — that's
a Stalwart-side change, out of scope for this client work. Functionally nothing is broken (SSE
fallback works), but don't expect WS to actually engage against this sandbox until that's
addressed.
- **Code signing** — blocked on the human actually enrolling in the Apple Developer Program
(§4). Once done, wiring the signing identity + notarization into `electron-builder` and CI
secrets is a config change, not a rewrite — the current config is structured for it.
- **App icon** — using the 512×512 PWA icon as a stand-in.
`public/branding/Bulwark_Icon_App.svg` should be rasterized at 1024×1024+ for a proper icon; no
SVG rasterization tooling was available in-agent.
- **Internal dogfood gate** — a human should install an unsigned build locally and sign off on
UX before this goes any further (wider rollout, PR, etc.).
## 6. Phase 2 — Native mobile client + push relay
### 6.1 `vncmail-native` — what it already had vs. what this program added
Upstream `bulwarkmail/native` (forked as-is, no rewrite) already ships:
- Multi-account JMAP sign-in against any server.
- Full QR-code cross-device pairing (`src/screens/LoginScreen.tsx`, `QrScanModal`,
`redeemPairingCode`/OAuth handoff in `src/lib/oauth.ts`).
- Android push notifications via FCM, dispatched through `bulwarkmail/relay`.
- A basic offline mail cache (`src/lib/offline-sync.ts`, `src/stores/offline-cache-store.ts`) —
bulk-downloads the last N days of mail via `Email/query`+`Email/get` into AsyncStorage, with a
size cap and eviction. **Not** the delta-sync/SQLCipher/FTS engine §7 describes — a periodic
bulk re-download, not incremental sync, plain JSON not an encrypted database.
- Android + iOS release pipelines already working (`release-android.yml` sideloads an APK from
GitHub Releases; `release-ios.yml` + `docs/ios-release.md` ship to TestFlight) — iOS *builds*
already work, just without push (Android-only so far per its own README).
This program's first pass (2026-08-04) added, without touching any of the above:
- Verified `npm install`, typecheck, and the existing test suite all pass cleanly (429/430 tests;
one pre-existing, unrelated transform failure in `src/stores/__tests__/auth-store.test.ts`, not
introduced by this work — worth a look eventually, not urgent).
- Confirmed live reachability to `stalwart.sandbox.vnc.de` (HTTP 307 → `/jmap/session`, valid
JMAP session JSON returned) — and incidentally re-confirmed the WebSocket push capability from
§4/§5.
- Added `.github/workflows/android-emulator-smoke.yml` — builds the debug APK, boots a cached
AVD via `reactivecircus/android-emulator-runner`, installs, launches the app, fails on process
death or a `FATAL EXCEPTION` in logcat within a settle window.
### How to build and run it locally
```bash
cd ~/vncmail-native
npm install
npx expo start # Expo Go works fine UNTIL SQLCipher is added (§4) — see note below
```
**Once SQLCipher work starts (§7):** switch to a custom dev client — `npx expo prebuild` +
`npx expo run:android` / `npx expo run:ios`, or an EAS development build. Expo Go cannot run an
app with `useSQLCipher` enabled. Do not commit the generated `ios`/`android` directories —
Continuous Native Generation regenerates them from config plugins on each build (§4).
### 6.2 `vncmail-relay` — self-hosted push relay
Forked as-is from `bulwarkmail/relay`. This program added:
- `deploy/k8s/{namespace,pvc,secret.example,deployment,service,ingress,kustomization}.yaml` +
`deploy/k8s/README.md` — mirrors the conventions already used to deploy `vncmail-plus` on
microk8s (same namespace, same Recreate-strategy/PVC pattern). **One deliberately unresolved
item:** the relay's own Dockerfile creates its runtime user via unpinned `adduser -S` (unlike
`vncmail-plus`'s documented uid 1001) — `runAsUser`/`fsGroup` are left unset in the manifest
with instructions to verify against the real built image before first deploy, rather than
guessing a UID.
- `.github/workflows/docker-publish.yml` — publishes to `ghcr.io/brvncde-dotcom/vncmail-relay`,
same multi-arch buildx/digest-merge structure as `vncmail-plus`'s own publish workflow.
- `SETUP-VNC.md` — documents a generated VAPID keypair (values are in that file only, referenced
by name — not the actual secret — in `secret.example.yaml`'s placeholders) and flags what's
still human-owned before this can go live: a dedicated Firebase project + its FCM
service-account JSON.
**Not done, and deliberately so:** no `kubectl apply` was run — there is no kubeconfig available
in the build environment; deploying is a human-only action. The manifests and a full runbook are
ready in `deploy/k8s/README.md`, waiting on:
1. Create a dedicated Firebase project (not reusing `vncmail-plus`'s or `src-website`'s) and
generate its service-account JSON.
2. `kubectl apply` the manifests (with real secrets substituted for `secret.example.yaml`'s
placeholders) against the microk8s cluster.
3. Once the relay is live and reachable (e.g. `vncmail-relay.sandbox.vnc.de`), repoint both
`vncmail-plus`'s `DEFAULT_RELAY_BASE_URL` and `vncmail-native`'s equivalent relay base URL
(check `src/api/push.ts`/`src/lib/push-notifications.ts`) at it instead of upstream's shared
instance. Re-run the webmail's existing Web Push smoke path end-to-end against the new relay
before treating it as the default.
## 7. Remaining roadmap (not yet started)
In rough order, per the `VNCprodbuild` skill:
1. **iOS push (`vncmail-native`)** — blocked on the human's Apple Developer Program enrollment
(§4/§5). `vncmail-native` already builds for iOS and ships via TestFlight; only push and
client certs are missing.
2. **JMAP delta-sync engine** — replace `offline-sync.ts`'s bulk AsyncStorage download with a
real `Email/changes`/`Mailbox/changes` cursor-based incremental sync. **This is the
highest-stakes step in the entire program** — the skill calls for high/xhigh reasoning effort
plus an independent, fresh-context agent adversarially reviewing the design before any
implementation starts. Not yet begun.
3. **SQLCipher local store** — swap AsyncStorage for `expo-sqlite` with `useSQLCipher: true`
(§4), one isolated database/key per account (multi-account confirmed required, §4). Needs an
explicit, security-sign-off decision on key derivation/lifecycle (from-password vs.
device-random-key wrapped by biometric; wipe-on-logout) before implementation — do not let an
agent default this silently.
4. **FTS5 search index** — SQLite FTS5 population job tied to the sync engine above.
5. **Offline compose/outbox** — queue composed messages while offline, replay via JMAP
`Email/set` on reconnect, handle conflicts.
6. **Platform hardening** — background refresh scheduling (`BGTaskScheduler`/`WorkManager`),
Apple export-compliance declaration (`ITSAppUsesNonExemptEncryption`, triggered once SQLCipher
ships in the iOS binary — an agent can draft the text, only a human can file it), Google Play
Console account/signing key, final store submissions.
7. **Fix the webmail's own no-op service worker**`public/sw.js` intentionally caches nothing
today; adding Workbox-style precaching of the app shell is a cheap, independent improvement to
the PWA's offline-shell behavior, unrelated to the native-client work above.
## 8. Known landmines
- **`~/vncmail-plus` is a shared, actively-used checkout.** Other sessions commit and switch
branches there concurrently. An untracked file written directly into that checkout was lost
mid-session to a concurrent branch switch — confirmed incident, 2026-08-04. **Any work meant
to persist must go into a dedicated worktree (like `~/worktrees/vncmail-electron`) or be
committed immediately** — never leave meaningful uncommitted/untracked work sitting in the
shared checkout.
- **`~/vncmail-native` and `~/vncmail-relay` are fresh clones** (created 2026-08-04) with no
confirmed concurrent-session activity yet — lower risk today, but don't assume that stays true
as more work lands there.
- **electron-builder + `node_modules`** — see §5's bug writeup; a general electron-builder
landmine, not specific to this codebase.
- **Expo Go + SQLCipher are mutually exclusive** — see §4/§6.1. The moment `useSQLCipher` is
enabled, Expo Go can no longer run the app at all; this is a hard platform constraint, not a
configuration bug to work around.
- **Electron's random localhost port breaks JMAP login against the sandbox Stalwart —
deferred, not fixed, 2026-08-04.** User confirmed testing the packaged Electron app directly
against `stalwart.sandbox.vnc.de` (not `localhost`) hit a CORS-shaped login failure. Verified
server-side: Stalwart's own CORS headers are correctly wildcarded (`Access-Control-Allow-Origin: *`)
on every hop including the `.well-known/jmap``/jmap/session` redirect — so this is not a
Stalwart allow-list problem. Also found, separately: `vncmail.sandbox.vnc.de` (the documented
deployed webmail domain) currently does not resolve (NXDOMAIN) — unrelated to this bug but
worth knowing regardless. Leading theory, not yet confirmed against real browser devtools:
`electron/main.ts` binds the bundled Next.js server via `server.listen(0, ...)` — a random
OS-assigned port every launch — producing a different origin on every run; even if that origin
were allow-listed once, it wouldn't stay valid. **User explicitly said skip this for now**
Electron packaging/building itself works, this only affects live login against the sandbox.
Fix path when revisited: bind Electron's local server to a fixed port instead of `0`.
## 9. Before merging any of this
None of the three repos' branches described here have been pushed or opened as a PR. Before
that happens:
- Run the full existing test/lint suites in each repo, not just the new smoke tests added here.
- `vncmail-plus` has its own `VERSION`/`CHANGELOG.md` convention (currently `1.7.8`) — a version
bump belongs at actual release/merge time, not mid-feature-branch; this manual deliberately
did not touch either file.
- Cross-check the eslint-ignore fix (§5) lands even if the rest of the Electron work is split out
or delayed — it's an independent, valuable fix on its own.
+90
View File
@@ -0,0 +1,90 @@
import { test, expect, _electron as electron } from '@playwright/test';
import type { ElectronApplication, Page } from '@playwright/test';
import path from 'node:path';
// Regression gate for the Electron desktop shell (electron/main.ts +
// electron/preload.ts). Launches the real skeleton - the same standalone
// Next.js server artifact the Dockerfile produces, booted as a child
// process by main.ts, with a real BrowserWindow on top - and asserts the
// login screen renders with zero uncaught page errors. Every later step in
// the Electron rollout (notification bridge, realtime sync, packaging) must
// keep this green; run it before touching anything else.
//
// Requires `npm run build:standalone && npm run build:electron` to have run
// first (see package.json's `electron:dev`/`test:electron` scripts, which
// this suite assumes but does not itself trigger, matching how
// playwright.config.ts's browser suite assumes `npm run build` for its own
// prod-mode runs).
const projectRoot = path.resolve(__dirname, '..');
test.describe('Electron desktop shell', () => {
let electronApp: ElectronApplication;
// Named `appWindow`, not `window` - the latter would shadow the DOM
// global inside every `appWindow.evaluate(() => window...)` callback
// below, silently breaking their typing (evaluate() callbacks run in the
// browser context, where `window` must resolve to the DOM global).
let appWindow: Page;
const pageErrors: Error[] = [];
test.beforeAll(async () => {
electronApp = await electron.launch({
args: [projectRoot],
env: {
...process.env,
// Bypass the first-run setup wizard (lib/setup/state.ts's
// "bootstrap" state, which 302s everything to /setup) without
// needing a reachable JMAP server just to prove the login screen
// renders - any non-empty JMAP_SERVER_URL is enough to reach
// "env-managed" state and serve the normal app shell.
JMAP_SERVER_URL: 'https://stalwart.sandbox.vnc.de',
SESSION_SECRET: 'electron-smoke-test-not-for-production',
NODE_ENV: 'production',
},
});
appWindow = await electronApp.firstWindow();
appWindow.on('pageerror', (error) => {
pageErrors.push(error);
});
await appWindow.waitForLoadState('domcontentloaded');
});
test.afterAll(async () => {
await electronApp?.close();
});
test('boots the standalone server and renders the login screen', async () => {
// Same selectors as e2e/login.spec.ts's browser-based check - the
// shell should render the identical login form, not a different view.
await expect(appWindow.locator('input[type="text"]')).toBeVisible({ timeout: 20000 });
await expect(appWindow.locator('input[type="password"]')).toBeVisible();
});
test('exposes the contextBridge API to the renderer', async () => {
const isElectron = await appWindow.evaluate(() => window.vnc?.isElectron);
expect(isElectron).toBe(true);
});
test('produces zero uncaught page errors', () => {
expect(pageErrors).toEqual([]);
});
test('the native notification bridge round-trips through IPC', async () => {
// Not asserting a real OS toast appears - that isn't observable in CI
// (headless runners/CI accounts routinely have no notification
// permission, and Notification.isSupported() can legitimately be
// false). What matters is that window.vnc.showNotification (exposed by
// electron/preload.ts's contextBridge) actually reaches the main
// process's ipcMain.handle("vnc:show-notification", ...) and resolves -
// proving the renderer -> preload -> main -> Electron Notification API
// plumbing is wired, not just that `window.vnc` exists.
const result = await appWindow.evaluate(async () => {
return window.vnc?.showNotification('Electron smoke test', {
body: 'IPC round-trip check',
});
});
expect(result).toBeDefined();
expect(typeof result?.shown).toBe('boolean');
});
});
+94
View File
@@ -0,0 +1,94 @@
// electron-builder config for the VNCmail+ (Bulwark) desktop shell.
//
// Phase 1 of the VNCprodbuild rollout (~/.claude/skills/VNCprodbuild/SKILL.md):
// step 1 - base config, no targets (superseded by this file)
// step 6 - this file: real packaging targets + branding icon (below)
// step 7 - this file's `publish` block + electron/main.ts's
// setupAutoUpdater() - electron-updater against GitHub Releases.
// step 9 - still open: code signing / notarization (Apple Developer ID,
// optional Windows cert) - both are human-owned purchases, not
// configured here. Builds below ship UNSIGNED.
module.exports = {
appId: "de.vnc.vncmailplus",
productName: "VNCmail+",
copyright: "Copyright © VNC AG",
directories: {
output: "dist-electron-builds",
},
files: ["dist-electron/**/*", "package.json"],
extraResources: [
{
// Same artifact the Dockerfile bakes into the container image (see
// Dockerfile + scripts/assemble-standalone.mjs). electron/main.ts
// reads it from process.resourcesPath in packaged builds.
//
// Deliberately `from: ".next"` (not ".next/standalone") + a filter,
// not the more obvious `from: ".next/standalone"` alone:
// app-builder-lib's copy filter unconditionally drops a directory
// literally named "node_modules" sitting at the copy root (see
// node_modules/app-builder-lib/out/util/filter.js's
// `relative === "node_modules"` check - it assumes extraResources are
// hand-authored assets, not a pre-built server with a traced
// node_modules of its own). Copying from one level up so
// "standalone/node_modules" is never the literal copy root sidesteps
// that check, so the standalone server's node_modules actually
// survives into the packaged app instead of getting silently
// stripped (caught by manually launching a --dir build - the packaged
// server crashed with "Cannot find module 'next'").
from: ".next",
filter: ["standalone/**/*"],
to: ".",
},
],
// STAND-IN ICON, not a dedicated app icon: public/icon-512x512.png is the
// PWA manifest icon (512x512 square PNG). electron-builder can generate
// .icns/.ico from a single square PNG at build time (see
// node_modules/app-builder-lib/out/util/iconConverter.js), so this
// produces working icons for every target below - but at only 512x512,
// the largest macOS icns representation (1024x1024 "ICON512@2x") gets
// upsampled and will look soft compared to a real 1024x1024+ source.
// public/branding/Bulwark_Icon_App.svg looks like the intended master for
// this (as opposed to Bulwark_Favicon.png, sized for browser tabs), but
// it's vector and this environment has no SVG rasterizer (rsvg-convert /
// ImageMagick / Inkscape) to turn it into a proper 1024x1024 PNG. A human
// (or a follow-up step with the right tooling) should export
// Bulwark_Icon_App.svg at 1024x1024 and point `icon` at that instead.
icon: "public/icon-512x512.png",
mac: {
target: [
{ target: "dmg", arch: ["x64", "arm64"] },
{ target: "zip", arch: ["x64", "arm64"] },
],
category: "public.app-category.productivity",
// No Apple Developer ID yet (VNCprodbuild step 9) - ship unsigned/
// un-notarized for now. hardenedRuntime is meaningless without signing
// but left explicit so it's obvious what step 9 needs to flip on.
hardenedRuntime: false,
},
win: {
target: [{ target: "nsis", arch: ["x64"] }],
},
nsis: {
oneClick: false,
allowToChangeInstallationDirectory: true,
},
linux: {
target: [
{ target: "AppImage", arch: ["x64"] },
{ target: "deb", arch: ["x64"] },
],
category: "Network;Email;",
},
// electron-updater feed (see electron/main.ts's setupAutoUpdater()).
// GitHub Releases, not a new distribution channel - the skill's
// recommendation since this repo is already private and this needs no
// extra infrastructure. "Light decision" per VNCprodbuild step 7, not
// blocking, but flagged: switching later (e.g. to a self-hosted update
// server) would mean revisiting this block and the `provider` electron-
// updater talks to.
publish: {
provider: "github",
owner: "brvncde-dotcom",
repo: "vncmail-plus",
},
};
+231
View File
@@ -0,0 +1,231 @@
// Main-process key service for the local search index.
//
// The index database is SQLCipher-encrypted with a random per-account 32-byte
// key. That key is wrapped with Electron's `safeStorage` (OS keychain / DPAPI /
// libsecret-or-kwallet) and stored under the store directory. Only the main
// process can call `safeStorage`, but the index itself lives in the standalone
// Next.js server child process - so the unwrapped key has to cross one process
// boundary.
//
// TRANSPORT: an inherited file descriptor (fd 3), NOT an environment variable.
// A nonce or key passed through the spawned process's environment is readable
// by any other process running as the same OS user (`ps eww`, /proc/<pid>/environ),
// which would defeat the entire point of using the OS keychain. An inherited fd
// is not exposed to process listing. libuv creates extra stdio "pipe" entries
// as socketpairs, so fd 3 is duplex - verified by execution through Electron's
// own spawn before this was built on.
//
// The server side asks for a key only when a reindex job actually runs and drops
// it when the job finishes (see lib/mail-index/key.ts) - there is no long-lived
// resident copy anywhere.
import { safeStorage } from "electron";
import { createHash, randomBytes } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import type { Readable, Writable } from "node:stream";
/** Must match lib/mail-index/paths.ts's accountFileToken(). */
function accountFileToken(accountId: string): string {
return createHash("sha256").update(accountId, "utf8").digest("hex").slice(0, 32);
}
function keyFilePath(storeDir: string, accountId: string): string {
return path.join(storeDir, "keys", `${accountFileToken(accountId)}.bin`);
}
export type KeyServiceFailure =
/** No OS keyring at all - see the Linux note in checkEncryptionAvailable(). */
| "no-secure-storage"
/** Reading/writing the wrapped key file failed. */
| "key-io-failed"
/** The wrapped key exists but safeStorage could not decrypt it. */
| "key-unreadable";
export class KeyServiceError extends Error {
code: KeyServiceFailure;
constructor(code: KeyServiceFailure, message: string) {
super(message);
this.name = "KeyServiceError";
this.code = code;
}
}
/**
* Decides whether we are willing to store an encryption key on this system.
*
* The Linux caveat is the reason this is a function and not a one-liner:
* `safeStorage.isEncryptionAvailable()` can return **true** on Linux while the
* data is protected by a hardcoded, publicly-known password, with
* `getSelectedStorageBackend()` reporting `basic_text`. That is worse than an
* honest failure, because it looks like it worked. So a `basic_text` backend is
* treated as "no secure storage" and the feature refuses to materialise
* anything - the index is a convenience, and silently pretending a mailbox is
* encrypted when it is not is not a trade worth making.
*
* `getSelectedStorageBackend()` is **Linux-only** and throws elsewhere, hence
* the platform guard. Both calls also require `app.whenReady()`.
*/
export function checkEncryptionAvailable(): { ok: true } | { ok: false; reason: string } {
if (!safeStorage.isEncryptionAvailable()) {
return { ok: false, reason: "The OS reports no secure storage available for encryption keys." };
}
if (process.platform === "linux") {
let backend: string;
try {
backend = safeStorage.getSelectedStorageBackend();
} catch {
// Older/newer Electron, or called too early. Be conservative.
return { ok: false, reason: "Could not determine the Linux secret-storage backend." };
}
if (backend === "basic_text" || backend === "unknown") {
return {
ok: false,
reason:
`No OS keyring is available (backend: ${backend}). Electron would "encrypt" the key ` +
`with a hardcoded password, which provides no real protection, so the encrypted ` +
`local index is disabled on this system.`,
};
}
}
return { ok: true };
}
/** Fetches the account's raw index key, creating and wrapping one on first use. */
function getOrCreateKey(storeDir: string, accountId: string): Buffer {
const availability = checkEncryptionAvailable();
if (!availability.ok) {
throw new KeyServiceError("no-secure-storage", availability.reason);
}
const file = keyFilePath(storeDir, accountId);
if (fs.existsSync(file)) {
let wrapped: Buffer;
try {
wrapped = fs.readFileSync(file);
} catch (error) {
throw new KeyServiceError("key-io-failed", `Could not read the key file: ${String(error)}`);
}
let hex: string;
try {
hex = safeStorage.decryptString(wrapped);
} catch (error) {
// Most likely cause on macOS: the app's code identity changed (unsigned
// builds get a fresh ad-hoc signature per build), so the Keychain ACL no
// longer matches. Not recoverable and not a user secret - the caller
// deletes the database and re-indexes.
throw new KeyServiceError(
"key-unreadable",
`The stored key could not be decrypted (${String(error)}). It must be recreated.`,
);
}
const key = Buffer.from(hex.trim(), "hex");
if (key.length === 32) return key;
// Corrupt payload: fall through and mint a new one.
}
const key = randomBytes(32);
let wrapped: Buffer;
try {
wrapped = safeStorage.encryptString(key.toString("hex"));
} catch (error) {
throw new KeyServiceError("no-secure-storage", `Could not wrap the key: ${String(error)}`);
}
try {
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
// Write-then-rename so a crash mid-write cannot leave a truncated wrapped
// key that would look like "key-unreadable" forever.
const tmp = `${file}.tmp-${process.pid}`;
fs.writeFileSync(tmp, wrapped, { mode: 0o600 });
fs.renameSync(tmp, file);
} catch (error) {
throw new KeyServiceError("key-io-failed", `Could not persist the key: ${String(error)}`);
}
return key;
}
function deleteKey(storeDir: string, accountId: string): void {
try {
fs.rmSync(keyFilePath(storeDir, accountId), { force: true });
} catch {
/* best effort - the caller is purging anyway */
}
}
interface Request {
id?: unknown;
op?: unknown;
accountId?: unknown;
}
/**
* Serves newline-delimited JSON requests from the standalone server over the
* inherited fd. One line in, one line out, no streaming and no state.
*/
export function attachKeyService(
channel: (Readable & Writable) | null | undefined,
storeDir: string,
): void {
if (!channel) {
console.error("[electron] key service: no channel on fd 3; the local index will be disabled");
return;
}
let buffer = "";
const respond = (payload: Record<string, unknown>) => {
try {
channel.write(`${JSON.stringify(payload)}\n`);
} catch (error) {
console.error("[electron] key service: failed to write response:", error);
}
};
channel.on("data", (chunk: Buffer | string) => {
buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
// Guard against a peer that never sends a newline.
if (buffer.length > 64 * 1024) buffer = "";
let newline: number;
while ((newline = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
if (!line.trim()) continue;
let req: Request;
try {
req = JSON.parse(line) as Request;
} catch {
respond({ id: null, ok: false, code: "bad-request", error: "Malformed request" });
continue;
}
const id = typeof req.id === "number" ? req.id : null;
const accountId = typeof req.accountId === "string" ? req.accountId : "";
if (!accountId) {
respond({ id, ok: false, code: "bad-request", error: "Missing accountId" });
continue;
}
try {
if (req.op === "getIndexKey") {
const key = getOrCreateKey(storeDir, accountId);
respond({ id, ok: true, key: key.toString("hex") });
key.fill(0);
} else if (req.op === "deleteIndexKey") {
deleteKey(storeDir, accountId);
respond({ id, ok: true });
} else {
respond({ id, ok: false, code: "bad-request", error: `Unknown op: ${String(req.op)}` });
}
} catch (error) {
const code = error instanceof KeyServiceError ? error.code : "key-io-failed";
const message = error instanceof Error ? error.message : String(error);
respond({ id, ok: false, code, error: message });
}
}
});
channel.on("error", (error: unknown) => {
console.error("[electron] key service channel error:", error);
});
}
+269
View File
@@ -0,0 +1,269 @@
// Electron main process for the VNCmail+ (Bulwark) desktop shell.
//
// Boots the exact same Next.js "standalone" server artifact the Dockerfile
// already produces for production (see next.config.ts's `output:
// "standalone"` and the Dockerfile's builder stage) as a child process on a
// random localhost port, then opens a BrowserWindow pointed at it. This is
// deliberately the same server, not a reimplementation - lib/jmap/client.ts
// and every app/api/** route behave identically to the web deployment.
import { app, BrowserWindow, ipcMain, Notification } from "electron";
import { autoUpdater } from "electron-updater";
import { spawn, type ChildProcess } from "node:child_process";
import { createServer } from "node:net";
import { get as httpGet } from "node:http";
import path from "node:path";
import fs from "node:fs";
import type { Duplex } from "node:stream";
import { attachKeyService, checkEncryptionAvailable } from "./key-service";
let serverProcess: ChildProcess | null = null;
let mainWindow: BrowserWindow | null = null;
/**
* Root for the encrypted local search index (lib/mail-index/**). Under
* `userData`, so it is per-OS-user and removed with the app's data.
*
* Passing this to the server child process is what ACTIVATES the index: the
* routes 404 without it. That matters because the standalone server is the same
* artifact the production Dockerfile ships to multi-tenant deployments, where a
* server-side index of every user's mail would be badly wrong. One variable
* both enables the feature and supplies its path, so the two cannot drift apart.
*/
function getIndexStoreDir(): string {
return path.join(app.getPath("userData"), "offline");
}
/**
* Locates the standalone server's entrypoint. Packaged builds ship it as an
* extraResource (see electron-builder.config.js) because .next/standalone
* isn't inside the app.asar; dev runs read it straight out of the repo via
* `npm run build:standalone`.
*/
function getStandaloneServerEntry(): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, "standalone", "server.js");
}
return path.join(app.getAppPath(), ".next", "standalone", "server.js");
}
function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (address && typeof address === "object") {
const { port } = address;
server.close(() => resolve(port));
} else {
server.close(() => reject(new Error("Could not allocate a free localhost port")));
}
});
});
}
function waitForServerReady(url: string, timeoutMs = 20000): Promise<void> {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve, reject) => {
const attempt = () => {
const req = httpGet(url, (res) => {
res.resume();
resolve();
});
req.on("error", () => {
if (Date.now() > deadline) {
reject(new Error(`Standalone server never became reachable at ${url}`));
return;
}
setTimeout(attempt, 200);
});
};
attempt();
});
}
async function startStandaloneServer(): Promise<string> {
const serverEntry = getStandaloneServerEntry();
if (!fs.existsSync(serverEntry)) {
throw new Error(
`Standalone Next.js server not found at ${serverEntry}. Run "npm run build:standalone" first.`,
);
}
const port = await getFreePort();
const url = `http://127.0.0.1:${port}`;
const storeDir = getIndexStoreDir();
const encryption = checkEncryptionAvailable();
if (!encryption.ok) {
// Refuse rather than degrade. On Linux with no keyring, safeStorage
// "succeeds" using a hardcoded public password, which would look like an
// encrypted mailbox index while providing no protection. Leaving the env
// vars unset makes every index route 404, so the app runs normally without
// the feature.
console.error(`[electron] local search index disabled: ${encryption.reason}`);
}
// Spawn the Electron binary itself as a plain Node process
// (ELECTRON_RUN_AS_NODE) instead of depending on a system Node install -
// the packaged app can't assume Node exists on the target machine, and
// this keeps dev/packaged behavior identical.
//
// stdio gains a 4th entry: fd 3 is the key channel for the local index (see
// electron/key-service.ts). libuv creates extra stdio "pipe" entries as
// socketpairs, so it is duplex in both directions - verified by execution
// before this was built on. Deliberately NOT an environment variable: env is
// readable by any process running as the same OS user, which would defeat
// using the OS keychain at all. The fd NUMBER below is not a secret; only
// what travels over it is.
serverProcess = spawn(process.execPath, [serverEntry], {
env: {
...process.env,
ELECTRON_RUN_AS_NODE: "1",
PORT: String(port),
HOSTNAME: "127.0.0.1",
NODE_ENV: process.env.NODE_ENV || "production",
...(encryption.ok
? { VNCMAIL_DESKTOP_STORE_DIR: storeDir, VNCMAIL_DESKTOP_KEY_FD: "3" }
: {}),
},
stdio: encryption.ok
? ["inherit", "inherit", "inherit", "pipe"]
: "inherit",
});
if (encryption.ok) {
attachKeyService(serverProcess.stdio[3] as Duplex | null, storeDir);
}
serverProcess.on("exit", (code, signal) => {
if (code !== 0 && code !== null) {
console.error(`[electron] standalone server exited early (code=${code}, signal=${signal})`);
}
serverProcess = null;
});
await waitForServerReady(url);
return url;
}
function stopStandaloneServer(): void {
if (serverProcess && !serverProcess.killed) {
serverProcess.kill();
}
serverProcess = null;
}
async function createMainWindow(): Promise<void> {
// Test-only escape hatch: when set, skip spawning the standalone server
// entirely and load this URL instead. Used by
// integration/tests/11-electron-notification.spec.ts, which needs a
// dev-mode Next.js server (proxy.ts's CSP only widens connect-src to
// allow plain-HTTP/ws JMAP in dev - see that file's comments) to reach
// the integration fixture's deliberately-plaintext local Stalwart,
// exactly the same trade-off integration/webmail.Dockerfile already makes
// for the browser-based integration suite. Never set by real users or by
// any of the packaging/CI paths - those always go through
// startStandaloneServer() below.
const url = process.env.ELECTRON_LOAD_URL || (await startStandaloneServer());
mainWindow = new BrowserWindow({
width: 1280,
height: 860,
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
mainWindow.on("closed", () => {
mainWindow = null;
});
await mainWindow.loadURL(url);
}
// --- Native notification bridge --------------------------------------------
// Called from the preload's `window.vnc.showNotification` (electron/preload.ts),
// itself called from lib/electron-bridge.ts's showElectronNotification(),
// itself called from app/(main)/[locale]/page.tsx's "new mail arrived"
// effect whenever lib/jmap/client.ts's push pipeline (WebSocket, or its SSE/
// polling fallback - see that file's circuit breaker) reports a genuine new
// message. Electron's own Notification API is the desktop shell's
// notification path - it sits alongside, not in place of, the browser/PWA's
// service-worker push path (public/sw.js's `push`/`notificationclick`
// handlers + lib/web-push.ts).
ipcMain.handle(
"vnc:show-notification",
(_event, title: string, options?: { body?: string; tag?: string }) => {
// Test-only observability hook, read via Playwright's
// electronApp.evaluate(({ app }) => ...) - see
// integration/tests/11-electron-notification.spec.ts. Not gated behind
// NODE_ENV: it's an inert counter with no behavioral effect, cheaper
// than maintaining a second code path just for tests.
const counters = app as unknown as { __notificationCallCount?: number };
counters.__notificationCallCount = (counters.__notificationCallCount ?? 0) + 1;
if (!Notification.isSupported()) {
return { shown: false };
}
const notification = new Notification({
title,
body: options?.body ?? "",
});
notification.show();
return { shown: true };
},
);
// --- Auto-update -------------------------------------------------------
// GitHub Releases as the update feed (electron-builder.config.js's
// `publish` block) - the skill's recommendation over standing up a new
// distribution channel, since the repo is already private. "Light
// decision" per VNCprodbuild step 7, not re-litigated here.
//
// Deliberately best-effort: there's no code signing yet (step 9), so on
// macOS in particular an update download/install can fail signature
// verification. A failed check must never take the app down - it's
// background maintenance, not something the user is blocked on.
function setupAutoUpdater(): void {
if (!app.isPackaged) {
// Unpacked dev/test runs (npm run electron:dev, the Playwright smoke
// test) have no latest.yml alongside them - checking would just log a
// noisy 404 against GitHub Releases for every dev run.
return;
}
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.on("error", (error) => {
console.error("[electron] auto-update error:", error);
});
autoUpdater.checkForUpdatesAndNotify().catch((error) => {
console.error("[electron] checkForUpdatesAndNotify failed:", error);
});
}
app.whenReady().then(() => {
void createMainWindow();
setupAutoUpdater();
});
app.on("window-all-closed", () => {
stopStandaloneServer();
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("before-quit", () => {
stopStandaloneServer();
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
void createMainWindow();
}
});
+27
View File
@@ -0,0 +1,27 @@
// Preload script for the VNCmail+ desktop shell. Runs in an isolated
// context with access to Node APIs, and exposes a minimal, explicit surface
// to the renderer via contextBridge - the renderer never gets direct Node or
// Electron access (contextIsolation + nodeIntegration: false, see main.ts).
import { contextBridge, ipcRenderer } from "electron";
export interface ShowNotificationOptions {
body?: string;
tag?: string;
}
export interface ShowNotificationResult {
shown: boolean;
}
contextBridge.exposeInMainWorld("vnc", {
isElectron: true,
// Routes to Electron's own Notification API in main.ts (ipcMain.handle
// "vnc:show-notification"). This is the desktop shell's native
// notification path - it does not replace lib/web-push.ts's Web Push
// (VAPID) path, which is what the browser/PWA deployment still uses.
showNotification: (
title: string,
options?: ShowNotificationOptions,
): Promise<ShowNotificationResult> =>
ipcRenderer.invoke("vnc:show-notification", title, options),
});
+20
View File
@@ -53,6 +53,18 @@ export default [
},
},
},
{
// Plain Node scripts (electron bundling/packaging helpers) - not React/
// browser code, so they get node globals only, no react/jsx parsing.
files: ["scripts/**/*.{mjs,cjs,js}"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.node,
},
},
},
{
files: ["**/*.test.{ts,tsx}", "**/*.spec.{ts,tsx}"],
languageOptions: {
@@ -70,6 +82,8 @@ export default [
{
ignores: [
".next/**",
"dist-electron/**",
"dist-electron-builds/**",
"node_modules/**",
"repos/**",
"data/admin/plugins/**",
@@ -81,6 +95,12 @@ export default [
"benchmark/**",
"examples/**",
"integration/**",
// Independent sub-package with its own package.json/build (esbuild,
// browser-only globals) - same reasoning as repos/** and examples/**
// above. Pre-existing gap: this was blocking `npm run lint` (and thus
// the pre-commit hook) repo-wide before this Electron work even
// touched anything - see the electron-desktop branch's first commits.
"vnc/plugins/smime/**",
],
},
];
+1
View File
@@ -7,4 +7,5 @@ stalwart/stalwart-cli
# Playwright/test artifacts
node_modules/
test-results/
test-results-electron/
playwright-report/
@@ -0,0 +1,207 @@
import { test, expect, _electron as electron } from '@playwright/test';
import type { ElectronApplication, Page } from '@playwright/test';
import { spawn, type ChildProcess } from 'node:child_process';
import { createServer } from 'node:net';
import { get as httpGet } from 'node:http';
import path from 'node:path';
import { ACCOUNTS, JMAP_URL } from './helpers/config';
import { sendMail } from './helpers/smtp';
import { JmapClient } from './helpers/jmap';
import { expectFolderUnread } from './helpers/app';
/**
* Electron desktop shell against the real Stalwart fixture, end to end.
*
* Unlike e2e/electron-smoke.spec.ts (which calls window.vnc.showNotification
* directly to prove the IPC bridge itself is wired), this launches the real
* Electron shell, logs in as a real account against this same integration
* stack's Stalwart, injects a message over SMTP exactly like
* 02-mail-sync.spec.ts does for the browser-based suite, and asserts a
* native notification fires as a side effect of the REAL push pipeline:
*
* SMTP delivery -> Stalwart -> JMAP StateChange push (lib/jmap/client.ts)
* -> stores/email-store.ts's handleStateChange -> handleNewEmailNotification
* -> app/(main)/[locale]/page.tsx's effect -> lib/electron-bridge.ts's
* showElectronNotification() -> the contextBridge/IPC bridge
* (electron/preload.ts) -> electron/main.ts's ipcMain.handle, which is
* what actually shows the OS notification (and increments the
* __notificationCallCount test hook this test polls).
*
* Nothing here is mocked - real SMTP socket, real Stalwart, real Electron
* process, real IPC.
*
* WHY A DEV SERVER, NOT THE STANDALONE BUILD: electron/main.ts normally boots
* the production "standalone" artifact (Phase 1 step 1), whose CSP
* (proxy.ts) only allows TLS connections in production (`https:`/`wss:`).
* This fixture's Stalwart is deliberately plain HTTP - the same reason
* integration/webmail.Dockerfile runs the browser-suite's webmail in dev
* mode instead of building it. This test makes the identical trade-off:
* electron/main.ts's ELECTRON_LOAD_URL escape hatch (test-only, never used
* by real users or any packaging/CI path) points the shell at a `next dev`
* server this test spawns itself, instead of the standalone build. That
* still exercises the real preload/IPC bridge, the real JMAP client
* (identical source either way), and the real notification handler - the
* only thing NOT covered here is the standalone-server-boot mechanism
* itself, which e2e/electron-smoke.spec.ts already covers separately.
*
* NOTE on "the real WebSocket path": confirmed against the actual
* `stalwartlabs/stalwart:v0.16` image this fixture runs (same as the
* sandbox server this feature was built against) that its /jmap/ws endpoint
* requires the same HTTP Authorization header as every other JMAP endpoint
* on the WebSocket UPGRADE request itself - and confirmed separately that
* the browser WebSocket API has no way to attach a custom header to that
* handshake (a WHATWG spec restriction, not a CSP or Electron quirk - CSP
* was a real, now-fixed blocker for reaching the network at all, see the
* commit that added `wss:` to proxy.ts's production connect-src, but is not
* why THIS specific handshake fails). So the WS attempt below will reach
* the network correctly but still fail authentication against Stalwart
* every time, and the client's circuit breaker (wsPermanentlyDisabled,
* after 5 quick attempts) falls back to SSE within a few seconds. That
* fallback is what actually delivers the push exercised below - a real,
* working push path, just not literally the WebSocket one. Asserting the WS
* handshake itself succeeds would be asserting something that cannot be
* true against this server from a browser context; the assertion here is
* on the thing that IS true end to end: a real delivery reaches the native
* notification bridge no matter which transport carried the StateChange.
*/
const alice = ACCOUNTS.alice;
const projectRoot = path.resolve(__dirname, '../..');
function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer();
server.unref();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (address && typeof address === 'object') {
const { port } = address;
server.close(() => resolve(port));
} else {
server.close(() => reject(new Error('Could not allocate a free localhost port')));
}
});
});
}
function waitForServerReady(url: string, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve, reject) => {
const attempt = () => {
const req = httpGet(url, (res) => {
res.resume();
resolve();
});
req.on('error', () => {
if (Date.now() > deadline) {
reject(new Error(`Dev server never became reachable at ${url}`));
return;
}
setTimeout(attempt, 300);
});
};
attempt();
});
}
async function getNotificationCallCount(app: ElectronApplication): Promise<number> {
return app.evaluate(({ app: electronApp }) => {
const counters = electronApp as unknown as { __notificationCallCount?: number };
return counters.__notificationCallCount ?? 0;
});
}
test.describe('Electron desktop shell - real push notification', () => {
test('a real SMTP delivery triggers the native notification bridge', async () => {
const jmap = await JmapClient.connect(alice.email, alice.password);
await jmap.reset();
const devPort = await getFreePort();
const devUrl = `http://127.0.0.1:${devPort}`;
// `next dev` (not the standalone build - see the header comment above
// for why) with JMAP_SERVER_URL pointed at this fixture's real Stalwart.
const devServer: ChildProcess = spawn('npx', ['next', 'dev', '--turbopack', '-p', String(devPort)], {
cwd: projectRoot,
env: {
...process.env,
JMAP_SERVER_URL: JMAP_URL,
// Must be >= 32 chars (lib/impersonation/master-config.ts) - anything
// shorter logs a "Failed to store Stalwart auth context" error on
// every request. Not a real secret either way.
SESSION_SECRET: 'integration-not-a-real-secret-32-chars!!',
NODE_ENV: 'development',
},
stdio: 'pipe',
});
devServer.stderr?.on('data', (chunk) => process.stderr.write(`[next dev] ${chunk}`));
let electronApp: ElectronApplication | undefined;
try {
// next dev's cold compile of the login route can take a while the
// first time - generous timeout, matches this suite's overall 90s
// test timeout with headroom for what comes after.
await waitForServerReady(devUrl, 60000);
electronApp = await electron.launch({
args: [projectRoot],
env: {
...process.env,
ELECTRON_LOAD_URL: devUrl,
},
});
const appWindow: Page = await electronApp.firstWindow();
await appWindow.waitForLoadState('domcontentloaded');
// Diagnosing a failure locally: temporarily add
// appWindow.on('console', (msg) => console.log(msg.type(), msg.text()));
// appWindow.on('request', (req) => { if (/jmap/i.test(req.url())) console.log(req.method(), req.url()); });
// right here - that's what surfaced the WS-then-SSE-fallback sequence
// this test now relies on, and would surface the same for whatever
// trips the retry below.
// Real login through the actual form - same selectors
// integration/tests/helpers/app.ts's submitCredentials() uses. Not
// reusing that helper directly because it also calls page.goto('/'),
// which would navigate this window away from the dev server
// electron/main.ts already loaded it against.
await appWindow.locator('#username').waitFor({ state: 'visible', timeout: 30000 });
await appWindow.fill('#username', alice.email);
await appWindow.fill('#password', alice.password);
await appWindow.click('button[type="submit"]');
await appWindow.locator('[data-testid="account-switcher"]').first().waitFor({ state: 'visible', timeout: 30000 });
// The account switcher rendering only means the sidebar chrome is up,
// not that the Inbox has actually loaded/been auto-selected yet - the
// "new mail" notification only fires when handleStateChange's refresh
// finds an actively-SELECTED inbox (stores/email-store.ts's
// refreshCurrentMailbox() early-returns with no selectedMailbox).
// Same wait 02-mail-sync.spec.ts's very first test uses right after
// login, before its own first delivery, for exactly this reason.
await expectFolderUnread(appWindow, { role: 'inbox' }, 0);
// Baseline before triggering delivery, so this assertion is robust
// even if a stray notification fired during login/setup.
const before = await getNotificationCallCount(electronApp);
const subject = `IT electron-push ${Date.now()}`;
await sendMail({
from: alice.email,
authPass: alice.password,
to: alice.email,
subject,
body: 'hi from the electron integration test',
});
await expect
.poll(() => getNotificationCallCount(electronApp!), {
timeout: 60000,
message: 'native notification bridge never fired after a real SMTP delivery',
})
.toBeGreaterThan(before);
} finally {
await electronApp?.close();
devServer.kill();
}
});
});
@@ -0,0 +1,508 @@
import { test, expect, _electron as electron } from '@playwright/test';
import type { ElectronApplication, Page } from '@playwright/test';
import { spawn, type ChildProcess } from 'node:child_process';
import { createServer } from 'node:net';
import { get as httpGet } from 'node:http';
import { createHash, randomBytes } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { ACCOUNTS, JMAP_URL } from './helpers/config';
import { sendMail } from './helpers/smtp';
import { JmapClient } from './helpers/jmap';
import { expectFolderUnread } from './helpers/app';
/**
* The encrypted local search index (lib/mail-index/**) against the real
* Stalwart fixture. THREE tests, because no single configuration can cover the
* whole feature - the reasons are specific and worth reading before changing
* any of them.
*
* Constraint 1 - the renderer cannot reach this fixture from a production
* build. The renderer talks JMAP DIRECTLY to Stalwart, and this fixture's
* Stalwart is deliberately plain HTTP (integration/webmail.Dockerfile explains
* why). The production CSP pins `connect-src` to `'self' https: wss:`. Setting
* NODE_ENV=development at RUNTIME does not help: `next build` INLINES
* process.env.NODE_ENV into the compiled middleware, so proxy.ts's `isDev` is
* frozen at build time. Verified by watching a standalone server started with
* NODE_ENV=development still serve the production CSP, and the login fail with
* "Refused to connect ... violates connect-src 'self' https: wss:".
*
* Constraint 2 - the fd-3 key channel cannot survive `next dev`. `next dev`
* forks its server process with an IPC channel that claims fd 3, so adopting it
* fails with EEXIST; fd 4 in that process is not a pipe either (ENOTTY). Both
* were observed, not assumed. Extra file descriptors simply are not plumbed
* through `npx -> next dev -> forked server`. The real standalone server is a
* single process and has no such problem (test 3 proves it).
*
* So each test takes the configuration that lets it prove its own half:
*
* 1. PIPELINE - drives the REAL standalone server over HTTP from Node, with a
* real fd-3 key channel. CSP is irrelevant here because there is no
* browser: a Node client with a real session cookie exercises the real
* routes. This is the test that proves a real delivery becomes searchable
* by a word from its BODY, and that the file on disk is really encrypted.
*
* 2. TRIGGER - proves the EVENT-DRIVEN wiring: a real SMTP delivery makes the
* renderer POST /api/offline/reindex off the back of its live JMAP push.
* Runs against `next dev` (constraint 1), and asserts the request is made -
* the indexing itself is test 1's job.
*
* 3. WIRING - launches the REAL shell with no ELECTRON_LOAD_URL, so
* electron/main.ts boots the real standalone artifact and stands up the real
* fd-3 key service on real safeStorage. Asserts the index routes are
* REACHABLE in a real build (401 "sign in", not 404 "feature absent", not
* 503 "no native binding / no key channel").
*
* Nothing is mocked anywhere: real SMTP, real Stalwart, real Electron, real
* SQLCipher, real safeStorage.
*/
const alice = ACCOUNTS.alice;
const projectRoot = path.resolve(__dirname, '../..');
/** Mirrors lib/mail-index/paths.ts's accountFileToken(). */
function accountFileToken(accountId: string): string {
return createHash('sha256').update(accountId, 'utf8').digest('hex').slice(0, 32);
}
function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer();
server.unref();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (address && typeof address === 'object') {
const { port } = address;
server.close(() => resolve(port));
} else {
server.close(() => reject(new Error('Could not allocate a free localhost port')));
}
});
});
}
function waitForServerReady(url: string, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve, reject) => {
const attempt = () => {
const req = httpGet(url, (res) => {
res.resume();
resolve();
});
req.on('error', () => {
if (Date.now() > deadline) {
reject(new Error(`Server never became reachable at ${url}`));
return;
}
setTimeout(attempt, 300);
});
};
attempt();
});
}
/**
* Serves the key protocol of electron/key-service.ts over the child's inherited
* fd. The key and the encryption are real; only safeStorage's wrapping of it is
* out of the picture here, which is what test 3 covers.
*/
function serveKeyChannel(child: ChildProcess, fdIndex: number, key: Buffer): void {
const channel = child.stdio[fdIndex] as NodeJS.ReadWriteStream | null;
if (!channel) throw new Error(`child has no fd ${fdIndex} - key channel missing`);
let buffer = '';
channel.on('data', (chunk: Buffer) => {
buffer += chunk.toString('utf8');
let newline: number;
while ((newline = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
if (!line.trim()) continue;
const req = JSON.parse(line) as { id?: number; op?: string };
const reply =
req.op === 'getIndexKey'
? { id: req.id, ok: true, key: key.toString('hex') }
: req.op === 'deleteIndexKey'
? { id: req.id, ok: true }
: { id: req.id, ok: false, code: 'bad-request', error: 'unknown op' };
channel.write(`${JSON.stringify(reply)}\n`);
}
});
}
/** Minimal cookie jar - the index routes are cookie-authenticated. */
class Jar {
private cookies = new Map<string, string>();
absorb(response: Response): void {
for (const raw of response.headers.getSetCookie()) {
const [pair] = raw.split(';');
const eq = pair.indexOf('=');
if (eq <= 0) continue;
this.cookies.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
}
}
header(): string {
return [...this.cookies].map(([k, v]) => `${k}=${v}`).join('; ');
}
}
interface SearchHit {
contentType: string;
id: string;
title: string;
snippet: string;
}
interface SearchResponse {
ok?: boolean;
count?: number;
hits?: SearchHit[];
contextBlock?: string;
stats?: Array<{ contentType: string; count: number }>;
error?: string;
}
test.describe('Electron desktop shell - encrypted local search index', () => {
test('pipeline: a real delivery becomes searchable by a body word, and the file is encrypted', async () => {
const jmap = await JmapClient.connect(alice.email, alice.password);
await jmap.reset();
const stamp = Date.now();
const subject = `IT index subject ${stamp}`;
// Appears ONLY in the body, so a hit proves the body was actually fetched
// and indexed - not merely the subject, which any list view already holds.
const bodyPhrase = `zurichlease${stamp}`;
// Deliver BEFORE indexing, so the catch-up path has something real to find.
await sendMail({
from: alice.email,
authPass: alice.password,
to: alice.email,
subject,
body: `Please review the ${bodyPhrase} renewal before September.`,
});
const storeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vncmail-index-it-'));
const key = randomBytes(32);
const port = await getFreePort();
const baseUrl = `http://127.0.0.1:${port}`;
const serverEntry = path.join(projectRoot, '.next', 'standalone', 'server.js');
expect(
fs.existsSync(serverEntry),
`missing ${serverEntry} - run "npm run build:standalone" first`,
).toBe(true);
// The REAL standalone artifact, spawned exactly as electron/main.ts spawns
// it (including the fd-3 key channel), just with plain node rather than
// ELECTRON_RUN_AS_NODE - the server code is identical either way.
const server = spawn(process.execPath, [serverEntry], {
cwd: path.dirname(serverEntry),
env: {
...process.env,
PORT: String(port),
HOSTNAME: '127.0.0.1',
NODE_ENV: 'production',
JMAP_SERVER_URL: JMAP_URL,
SESSION_SECRET: 'integration-not-a-real-secret-32-chars!!',
VNCMAIL_DESKTOP_STORE_DIR: storeDir,
VNCMAIL_DESKTOP_KEY_FD: '3',
},
stdio: ['pipe', 'pipe', 'pipe', 'pipe'],
});
server.stderr?.on('data', (chunk) => process.stderr.write(`[standalone] ${chunk}`));
serveKeyChannel(server, 3, key);
const jar = new Jar();
const call = async (url: string, init?: RequestInit): Promise<Response> => {
const response = await fetch(`${baseUrl}${url}`, {
...init,
headers: { ...(init?.headers ?? {}), cookie: jar.header() },
});
jar.absorb(response);
return response;
};
const search = async (query: string, types?: string): Promise<SearchResponse> => {
const params = new URLSearchParams({ q: query, stats: 'true' });
if (types) params.set('types', types);
const response = await call(`/api/offline/search?${params.toString()}`);
if (!response.ok) return { error: `HTTP ${response.status}: ${await response.text()}` };
return (await response.json()) as SearchResponse;
};
try {
await waitForServerReady(baseUrl, 60000);
// Server-side login. This route verifies the credentials against Stalwart
// from Node and writes BOTH the session cookie and the jmap_stalwart_ctx
// auth context the index routes read (app/api/auth/session/route.ts:94).
const login = await call('/api/auth/session?slot=0', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverUrl: JMAP_URL,
username: alice.email,
password: alice.password,
slot: 0,
}),
});
expect(login.status, `login failed: ${await login.text()}`).toBe(200);
// The gate must be open and the native binding loaded, or every assertion
// below would fail for an unrelated reason.
const reachable = await call('/api/offline/search?stats=true&q=');
expect(
reachable.status,
`index routes unreachable: ${(await reachable.text()).slice(0, 300)}`,
).toBe(200);
// Index it. This is the catch-up shape (no ids), which is what the app
// runs at launch.
const reindex = await call('/api/offline/reindex', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ catchUp: true }),
});
const reindexBody = await reindex.json();
expect(reindex.status, JSON.stringify(reindexBody)).toBe(200);
expect(
reindexBody.written?.mail,
`no mail indexed: ${JSON.stringify(reindexBody)}`,
).toBeGreaterThan(0);
// THE assertion: found by a word that exists only in the message body.
const hit = await search(bodyPhrase);
expect(hit.error).toBeUndefined();
expect(hit.count, `search for a body word found nothing: ${JSON.stringify(hit)}`)
.toBeGreaterThan(0);
expect(hit.hits?.[0].contentType).toBe('mail');
expect(hit.hits?.[0].title).toBe(subject);
expect(hit.hits?.[0].snippet).toContain(bodyPhrase);
// The prompt-ready retrieval surface an AI feature would consume.
expect(hit.contextBlock).toContain('[EMAIL]');
expect(hit.contextBlock).toContain(subject);
// Also findable by sender address, which lives in the `people` column.
expect((await search(alice.email)).count).toBeGreaterThan(0);
// Type filtering must filter, and a word in no message must not match -
// otherwise the hit above proves nothing about relevance.
expect((await search(bodyPhrase, 'calendar')).count).toBe(0);
expect((await search(bodyPhrase, 'mail')).count).toBeGreaterThan(0);
expect((await search(`absent${stamp}`)).count).toBe(0);
// Catch-up must be idempotent: a second pass must not duplicate rows.
const before = ((await search(bodyPhrase)).stats ?? [])
.find((s) => s.contentType === 'mail')?.count ?? 0;
const second = await call('/api/offline/reindex', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ catchUp: true }),
});
expect(second.status).toBe(200);
const after = ((await search(bodyPhrase)).stats ?? [])
.find((s) => s.contentType === 'mail')?.count ?? 0;
expect(after).toBe(before);
expect((await search(bodyPhrase)).count).toBe(1);
// Calendar/contacts/files: assert they were ATTEMPTED and did not error,
// rather than asserting counts - this fixture provisions mailboxes only,
// so an empty calendar is the correct result and a count assertion would
// be testing the fixture rather than the code.
const errors = (reindexBody.errors ?? []) as Array<{ contentType: string; message: string }>;
expect(errors, `per-type failures during reindex: ${JSON.stringify(errors)}`).toEqual([]);
const attempted = Object.keys(reindexBody.written ?? {});
const skipped = (reindexBody.skipped ?? []) as string[];
expect(
[...attempted, ...skipped].sort(),
'every content type must be either attempted or explicitly skipped',
).toEqual(['calendar', 'contact', 'file', 'mail']);
} finally {
server.kill();
// Let the process release its WAL files before reading them.
await new Promise((r) => setTimeout(r, 500));
}
// ── the file on disk is genuinely encrypted ──────────────────────────────
const accountId = `${alice.email}@${new URL(JMAP_URL).hostname}`;
const dbPath = path.join(storeDir, 'index', `${accountFileToken(accountId)}.db`);
expect(fs.existsSync(dbPath), `no index database at ${dbPath}`).toBe(true);
// Read every file the store wrote, WAL included: the newest rows can still
// be sitting in the -wal, so checking only the main database could miss
// plaintext that is genuinely on disk.
const onDisk = Buffer.concat(
['', '-wal', '-shm']
.map((suffix) => `${dbPath}${suffix}`)
.filter((f) => fs.existsSync(f))
.map((f) => fs.readFileSync(f)),
);
expect(onDisk.length).toBeGreaterThan(0);
// The assertions that catch a silently-UNENCRYPTED store. `PRAGMA key` is a
// no-op on a non-SQLCipher binding - no error, working database, mailbox in
// cleartext - so every functional assertion above would pass either way.
expect(
fs.readFileSync(dbPath).subarray(0, 15).toString('latin1'),
'the index file has a plain SQLite header - it is NOT encrypted',
).not.toBe('SQLite format 3');
expect(
onDisk.includes(bodyPhrase),
'the message body is recoverable from the raw database bytes - not encrypted',
).toBe(false);
expect(
onDisk.includes(subject),
'the subject is recoverable from the raw database bytes - not encrypted',
).toBe(false);
fs.rmSync(storeDir, { recursive: true, force: true });
});
test('trigger: a real delivery makes the renderer ask the index to update', async () => {
const jmap = await JmapClient.connect(alice.email, alice.password);
await jmap.reset();
const devPort = await getFreePort();
const devUrl = `http://127.0.0.1:${devPort}`;
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vncmail-trigger-profile-'));
// `next dev` for the CSP reason in the header comment. No key channel here:
// this test asserts the REQUEST is made, which is the wiring it owns; the
// indexing itself is test 1's job. (Extra fds don't survive next dev
// anyway - constraint 2 above.)
const devServer = spawn('npx', ['next', 'dev', '--turbopack', '-p', String(devPort)], {
cwd: projectRoot,
env: {
...process.env,
JMAP_SERVER_URL: JMAP_URL,
SESSION_SECRET: 'integration-not-a-real-secret-32-chars!!',
NODE_ENV: 'development',
// Enough for the route to exist and pass its gate; it fails later on the
// absent key channel, which this test deliberately does not assert on.
VNCMAIL_DESKTOP_STORE_DIR: path.join(userDataDir, 'offline'),
VNCMAIL_DESKTOP_KEY_FD: '3',
},
stdio: 'pipe',
});
devServer.stderr?.on('data', (chunk) => process.stderr.write(`[next dev] ${chunk}`));
let electronApp: ElectronApplication | undefined;
try {
await waitForServerReady(devUrl, 90000);
electronApp = await electron.launch({
args: [projectRoot, `--user-data-dir=${userDataDir}`],
env: { ...process.env, ELECTRON_LOAD_URL: devUrl },
});
const appWindow: Page = await electronApp.firstWindow();
await appWindow.waitForLoadState('domcontentloaded');
await appWindow.locator('#username').waitFor({ state: 'visible', timeout: 60000 });
await appWindow.fill('#username', alice.email);
await appWindow.fill('#password', alice.password);
await appWindow.click('button[type="submit"]');
await appWindow
.locator('[data-testid="account-switcher"]')
.first()
.waitFor({ state: 'visible', timeout: 60000 });
// An actively-selected inbox is a precondition for the push handler's
// refresh, which is what schedules the index update - the same reason
// 11-electron-notification.spec.ts waits here.
await expectFolderUnread(appWindow, { role: 'inbox' }, 0);
const reindexCalls: string[] = [];
appWindow.on('request', (request) => {
if (request.method() === 'POST' && request.url().includes('/api/offline/reindex')) {
reindexCalls.push(request.postData() ?? '');
}
});
// Let the launch-time catch-up land first so it is not mistaken for the
// delivery-driven call below.
await appWindow.waitForTimeout(8000);
const baseline = reindexCalls.length;
await sendMail({
from: alice.email,
authPass: alice.password,
to: alice.email,
subject: `IT index trigger ${Date.now()}`,
body: 'a delivery should make the renderer ask the index to update',
});
await expect
.poll(() => reindexCalls.length, {
timeout: 60000,
message:
'a real delivery did not make the renderer POST /api/offline/reindex - ' +
'the push -> handleStateChange -> indexOnStateChange wiring is broken',
})
.toBeGreaterThan(baseline);
// The delivery-driven call must name the mail type, rather than being an
// unconditional full catch-up.
const triggered = reindexCalls.slice(baseline);
expect(
triggered.some((body) => body.includes('"mail"')),
`no reindex call mentioned the mail type: ${JSON.stringify(triggered)}`,
).toBe(true);
} finally {
await electronApp?.close();
devServer.kill();
fs.rmSync(userDataDir, { recursive: true, force: true });
}
});
test('wiring: the real standalone boot reaches the index with a real safeStorage key', async () => {
// A FRESH profile is load-bearing, not hygiene: the 401 this test asserts is
// "nobody is signed in", and a leftover jmap_stalwart_ctx cookie from any
// previous run turns it into a 200. That actually happened while writing this.
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vncmail-wiring-profile-'));
const electronApp = await electron.launch({
args: [projectRoot, `--user-data-dir=${userDataDir}`],
env: {
...process.env,
JMAP_SERVER_URL: JMAP_URL,
SESSION_SECRET: 'integration-not-a-real-secret-32-chars!!',
},
});
try {
const appWindow: Page = await electronApp.firstWindow();
await appWindow.waitForLoadState('domcontentloaded');
await appWindow.locator('#username').waitFor({ state: 'visible', timeout: 60000 });
// safeStorage must be usable, or main.ts deliberately refuses to enable
// the feature at all (electron/key-service.ts's checkEncryptionAvailable).
const encryptionAvailable = await electronApp.evaluate(({ safeStorage }) =>
safeStorage.isEncryptionAvailable(),
);
expect(
encryptionAvailable,
'safeStorage reports no encryption available on this host, so main.ts ' +
'correctly disabled the index - this assertion cannot pass here',
).toBe(true);
const probe = await appWindow.evaluate(async () => {
const response = await fetch('/api/offline/search?q=anything');
return { status: response.status, body: (await response.text()).slice(0, 300) };
});
// 401 = the gate opened, the native binding loaded and the fd-3 key
// channel is present; it refuses only because nobody is signed in (this
// build cannot log in against a plain-HTTP Stalwart - constraint 1).
// 404 => VNCMAIL_DESKTOP_STORE_DIR was never set (gate closed, or
// main.ts refused because no OS keyring is available)
// 503 => the native binding or the key channel is missing from the real
// artifact - the class of failure only a real build reveals
expect(
probe.status,
`expected 401 (reachable, unauthenticated) but got ${probe.status}: ${probe.body}`,
).toBe(401);
} finally {
await electronApp.close();
fs.rmSync(userDataDir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,85 @@
/**
* Global setup for playwright.integration-electron.config.ts - a narrower
* variant of ./global-setup.ts.
*
* The Electron suite (11-electron-notification.spec.ts) boots its OWN
* standalone Next.js server via electron/main.ts, so unlike the main
* integration config it never talks to the docker-compose `webmail`
* container on :3000 at all - only to `stalwart` (JMAP + SMTP). Bringing up
* `webmail` too would be pointless work, and on a host where something else
* already owns port 3000 (this repo doesn't own that port - any other
* project's dev server can be sitting on it) it would fail outright for a
* container this suite never uses. `docker compose up <service>` scopes the
* bring-up to just `stalwart`.
*
* Set IT_NO_DOCKER=1 to skip container management entirely (useful when the
* stack is already running).
*/
import { execFileSync } from 'node:child_process';
import { existsSync, copyFileSync } from 'node:fs';
import path from 'node:path';
import { JMAP_URL, ACCOUNTS, ACCOUNT_PASSWORD } from './helpers/config';
const INTEGRATION_DIR = path.resolve(__dirname, '..');
const COMPOSE_FILE = path.join(INTEGRATION_DIR, 'docker-compose.yml');
const ENV_FILE = path.join(INTEGRATION_DIR, '.env');
const STALWART_CLI_BIN = path.join(INTEGRATION_DIR, 'stalwart', 'stalwart-cli');
function run(cmd: string, args: string[]): void {
execFileSync(cmd, args, { cwd: INTEGRATION_DIR, stdio: 'inherit' });
}
async function waitForStalwart(timeoutMs = 240000): Promise<void> {
const url = `${JMAP_URL}/jmap/session`;
const deadline = Date.now() + timeoutMs;
const auth = 'Basic ' + Buffer.from(`${ACCOUNTS.alice.email}:${ACCOUNT_PASSWORD}`).toString('base64');
for (;;) {
try {
const res = await fetch(url, { headers: { Authorization: auth } });
if (res.ok) return;
} catch {
/* not up yet */
}
if (Date.now() > deadline) throw new Error(`Timed out waiting for Stalwart JMAP at ${url}`);
await new Promise((r) => setTimeout(r, 2000));
}
}
export default async function globalSetup(): Promise<void> {
if (process.env.IT_NO_DOCKER === '1') {
console.log('[global-setup-electron] IT_NO_DOCKER=1 - skipping docker compose management');
} else {
// stalwart/prepare-stalwart-cli.sh fetches a LINUX binary (it's COPYed
// into the Stalwart container by integration/stalwart/Dockerfile - never
// meant to run on the host at all) but ends by executing it as its own
// sanity check, which only works when the host itself is Linux. On a
// macOS host that self-check fails outright ("cannot execute binary
// file") even though the download+extract already succeeded and the
// file the Dockerfile needs is perfectly fine on disk. Skipping the
// script once the binary already exists sidesteps that host/target
// mismatch without touching the shared script (used by the main
// integration config too, on hosts where it does work).
if (existsSync(STALWART_CLI_BIN)) {
console.log('[global-setup-electron] stalwart-cli already present, skipping fetch');
} else {
console.log('[global-setup-electron] fetching stalwart-cli');
run('bash', [path.join(INTEGRATION_DIR, 'stalwart', 'prepare-stalwart-cli.sh')]);
}
if (!existsSync(ENV_FILE)) {
console.log('[global-setup-electron] creating integration/.env from .env.example');
copyFileSync(path.join(INTEGRATION_DIR, '.env.example'), ENV_FILE);
}
console.log('[global-setup-electron] docker compose up -d --build --wait stalwart');
run('docker', [
'compose', '-f', COMPOSE_FILE, '--env-file', ENV_FILE,
'up', '-d', '--build', '--wait', '--wait-timeout', '300', 'stalwart',
]);
}
console.log('[global-setup-electron] waiting for Stalwart JMAP');
await waitForStalwart();
console.log('[global-setup-electron] stack ready');
}
+1
View File
@@ -75,6 +75,7 @@ export class DemoJMAPClient implements IJMAPClient {
getMaxDelayedSend(): number { return 30 * 24 * 60 * 60; }
hasDelayedSend(): boolean { return true; }
getEventSourceUrl(): string | null { return null; }
getWebSocketUrl(): string | null { return null; }
supportsEmailSubmission(): boolean { return true; }
supportsQuota(): boolean { return true; }
supportsVacationResponse(): boolean { return true; }
+55
View File
@@ -0,0 +1,55 @@
// Detects whether the app is running inside the VNCmail+ (Bulwark) Electron
// desktop shell and wraps the native notification bridge that
// electron/preload.ts exposes via contextBridge. Mirrors how lib/web-push.ts
// mirrors the React Native push flow - same idea, different native API:
// PushManager/service-worker there, Electron's own Notification API here.
//
// Web/PWA deployments never get `window.vnc` at all (contextBridge only
// exists inside the Electron shell), so `isElectronShell()` is false there
// and callers should keep using the lib/web-push.ts + public/sw.js path.
// Wiring this bridge up to real mail-delivery events (JMAP WebSocket push
// vs. polling) is a separate, later decision - this module is only the
// plumbing.
export interface ShowNotificationOptions {
body?: string;
tag?: string;
}
export interface ShowNotificationResult {
shown: boolean;
}
export interface VncElectronBridge {
isElectron: true;
showNotification: (
title: string,
options?: ShowNotificationOptions,
) => Promise<ShowNotificationResult>;
}
declare global {
interface Window {
vnc?: VncElectronBridge;
}
}
export function isElectronShell(): boolean {
return typeof window !== "undefined" && window.vnc?.isElectron === true;
}
/**
* Shows a notification via Electron's native Notification API when running
* inside the desktop shell. Resolves to false (never throws) when not
* running in Electron, or when the main process reports notifications
* unsupported on this OS/session - callers can fall back to the
* service-worker push path (lib/web-push.ts) in that case.
*/
export async function showElectronNotification(
title: string,
options?: ShowNotificationOptions,
): Promise<boolean> {
if (!isElectronShell()) return false;
const result = await window.vnc!.showNotification(title, options);
return result.shown;
}
+1
View File
@@ -35,6 +35,7 @@ export interface IJMAPClient {
getMaxDelayedSend(accountId?: string): number;
hasDelayedSend(accountId?: string): boolean;
getEventSourceUrl(): string | null;
getWebSocketUrl(): string | null;
supportsEmailSubmission(): boolean;
supportsQuota(): boolean;
supportsVacationResponse(): boolean;
+440 -7
View File
@@ -953,6 +953,36 @@ export class JMAPClient implements IJMAPClient {
if (session.eventSourceUrl) {
session.eventSourceUrl = this.rewriteSessionUrl(session.eventSourceUrl);
}
const wsCapability = session.capabilities?.["urn:ietf:params:jmap:websocket"] as
| { url?: string }
| undefined;
if (wsCapability?.url) {
wsCapability.url = this.rewriteWebSocketUrl(wsCapability.url);
}
}
/**
* Same reasoning as rewriteSessionUrl (a reverse proxy may advertise its
* own internal hostname), but scheme-aware: unlike apiUrl/eventSourceUrl,
* this URL is never touched by fetch() - it goes straight into `new
* WebSocket(...)`, and a ws/wss URL can never share an origin string with
* an http/https serverUrl even when the host is identical, so reusing
* rewriteSessionUrl's plain origin-equality check would rewrite EVERY
* websocket URL onto an http(s) scheme and break the constructor outright.
*/
private rewriteWebSocketUrl(url: string): string {
try {
const parsed = new URL(url);
const server = new URL(this.serverUrl);
const expectedScheme = server.protocol === "https:" ? "wss:" : "ws:";
if (parsed.host === server.host && parsed.protocol === expectedScheme) {
return url;
}
const pathAndRest = url.slice(url.indexOf("/", url.indexOf("//") + 2));
return `${expectedScheme}//${server.host}${pathAndRest}`;
} catch {
return url;
}
}
private async request(methodCalls: JMAPMethodCall[], using?: string[]): Promise<JMAPResponse> {
@@ -3761,6 +3791,22 @@ export class JMAPClient implements IJMAPClient {
return this.session.eventSourceUrl || coreCapability?.eventSourceUrl || null;
}
/**
* RFC 8887 (JMAP over WebSocket) push endpoint, advertised under the
* `urn:ietf:params:jmap:websocket` capability (not a root session field
* like eventSourceUrl - it's nested the same way every other JMAP
* extension capability is). Rewritten to the client's own server host in
* rewriteSessionUrls() at connect time, same reasoning as apiUrl/
* downloadUrl/eventSourceUrl. Returns null for servers that don't
* advertise it - callers fall back to SSE/polling.
*/
getWebSocketUrl(): string | null {
const wsCapability = this.capabilities["urn:ietf:params:jmap:websocket"] as
| { url?: string; supportsPush?: boolean }
| undefined;
return wsCapability?.url || null;
}
getAccountId(): string {
return this.accountId;
}
@@ -5982,12 +6028,51 @@ export class JMAPClient implements IJMAPClient {
private visibilityHandler: (() => void) | null = null;
private onlineHandler: (() => void) | null = null;
// JMAP-over-WebSocket (RFC 8887) push - preferred over SSE when the server
// advertises it (getWebSocketUrl()), since it's the transport the desktop
// shell's main process eventually wants for background/no-window
// notifications (see electron/preload.ts's showNotification bridge).
// Falls back to the existing SSE/polling chain below when unsupported OR
// when the handshake itself keeps failing (see wsPermanentlyDisabled).
//
// KNOWN LIMITATION, confirmed empirically against the sandbox server this
// was built against (stalwart.sandbox.vnc.de): its /jmap/ws endpoint
// requires the same HTTP Basic/Bearer Authorization header as every other
// JMAP endpoint on the WebSocket UPGRADE request itself (curling it with
// no Authorization header returns a plain 401 before any WS frame is
// possible). The browser WebSocket constructor has no way to attach
// custom headers to that handshake (a WHATWG spec restriction, not an
// Electron/browser quirk - credentials in the URL are actively rejected
// too), so from this renderer-side client there is no way to satisfy that
// auth requirement. Against a server with this exact auth model, every
// connection attempt below will fail at the handshake and the circuit
// breaker (wsPermanentlyDisabled) will fall back to SSE after a few quick
// retries - which is not a bug in this code, it is what actually happens
// on the wire. It's still implemented for real (not stubbed) because (a)
// it's fully spec-correct and will light up automatically against any
// server whose WS endpoint doesn't have this requirement - e.g. one
// sitting behind a proxy that authenticates via cookies instead - with no
// further changes, and (b) the alternative (opening it from Electron's
// main process via a header-capable client like the `ws` package) would
// mean piping raw credentials from the renderer to the main process over
// IPC, which is a materially bigger security-sensitive change than what
// was scoped here.
private ws: WebSocket | null = null;
private wsReconnectTimeout: NodeJS.Timeout | null = null;
private wsReconnectAttempts: number = 0;
private wsConsecutiveFailures: number = 0;
private wsPermanentlyDisabled: boolean = false;
private wsHeartbeatTimer: NodeJS.Timeout | null = null;
private lastWSActivity: number = 0;
private static readonly STATE_TYPE_MAP: Record<string, string> = {
'Mailbox/get': 'Mailbox',
'Email/get': 'Email',
'Calendar/get': 'Calendar',
'CalendarEvent/get': 'CalendarEvent',
'SieveScript/get': 'SieveScript',
'ContactCard/get': 'ContactCard',
'FileNode/get': 'FileNode',
};
private static readonly POLLING_INTERVAL = 3_000;
@@ -5998,20 +6083,315 @@ export class JMAPClient implements IJMAPClient {
private static readonly SSE_RECONNECT_DELAY = 3_000;
private static readonly SSE_PING_TIMEOUT = 90_000; // 3x the 30s ping interval
// Exponential backoff with full jitter (0..cap), doubling from a 200ms
// base and capping at 5s.
//
// Deliberately much tighter than a "normal" reconnect ladder (something
// like 1s/30s would be the textbook default for a flaky network) - and
// tuned from a real, measured failure mode, not guessed: the auth
// limitation described above fails FAST and DETERMINISTICALLY (the
// handshake is rejected before the socket ever opens, in well under a
// second, every single time), not slowly. Verified empirically (see
// integration/tests/11-electron-notification.spec.ts's development) that
// the original 1s-base/30s-cap/5-attempt ladder let the circuit breaker
// take up to ~31s to trip, during which there is NO live push at all
// (WS hasn't succeeded and hasn't given up yet, so SSE never even starts
// connecting) - a real mail delivery landing in that window was missed
// entirely, since SSE only streams future changes and does no catch-up
// fetch on connect. This tighter ladder closes that gap to a fraction of
// a second for the fast-fail case while remaining exactly as protective
// for a genuinely slow/flaky network: a hanging attempt is still bounded
// by the browser's own WebSocket connect timeout regardless of these
// constants, which govern only the GAP between attempts, not how long a
// single attempt is allowed to hang.
private static readonly WS_RECONNECT_BASE_DELAY = 200;
private static readonly WS_RECONNECT_MAX_DELAY = 5_000;
// App-level heartbeat: a WebSocket can sit in "open" readyState for a long
// time after the underlying network path is actually gone (sleep, network
// switch, a NAT/proxy that silently drops idle connections) - TCP alone
// won't always surface that promptly. Send a lightweight JMAP request
// every 30s and force-reconnect if nothing (heartbeat response OR a real
// push) has arrived within 3x that window, mirroring the SSE ping monitor
// above.
private static readonly WS_HEARTBEAT_INTERVAL = 30_000;
private static readonly WS_ACTIVITY_TIMEOUT = 90_000;
// Give up on WS for this client instance after this many CONSECUTIVE
// attempts that never reach "open" (a connection that opened fine and
// later dropped does not count - see connectWebSocket's openedSuccessfully
// tracking). Bounds the cost of the auth limitation described above to a
// handful of quick handshake attempts (with the tightened backoff above,
// well under a second in the common fast-fail case) instead of retrying a
// request that can never succeed, forever, for the lifetime of the session.
private static readonly WS_MAX_CONSECUTIVE_FAILURES = 3;
/** getWebSocketUrl(), gated by the circuit breaker above. */
private effectiveWebSocketUrl(): string | null {
return this.wsPermanentlyDisabled ? null : this.getWebSocketUrl();
}
setupPushNotifications(): boolean {
const eventSourceUrl = this.getEventSourceUrl();
if (eventSourceUrl) {
this.connectSSE(eventSourceUrl);
// SSE covers the primary account only; keep shared accounts fresh too.
const wsUrl = this.effectiveWebSocketUrl();
if (wsUrl) {
this.wsReconnectAttempts = 0;
this.connectWebSocket(wsUrl);
// Prime the polling baseline (pollingStates) in parallel with the WS
// attempt, not just for shared/secondary accounts below - if WS ends
// up failing and falling back (fallbackFromWebSocket()), this is what
// lets that fallback reconcile anything that changed to the PRIMARY
// account while WS was still churning through retries. Without an
// early baseline, a change in that window would be silently missed
// entirely: SSE only streams changes from the moment it connects
// onward (no catch-up on connect), so the one thing that CAN catch up
// is a diff against a state snapshot taken before the gap started.
void this.fetchCurrentStates();
// Not confirmed either way whether this server's WebSocket push fans
// out to shared/secondary accounts or, like Stalwart's SSE, covers the
// primary account only - keep the same secondary poll running under
// WS that SSE already needed, rather than assume broader coverage and
// risk shared-account counters going stale.
this.startSecondaryAccountPoll();
} else {
// The fallback poll already covers every session account.
this.startPollingFallback();
const eventSourceUrl = this.getEventSourceUrl();
if (eventSourceUrl) {
this.connectSSE(eventSourceUrl);
// SSE covers the primary account only; keep shared accounts fresh too.
this.startSecondaryAccountPoll();
} else {
// The fallback poll already covers every session account.
this.startPollingFallback();
}
}
this.setupBrowserEventListeners();
return true;
}
/**
* Opens the RFC 8887 JMAP-over-WebSocket connection and subscribes to
* push for every data type (`WebSocketPushEnable` with dataTypes: null).
* Reconnect on close/error is handled by scheduleWSReconnect() below with
* exponential backoff - this method only ever represents a single
* connection attempt.
*/
private connectWebSocket(wsUrl: string): void {
if (this.isRateLimited()) {
this.scheduleWSReconnect();
return;
}
let socket: WebSocket;
try {
socket = new WebSocket(wsUrl, "jmap");
} catch {
// New URL()-level failures (malformed URL) - retry later in case a
// session refresh fixes it; getWebSocketUrl() re-reads capabilities
// fresh on every attempt.
this.scheduleWSReconnect();
return;
}
this.ws = socket;
const isCurrent = () => this.ws === socket;
// Tracks whether THIS specific attempt ever reached "open" - a socket
// that opened fine and dropped later (real network blip on an
// established connection) must not count toward the circuit breaker the
// same way a handshake that never completes does (see
// wsPermanentlyDisabled's declaration above for why the latter needs
// one at all).
let openedSuccessfully = false;
socket.addEventListener("open", () => {
if (!isCurrent()) return;
openedSuccessfully = true;
// A real connection succeeded - both counters reset: the backoff
// ladder no longer applies to whatever eventually causes the NEXT
// disconnect, and the "give up on WS entirely" counter only tracks
// CONSECUTIVE handshake failures.
this.wsReconnectAttempts = 0;
this.wsConsecutiveFailures = 0;
this.lastWSActivity = Date.now();
this.startWSHeartbeat(socket);
try {
socket.send(JSON.stringify({ "@type": "WebSocketPushEnable", dataTypes: null }));
} catch {
// send() can throw if the socket already closed between "open"
// firing and this line running - the "close" handler below will
// schedule a reconnect regardless.
}
});
socket.addEventListener("message", (event) => {
if (!isCurrent()) return;
this.lastWSActivity = Date.now();
this.processWebSocketMessage(typeof event.data === "string" ? event.data : "");
});
socket.addEventListener("close", () => {
if (!isCurrent()) return;
this.stopWSHeartbeat();
this.ws = null;
if (this.intentionallyDisconnected) return;
if (!openedSuccessfully) {
this.wsConsecutiveFailures += 1;
if (this.wsConsecutiveFailures >= JMAPClient.WS_MAX_CONSECUTIVE_FAILURES) {
// The handshake itself is what's failing, repeatedly - most
// commonly (confirmed against this client's own reference
// server) because the WS endpoint requires an Authorization
// header the browser WebSocket API cannot attach. Retrying that
// forever would just hammer the server every ~30s with a request
// that can never succeed from here. Give up on WS for the rest of
// this client instance's life and stay on SSE/polling, which
// don't have this limitation.
this.wsPermanentlyDisabled = true;
console.warn(
'[JMAP] WebSocket push failed to establish after repeated attempts; falling back to SSE/polling for this session.',
);
this.fallbackFromWebSocket();
return;
}
}
this.scheduleWSReconnect();
});
// WebSocket always fires "close" right after "error" - the reconnect
// logic lives entirely in the "close" handler above so there is exactly
// one path that schedules a retry, not two racing each other.
}
/** Whatever push transport SSE would have used, now that WS has given up. */
private fallbackFromWebSocket(): void {
void this.reconcileAfterWebSocketFallback();
}
/**
* Diffs against the baseline setupPushNotifications() primed via
* fetchCurrentStates() when the WS attempt began - BEFORE either branch
* below gets a chance to erase that opportunity (startPollingFallback()
* unconditionally overwrites the same baseline via its own
* fetchCurrentStates() call; connectSSE() only ever streams changes from
* the moment it connects onward, no catch-up). This is what catches a
* real mail delivery (or any other tracked change) that happened to the
* primary account while WS was still churning through retries, which
* neither of those two paths would otherwise ever notice - confirmed as a
* real, not theoretical, gap during this feature's own development (see
* the WS_RECONNECT_BASE_DELAY comment above).
*
* Not airtight: if the early fetchCurrentStates() from
* setupPushNotifications() hasn't itself completed yet by the time this
* runs, there's nothing to diff against and this call just establishes
* the baseline instead of detecting drift. In practice that race needs a
* pathologically slow state-fetch racing an unusually fast WS failure,
* and the tightened backoff above (worst case ~1.75s to exhaust 3
* attempts) gives that fetch a lot more room to finish first than the
* original 31s-worst-case ladder did.
*/
private async reconcileAfterWebSocketFallback(): Promise<void> {
await this.checkForStateChanges();
const eventSourceUrl = this.getEventSourceUrl();
if (eventSourceUrl) {
this.connectSSE(eventSourceUrl);
this.startSecondaryAccountPoll();
} else {
this.startPollingFallback();
}
}
/**
* Parses one WebSocket text frame. Per RFC 8887 the server can send
* Response, StateChange, or PushState frames; only StateChange is
* consumed today (method calls aren't yet routed over this socket -
* request()/authenticatedFetch() still uses plain HTTP), so anything else
* is silently ignored rather than treated as an error.
*/
private processWebSocketMessage(raw: string): void {
if (!raw) return;
let message: { "@type"?: string; changed?: StateChange["changed"] } | null = null;
try {
message = JSON.parse(raw);
} catch {
return; // malformed frame - ignore, matches processSSEEvent's handling
}
if (message?.["@type"] === "StateChange" && message.changed) {
this.stateChangeCallback?.({ "@type": "StateChange", changed: message.changed });
}
}
private scheduleWSReconnect(): void {
if (this.intentionallyDisconnected) return;
if (this.wsReconnectTimeout) return; // already scheduled - don't stack retries
const wsUrl = this.effectiveWebSocketUrl();
if (!wsUrl) {
// Either the server capability disappeared (e.g. a session refresh
// dropped WebSocket support) or the circuit breaker already tripped -
// fall back to whatever push transport is still available instead of
// retrying a URL that's gone or a handshake that won't succeed.
this.fallbackFromWebSocket();
return;
}
const attempt = this.wsReconnectAttempts;
this.wsReconnectAttempts += 1;
const exponential = JMAPClient.WS_RECONNECT_BASE_DELAY * Math.pow(2, attempt);
const cap = Math.min(exponential, JMAPClient.WS_RECONNECT_MAX_DELAY);
// Full jitter (uniform 0..cap) rather than a fixed exponential delay -
// spreads reconnect attempts out after a shared network blip (proxy
// restart, wifi handoff affecting every open tab/window at once)
// instead of having them all retry in lockstep.
const delay = Math.random() * cap;
this.wsReconnectTimeout = setTimeout(() => {
this.wsReconnectTimeout = null;
if (this.isRateLimited()) {
this.scheduleWSReconnect();
return;
}
this.connectWebSocket(wsUrl);
}, delay);
}
private startWSHeartbeat(socket: WebSocket): void {
this.stopWSHeartbeat();
this.wsHeartbeatTimer = setInterval(() => {
if (this.ws !== socket) return;
if (Date.now() - this.lastWSActivity > JMAPClient.WS_ACTIVITY_TIMEOUT) {
// Silently dead connection (sleep/network switch/idle proxy) - the
// socket can still report readyState OPEN long after the underlying
// path is gone. Force-close; the "close" handler schedules the
// reconnect via the normal backoff path.
this.stopWSHeartbeat();
try {
socket.close();
} catch {
// Already closing/closed - the "close" handler (if it hasn't
// already run) will still fire and take care of reconnecting.
}
return;
}
try {
socket.send(JSON.stringify({
"@type": "Request",
requestId: `ws-heartbeat-${Date.now()}`,
using: ["urn:ietf:params:jmap:core"],
methodCalls: [["Core/echo", {}, "0"]],
}));
} catch {
// send() failing means the socket is already dead - the activity
// timeout above will catch it on the next tick if "close" doesn't
// fire first.
}
}, JMAPClient.WS_HEARTBEAT_INTERVAL);
}
private stopWSHeartbeat(): void {
if (this.wsHeartbeatTimer) {
clearInterval(this.wsHeartbeatTimer);
this.wsHeartbeatTimer = null;
}
}
/**
* Slow poll of the session's shared/secondary accounts, run in parallel with
* SSE (which never reports them). Skipped when there are no shared accounts,
@@ -6210,6 +6590,23 @@ export class JMAPClient implements IJMAPClient {
);
}
// Contacts and files get no push at all today (mail-index's event-driven
// reindex depends on this poll to notice them when SSE/WS isn't
// available) - mirrors the Calendar branch above, same accountId caveat.
if (this.supportsContacts()) {
using.push('urn:ietf:params:jmap:contacts');
methodCalls.push(
['ContactCard/get', { accountId: this.getContactsAccountId(), ids: [], properties: ['id'] }, 'f'],
);
}
if (this.hasCapability('urn:ietf:params:jmap:filenode')) {
using.push('urn:ietf:params:jmap:filenode');
methodCalls.push(
['FileNode/get', { accountId: this.getFilesAccountId(), ids: [], properties: ['id'] }, 'g'],
);
}
return { using, methodCalls };
}
@@ -6310,6 +6707,27 @@ export class JMAPClient implements IJMAPClient {
this.eventSource = null;
}
this.stopSSEPingMonitor();
if (this.wsReconnectTimeout) {
clearTimeout(this.wsReconnectTimeout);
this.wsReconnectTimeout = null;
}
this.stopWSHeartbeat();
if (this.ws) {
// Null out this.ws BEFORE close() so the "close" event handler's
// isCurrent() check (this.ws === socket) sees a mismatch once the
// event fires and skips scheduling a reconnect - this is an
// intentional teardown, not a dropped connection.
const socket = this.ws;
this.ws = null;
try {
socket.close();
} catch {
// Already closing/closed.
}
}
this.wsReconnectAttempts = 0;
this.wsConsecutiveFailures = 0;
this.wsPermanentlyDisabled = false;
this.cleanupBrowserEventListeners();
this.stateChangeCallback = null;
this.pollingStates = {};
@@ -6350,7 +6768,22 @@ export class JMAPClient implements IJMAPClient {
if (typeof window !== 'undefined') {
this.onlineHandler = () => {
// Network reconnected - reconnect SSE or force a poll
// Network reconnected - reconnect WS/SSE or force a poll. Don't
// make the user wait through whatever backoff delay was already in
// flight from repeated failures while offline - the network is
// confirmed back, so retry immediately.
const wsUrl = this.effectiveWebSocketUrl();
if (wsUrl) {
if (!this.ws) {
if (this.wsReconnectTimeout) {
clearTimeout(this.wsReconnectTimeout);
this.wsReconnectTimeout = null;
}
this.wsReconnectAttempts = 0;
this.connectWebSocket(wsUrl);
}
return;
}
const eventSourceUrl = this.getEventSourceUrl();
if (eventSourceUrl && !this.sseAbortController) {
this.connectSSE(eventSourceUrl);
+199
View File
@@ -0,0 +1,199 @@
// Renderer-side client for the encrypted local search index.
//
// The index is EVENT-DRIVEN: the renderer already holds the live JMAP push
// connection (WebSocket -> SSE -> polling, `lib/jmap/client.ts`'s
// setupPushNotifications), so the moment a StateChange announces new mail, a
// calendar change, a contact edit or a file upload, this posts to the reindex
// route. No polling loop, no background worker, no long-lived credential -
// just one more authenticated fetch from the place the push already arrives.
//
// Every function here is best-effort and never throws: a search index failing
// to update must never break the mail UI.
import { apiFetch } from '@/lib/browser-navigation';
import { debug } from '@/lib/debug';
import type { StateChange } from '@/lib/jmap/types';
export type IndexContentType = 'mail' | 'calendar' | 'contact' | 'file';
export interface IndexRunResult {
ok: boolean;
written?: Partial<Record<IndexContentType, number>>;
skipped?: IndexContentType[];
errors?: Array<{ contentType: IndexContentType; message: string }>;
durationMs?: number;
/** Set when the feature isn't available (not the desktop shell, no keyring, no binding). */
unavailable?: boolean;
error?: string;
}
/**
* Maps JMAP `StateChange` type keys onto our content types.
*
* The transport is already type-generic - the WebSocket handler
* (`client.ts:6308-6316`) and the SSE handler (`:6505`) pass the whole
* `changed` map through untouched, and the WS subscribes with
* `dataTypes: null` (every type) - so anything the server pushes arrives here.
*
* `Mailbox` is deliberately NOT mapped: a Mailbox state change is usually just
* an unread-count move, and it fires constantly. `Email` covers the cases that
* change indexable content.
*/
const STATE_TYPE_TO_CONTENT: Record<string, IndexContentType> = {
Email: 'mail',
Calendar: 'calendar',
CalendarEvent: 'calendar',
ContactCard: 'contact',
AddressBook: 'contact',
FileNode: 'file',
};
export function contentTypesFromStateChange(change: StateChange): IndexContentType[] {
const out = new Set<IndexContentType>();
for (const perAccount of Object.values(change.changed ?? {})) {
for (const stateType of Object.keys(perAccount ?? {})) {
const mapped = STATE_TYPE_TO_CONTENT[stateType];
if (mapped) out.add(mapped);
}
}
return [...out];
}
export interface IndexRequestOptions {
types?: readonly IndexContentType[];
/**
* Per-type ids to index. Supply them whenever the renderer already knows
* which objects changed - it turns the call into a couple of `Foo/get`s
* instead of a windowed query. Mail is the frequent case and the one where
* this matters.
*/
ids?: Partial<Record<IndexContentType, string[]>>;
/** Backfill the recent window for every supported type, and prune. */
catchUp?: boolean;
/** Cookie slot of the account to index. Defaults to the server's first signed-in slot. */
slot?: number;
}
let inFlight: Promise<IndexRunResult> | null = null;
/** Set once the server says the feature isn't there, so we stop asking. */
let knownUnavailable = false;
/**
* Posts one index request. Single-flighted: a burst of deliveries coalesces
* into the in-flight call rather than queueing N overlapping SQLite writers.
*/
export async function requestIndex(options: IndexRequestOptions = {}): Promise<IndexRunResult> {
if (knownUnavailable) return { ok: false, unavailable: true };
if (inFlight) return inFlight;
const query = typeof options.slot === 'number' ? `?slot=${options.slot}` : '';
const run = (async (): Promise<IndexRunResult> => {
try {
const response = await apiFetch(`/api/offline/reindex${query}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
types: options.types,
ids: options.ids,
catchUp: options.catchUp === true,
}),
});
// 404 = not the desktop shell (or the feature is gated off). Permanent for
// this page load; stop asking so a busy mailbox doesn't post per delivery.
if (response.status === 404) {
knownUnavailable = true;
return { ok: false, unavailable: true };
}
if (response.status === 503) {
// No keyring / no native binding / no key channel. Also permanent for
// this session, and the message is worth surfacing in Settings.
knownUnavailable = true;
const body = await response.json().catch(() => ({}));
return { ok: false, unavailable: true, error: body?.error };
}
if (!response.ok) {
const body = await response.json().catch(() => ({}));
return { ok: false, error: body?.error || `HTTP ${response.status}` };
}
const body = await response.json();
debug.log('push', '[index] reindex done', body?.written, body?.errors);
return {
ok: true,
written: body?.written,
skipped: body?.skipped,
errors: body?.errors,
durationMs: body?.durationMs,
};
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
} finally {
inFlight = null;
}
})();
inFlight = run;
return run;
}
/**
* The event-driven entry point, called from the push handler.
*
* `mailIds` lets the caller hand over the ids it already has (the refreshed
* mailbox page), so the frequent mail case costs one `Email/get` rather than a
* 30-day query. The other three types are rare events (a contact edit, a file
* upload, a calendar change), so they fall back to their own bounded queries.
*/
export function indexOnStateChange(
change: StateChange,
opts: { mailIds?: string[]; slot?: number } = {},
): void {
if (knownUnavailable) return;
const types = contentTypesFromStateChange(change);
if (types.length === 0) return;
const ids: Partial<Record<IndexContentType, string[]>> = {};
if (types.includes('mail') && opts.mailIds && opts.mailIds.length > 0) {
ids.mail = opts.mailIds.slice(0, 100);
}
// Fire-and-forget on purpose: this runs inside the push handler, and the mail
// UI must not wait on a search index.
void requestIndex({ types, ids: Object.keys(ids).length > 0 ? ids : undefined, slot: opts.slot });
}
/**
* Launch-time catch-up: backfills whatever changed while the app was closed,
* for which no push event was ever delivered. Also the recovery path for the
* polling transport, which has no signal for contacts or files at all
* (`client.ts`'s buildStatePollingRequest polls Mailbox/Email/Calendar/
* CalendarEvent/SieveScript only).
*/
export async function catchUpIndex(slot?: number): Promise<IndexRunResult> {
return requestIndex({ catchUp: true, slot });
}
export interface IndexStats {
contentType: string;
count: number;
newest: string | null;
indexedAt: number | null;
}
/** Reads per-type counts without searching. Used by the Settings panel. */
export async function fetchIndexStats(slot?: number): Promise<IndexStats[] | null> {
const slotQuery = typeof slot === 'number' ? `&slot=${slot}` : '';
try {
const response = await apiFetch(`/api/offline/search?stats=true${slotQuery}`);
if (!response.ok) return null;
const body = await response.json();
return Array.isArray(body?.stats) ? (body.stats as IndexStats[]) : [];
} catch {
return null;
}
}
/** Resets the "don't ask again" latch - e.g. after the user signs in again. */
export function resetIndexAvailability(): void {
knownUnavailable = false;
}
+283
View File
@@ -0,0 +1,283 @@
import { describe, expect, it } from 'vitest';
import type {
CalendarEvent, CalendarParticipant, ContactCard, Email, EmailBodyPart, FileNode,
} from '@/lib/jmap/types';
import {
contactDisplayName, emailBodyText, extractCalendarEvent, extractContact, extractFile,
extractMail, htmlToText, MAX_BODY_CHARS, normaliseText,
} from '../extract';
import { buildFilePaths } from '../jmap';
describe('htmlToText', () => {
it('drops script and style CONTENT, not just the tags', () => {
// The important case: a naive `<[^>]+>` strip leaves the script body behind
// as searchable text, so a page full of JS would pollute the index.
const out = htmlToText('<p>Hello</p><script>var secretToken = "abc123";</script><style>.a{color:red}</style>');
expect(out).toContain('Hello');
expect(out).not.toContain('secretToken');
expect(out).not.toContain('abc123');
expect(out).not.toContain('color:red');
});
it('turns block boundaries into newlines and decodes entities', () => {
expect(htmlToText('<p>one</p><p>two</p>')).toBe('one\ntwo');
expect(htmlToText('a<br>b')).toBe('a\nb');
expect(htmlToText('R&amp;D &lt;tag&gt; &quot;q&quot; &nbsp;x')).toBe('R&D <tag> "q" x');
expect(htmlToText('&#8364;10 &#x20AC;20')).toBe('€10 €20');
});
it('ignores comments and out-of-range numeric entities without throwing', () => {
expect(htmlToText('a<!-- hidden -->b')).toBe('a b');
expect(() => htmlToText('&#1114112; &#x999999;')).not.toThrow();
});
});
describe('normaliseText', () => {
it('collapses runs of spaces, tabs and non-breaking spaces', () => {
expect(normaliseText('a \t   b')).toBe('a b');
});
it('caps blank-line runs and handles null/undefined', () => {
expect(normaliseText('a\n\n\n\n\nb')).toBe('a\n\nb');
expect(normaliseText(undefined)).toBe('');
expect(normaliseText(null)).toBe('');
});
});
function baseEmail(overrides: Partial<Email> = {}): Email {
return {
id: 'M1', threadId: 'T1', mailboxIds: { mb1: true }, keywords: {},
size: 100, receivedAt: '2026-08-01T10:00:00Z', hasAttachment: false,
...overrides,
} as Email;
}
describe('emailBodyText', () => {
it('prefers the text/plain part', () => {
const email = baseEmail({
textBody: [{ partId: 'p1' } as EmailBodyPart],
htmlBody: [{ partId: 'p2' } as EmailBodyPart],
bodyValues: { p1: { value: 'plain wins' }, p2: { value: '<b>html loses</b>' } },
});
expect(emailBodyText(email)).toBe('plain wins');
});
it('falls back to flattened HTML when there is no plain alternative', () => {
const email = baseEmail({
htmlBody: [{ partId: 'p2' } as EmailBodyPart],
bodyValues: { p2: { value: '<p>hello</p><p>world</p>' } },
});
expect(emailBodyText(email)).toBe('hello\nworld');
});
it('falls back to preview when bodyValues is missing entirely', () => {
// This is the shape a caller gets when the Email/get omitted
// fetchTextBodyValues - a silent empty body if we did not handle it.
const email = baseEmail({
textBody: [{ partId: 'p1' } as EmailBodyPart],
preview: 'server preview text',
});
expect(emailBodyText(email)).toBe('server preview text');
});
it('treats a whitespace-only plain part as absent', () => {
const email = baseEmail({
textBody: [{ partId: 'p1' } as EmailBodyPart],
htmlBody: [{ partId: 'p2' } as EmailBodyPart],
bodyValues: { p1: { value: ' \n ' }, p2: { value: 'real content' } },
});
expect(emailBodyText(email)).toBe('real content');
});
});
describe('extractMail', () => {
it('flattens addresses into `people` and keeps metadata', () => {
const doc = extractMail('acc1', baseEmail({
subject: 'Quarterly budget',
from: [{ name: 'Sophie Müller', email: 'sophie@example.com' }],
to: [{ email: 'me@example.com' }],
cc: [{ name: 'Bob', email: 'bob@example.com' }],
preview: 'hi',
}));
expect(doc.contentType).toBe('mail');
expect(doc.title).toBe('Quarterly budget');
expect(doc.people).toContain('Sophie Müller sophie@example.com');
expect(doc.people).toContain('bob@example.com');
expect(doc.occurredAt).toBe('2026-08-01T10:00:00Z');
expect(doc.metadata.threadId).toBe('T1');
expect(doc.metadata.mailboxIds).toEqual(['mb1']);
});
it('substitutes a placeholder title rather than indexing an empty one', () => {
expect(extractMail('acc1', baseEmail()).title).toBe('(no subject)');
});
it('clamps a huge body', () => {
const doc = extractMail('acc1', baseEmail({
textBody: [{ partId: 'p1' } as EmailBodyPart],
bodyValues: { p1: { value: 'x'.repeat(MAX_BODY_CHARS * 2) } },
}));
expect(doc.body.length).toBe(MAX_BODY_CHARS);
});
});
function baseEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
return {
id: 'E1', calendarIds: { c1: true }, isDraft: false, isOrigin: true,
utcStart: '2026-08-10T09:00:00Z', utcEnd: '2026-08-10T10:00:00Z',
'@type': 'Event', uid: 'u1', title: 'Standup', description: '',
descriptionContentType: 'text/plain', created: null, updated: '2026-08-01T00:00:00Z',
sequence: 0, start: '2026-08-10T11:00:00', duration: 'PT1H', timeZone: 'Europe/Zurich',
showWithoutTime: false, status: 'confirmed', freeBusyStatus: 'busy', privacy: 'public',
color: null, keywords: null, categories: null, locale: null, replyTo: null,
organizerCalendarAddress: null, participants: null, mayInviteSelf: false,
mayInviteOthers: false, hideAttendees: false, recurrenceId: null,
recurrenceIdTimeZone: null, recurrenceRules: null, recurrenceOverrides: null,
excludedRecurrenceRules: null, useDefaultAlerts: false, alerts: null,
locations: null, virtualLocations: null, links: null, relatedTo: null,
...overrides,
} as CalendarEvent;
}
describe('extractCalendarEvent', () => {
it('indexes description, location, attendees and organizer', () => {
const doc = extractCalendarEvent('acc1', baseEvent({
title: 'Lease decision',
description: 'Zurich office lease renewal',
locations: { l1: { '@type': 'Location', name: 'Room 3.14', description: null, locationTypes: null, coordinates: null, timeZone: null, links: null, relativeTo: null } },
organizerCalendarAddress: 'mailto:boss@example.com',
// A partial participant on purpose: servers omit most JSCalendar fields,
// and the extractor must cope with exactly this shape.
participants: {
p1: { name: 'Ana', email: 'ana@example.com', sendTo: { imip: 'mailto:ana@example.com' } } as unknown as CalendarParticipant,
},
}));
expect(doc.title).toBe('Lease decision');
expect(doc.body).toContain('Zurich office lease renewal');
expect(doc.body).toContain('Room 3.14');
// mailto: prefixes stripped so the address tokenises like every other one.
expect(doc.people).toContain('boss@example.com');
expect(doc.people).not.toContain('mailto:');
expect(doc.people).toContain('ana@example.com');
expect(doc.metadata.participantCount).toBe(1);
});
it('flattens an HTML description', () => {
const doc = extractCalendarEvent('acc1', baseEvent({
description: '<p>agenda</p><script>bad()</script>',
descriptionContentType: 'text/html',
}));
expect(doc.body).toContain('agenda');
expect(doc.body).not.toContain('bad()');
});
it('prefers utcStart over the zone-less local start for ordering', () => {
expect(extractCalendarEvent('acc1', baseEvent()).occurredAt).toBe('2026-08-10T09:00:00Z');
expect(extractCalendarEvent('acc1', baseEvent({ utcStart: null })).occurredAt)
.toBe('2026-08-10T11:00:00');
});
});
describe('extractContact', () => {
const card = (overrides: Partial<ContactCard> = {}): ContactCard =>
({ id: 'C1', addressBookIds: { a1: true }, ...overrides }) as ContactCard;
it('uses name.full when present', () => {
expect(contactDisplayName(card({ name: { full: 'Ada Lovelace' } }))).toBe('Ada Lovelace');
});
it('assembles components in the right order when full is absent', () => {
expect(contactDisplayName(card({
name: { components: [{ kind: 'surname', value: 'Hopper' }, { kind: 'given', value: 'Grace' }] },
}))).toBe('Grace Hopper');
});
it('degrades to an email, then an org, then a placeholder', () => {
expect(contactDisplayName(card({ emails: { e: { address: 'x@y.z' } } }))).toBe('x@y.z');
expect(contactDisplayName(card({ organizations: { o: { name: 'ACME' } } }))).toBe('ACME');
expect(contactDisplayName(card())).toBe('(unnamed contact)');
});
it('puts emails and phones in `people` and notes/orgs in `body`', () => {
const doc = extractContact('acc1', card({
name: { full: 'Ada Lovelace' },
emails: { e1: { address: 'ada@example.com' } },
phones: { p1: { number: '+41 44 000 00 00' } },
organizations: { o1: { name: 'Analytical Engines' } },
notes: { n1: { note: 'met at the Zurich conference' } },
nicknames: { k1: { name: 'The Countess' } },
}));
expect(doc.people).toContain('ada@example.com');
expect(doc.people).toContain('+41 44 000 00 00');
expect(doc.people).toContain('The Countess');
expect(doc.body).toContain('Analytical Engines');
expect(doc.body).toContain('met at the Zurich conference');
// A contact has no single meaningful date; ranking is relevance-only.
expect(doc.occurredAt).toBeNull();
});
it('handles both RFC 9553 and legacy flat address shapes', () => {
expect(extractContact('acc1', card({ addresses: { a: { full: 'Bahnhofstrasse 1, Zurich' } } })).body)
.toContain('Bahnhofstrasse 1, Zurich');
expect(extractContact('acc1', card({ addresses: { a: { street: 'Bahnhofstrasse 1', locality: 'Zurich' } } })).body)
.toContain('Bahnhofstrasse 1, Zurich');
});
});
describe('extractFile', () => {
const node = (overrides: Partial<FileNode> = {}): FileNode =>
({
id: 'F1', parentId: null, name: 'invoice.pdf', type: 'application/pdf',
blobId: 'b1', size: 1234, created: '2026-07-01T00:00:00Z',
modified: '2026-07-15T00:00:00Z', ...overrides,
}) as FileNode;
it('indexes metadata only and says so', () => {
const doc = extractFile('acc1', node(), { path: 'Finance/2026' });
expect(doc.title).toBe('invoice.pdf');
expect(doc.body).toContain('Finance/2026');
expect(doc.body).toContain('pdf');
expect(doc.metadata.contentIndexed).toBe(false);
expect(doc.metadata.mimeType).toBe('application/pdf');
expect(doc.metadata.size).toBe(1234);
});
it('uses `modified` (FileNode has no `updated`) and falls back to `created`', () => {
expect(extractFile('acc1', node()).occurredAt).toBe('2026-07-15T00:00:00Z');
expect(extractFile('acc1', node({ modified: undefined as unknown as string })).occurredAt)
.toBe('2026-07-01T00:00:00Z');
});
it('marks directories', () => {
const doc = extractFile('acc1', node({ name: 'Finance', type: 'd', blobId: null }));
expect(doc.metadata.isDirectory).toBe(true);
expect(doc.metadata.mimeType).toBeNull();
expect(doc.body).toContain('folder');
});
});
describe('buildFilePaths', () => {
it('resolves the PARENT chain, excluding the node itself', () => {
const nodes = [
{ id: 'root', parentId: null, name: 'Finance' },
{ id: 'year', parentId: 'root', name: '2026' },
{ id: 'file', parentId: 'year', name: 'invoice.pdf' },
] as FileNode[];
const paths = buildFilePaths(nodes);
expect(paths.get('file')).toBe('Finance/2026');
expect(paths.get('year')).toBe('Finance');
expect(paths.get('root')).toBe('');
});
it('truncates rather than failing when an ancestor is not in the set', () => {
const nodes = [{ id: 'file', parentId: 'missing', name: 'x.txt' }] as FileNode[];
expect(buildFilePaths(nodes).get('file')).toBe('');
});
it('terminates on a parent cycle', () => {
const nodes = [
{ id: 'a', parentId: 'b', name: 'A' },
{ id: 'b', parentId: 'a', name: 'B' },
] as FileNode[];
expect(() => buildFilePaths(nodes)).not.toThrow();
});
});
+261
View File
@@ -0,0 +1,261 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { randomBytes } from 'node:crypto';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { isSqlcipherAvailable } from '../binding';
import { accountFileToken, getStoreDir, indexDbPath, STORE_DIR_ENV } from '../paths';
import { MailIndex, toFtsMatchQuery, type IndexDoc } from '../store';
describe('toFtsMatchQuery', () => {
it('quotes every token so FTS5 operators in user input cannot break the query', () => {
// FTS5's MATCH grammar is NOT protected by SQL parameter binding: a bare
// quote or a stray NEAR/AND/* raises `fts5: syntax error`, which would turn
// a search box into a 500.
expect(toFtsMatchQuery('a" OR b')).toBe('"a" AND "OR" AND "b"');
// No trailing `*` here: the final token is one character, below the
// prefix-match threshold (see the next test).
expect(toFtsMatchQuery('NEAR(x y)')).toBe('"NEAR" AND "x" AND "y"');
expect(toFtsMatchQuery('NEAR(x yes)')).toBe('"NEAR" AND "x" AND "yes"*');
expect(toFtsMatchQuery('foo*')).toBe('"foo"*');
expect(toFtsMatchQuery('a AND NOT b')).toContain('"NOT"');
});
it('prefix-matches only the final token, and only when it is long enough', () => {
expect(toFtsMatchQuery('zurich lea')).toBe('"zurich" AND "lea"*');
// Two characters would match too much of a mailbox to be useful.
expect(toFtsMatchQuery('zurich le')).toBe('"zurich" AND "le"');
});
it('keeps unicode letters, emails and hyphenated words', () => {
expect(toFtsMatchQuery('Müller')).toBe('"Müller"*');
expect(toFtsMatchQuery('東京')).toBe('"東京"');
expect(toFtsMatchQuery('a@b.com')).toBe('"a@b.com"*');
expect(toFtsMatchQuery("O'Brien-Smith")).toBe('"O\'Brien-Smith"*');
});
it('returns null for input with no usable tokens', () => {
expect(toFtsMatchQuery('')).toBeNull();
expect(toFtsMatchQuery(' ')).toBeNull();
expect(toFtsMatchQuery('***')).toBeNull();
expect(toFtsMatchQuery(undefined as unknown as string)).toBeNull();
});
it('bounds the token count', () => {
const many = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
expect((toFtsMatchQuery(many) ?? '').split(' AND ')).toHaveLength(24);
});
});
describe('paths', () => {
const original = process.env[STORE_DIR_ENV];
afterEach(() => {
if (original === undefined) delete process.env[STORE_DIR_ENV];
else process.env[STORE_DIR_ENV] = original;
});
it('is disabled unless the env var is set - the hosted-deployment gate', () => {
delete process.env[STORE_DIR_ENV];
expect(getStoreDir()).toBeNull();
process.env[STORE_DIR_ENV] = '';
expect(getStoreDir()).toBeNull();
});
it('rejects a relative path, which would resolve against the server cwd', () => {
process.env[STORE_DIR_ENV] = 'offline';
expect(getStoreDir()).toBeNull();
process.env[STORE_DIR_ENV] = '/abs/offline';
expect(getStoreDir()).toBe('/abs/offline');
});
it('hashes the filename so the directory is not an account inventory', () => {
const token = accountFileToken('linus@example.com');
expect(token).toMatch(/^[0-9a-f]{32}$/);
expect(token).not.toContain('linus');
expect(indexDbPath('/s', 'linus@example.com')).toBe(`/s/index/${token}.db`);
// Deterministic - the same account must resolve to the same file forever.
expect(accountFileToken('linus@example.com')).toBe(token);
});
});
function doc(overrides: Partial<IndexDoc> = {}): IndexDoc {
return {
jmapAccountId: 'acc1',
contentType: 'mail',
id: 'M1',
title: 'Quarterly budget review',
people: 'Sophie Müller sophie@example.com',
body: 'The Zurich office lease renewal needs a decision before September.',
occurredAt: '2026-08-01T10:00:00Z',
metadata: { threadId: 'T1' },
...overrides,
};
}
// The native binding is an OPTIONAL dependency, so these skip rather than fail
// on a platform with no prebuild (e.g. Alpine/musl in CI containers).
describe.skipIf(!isSqlcipherAvailable())('MailIndex (real SQLCipher)', () => {
let storeDir: string;
const accountId = 'linus@example.com';
const key = randomBytes(32);
beforeEach(() => {
storeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mail-index-test-'));
});
afterEach(() => {
fs.rmSync(storeDir, { recursive: true, force: true });
});
const open = () => MailIndex.open({ storeDir, accountId, key });
it('writes an ENCRYPTED file - no plaintext recoverable from the raw bytes', () => {
const index = open();
index.upsert([doc()]);
index.close();
const bytes = fs.readFileSync(indexDbPath(storeDir, accountId));
// The canary check, not just a header check: this is the assertion that
// would have caught `PRAGMA key` being a silent no-op.
expect(bytes.includes('Zurich office lease')).toBe(false);
expect(bytes.includes('Quarterly budget')).toBe(false);
expect(bytes.subarray(0, 15).toString('latin1')).not.toBe('SQLite format 3');
});
it('rejects a wrong key and rebuilds instead of throwing at the caller', () => {
const index = open();
index.upsert([doc()]);
index.close();
// A different key cannot read the data; the store recreates the file rather
// than surfacing an unrecoverable error, because the index is derived data
// and the key was never a user secret.
const other = MailIndex.open({ storeDir, accountId, key: randomBytes(32) });
expect(other.search({ query: 'Zurich' })).toHaveLength(0);
other.close();
});
it('refuses a key of the wrong length', () => {
expect(() => MailIndex.open({ storeDir, accountId, key: randomBytes(16) })).toThrow(/32 bytes/);
});
it('finds documents by body, title and people', () => {
const index = open();
index.upsert([doc()]);
expect(index.search({ query: 'Zurich' }).map((h) => h.id)).toEqual(['M1']);
expect(index.search({ query: 'quarterly' }).map((h) => h.id)).toEqual(['M1']);
expect(index.search({ query: 'sophie@example.com' }).map((h) => h.id)).toEqual(['M1']);
expect(index.search({ query: 'nonexistentword' })).toHaveLength(0);
index.close();
});
it('returns a snippet for use as LLM context', () => {
const index = open();
index.upsert([doc()]);
const [hit] = index.search({ query: 'Zurich' });
expect(hit.snippet).toContain('[Zurich]');
expect(hit.metadata.threadId).toBe('T1');
index.close();
});
it('upserting the same id REPLACES the FTS row rather than duplicating it', () => {
const index = open();
index.upsert([doc()]);
index.upsert([doc({ body: 'Completely different content about Geneva.' })]);
// One row, and the OLD text must no longer match - the classic
// stale-FTS-row bug when the index is maintained by hand.
expect(index.search({ query: 'Geneva' })).toHaveLength(1);
expect(index.search({ query: 'Zurich' })).toHaveLength(0);
expect(index.stats().find((s) => s.contentType === 'mail')?.count).toBe(1);
index.close();
});
it('scopes rows by JMAP account, so delegated accounts cannot merge', () => {
const index = open();
index.upsert([
doc({ jmapAccountId: 'acc1', id: 'X', body: 'shared secret alpha' }),
// Same JMAP id under a different account - legal, since JMAP ids are only
// unique within an account (see namespaceMailboxIds in lib/jmap/client.ts).
doc({ jmapAccountId: 'acc2', id: 'X', body: 'shared secret beta' }),
]);
expect(index.stats().find((s) => s.contentType === 'mail')?.count).toBe(2);
const hits = index.search({ query: 'secret' });
expect(hits).toHaveLength(2);
expect(new Set(hits.map((h) => h.jmapAccountId))).toEqual(new Set(['acc1', 'acc2']));
index.close();
});
it('filters by content type and searches across all four by default', () => {
const index = open();
index.upsert([
doc({ contentType: 'mail', id: 'm', title: 'Zurich mail' }),
doc({ contentType: 'calendar', id: 'c', title: 'Zurich meeting' }),
doc({ contentType: 'contact', id: 'k', title: 'Zurich person', occurredAt: null }),
doc({ contentType: 'file', id: 'f', title: 'Zurich file' }),
]);
expect(index.search({ query: 'Zurich' })).toHaveLength(4);
expect(index.search({ query: 'Zurich', types: ['calendar'] }).map((h) => h.id)).toEqual(['c']);
expect(new Set(index.search({ query: 'Zurich', types: ['mail', 'file'] }).map((h) => h.id)))
.toEqual(new Set(['m', 'f']));
index.close();
});
it('weights a title hit above a body-only hit', () => {
const index = open();
index.upsert([
doc({ id: 'body-only', title: 'unrelated', body: 'mentions lease once' }),
doc({ id: 'in-title', title: 'lease renewal', body: 'unrelated text' }),
]);
// bm25 is negative and lower is better, so the title hit must come first.
expect(index.search({ query: 'lease' })[0].id).toBe('in-title');
index.close();
});
it('removes documents and their FTS rows', () => {
const index = open();
index.upsert([doc()]);
expect(index.remove('acc1', 'mail', ['M1'])).toBe(1);
expect(index.search({ query: 'Zurich' })).toHaveLength(0);
expect(index.remove('acc1', 'mail', ['does-not-exist'])).toBe(0);
index.close();
});
it('prunes by date without touching newer rows', () => {
const index = open();
index.upsert([
doc({ id: 'old', occurredAt: '2020-01-01T00:00:00Z', body: 'ancient lease' }),
doc({ id: 'new', occurredAt: '2026-08-01T00:00:00Z', body: 'current lease' }),
]);
expect(index.pruneOlderThan('acc1', 'mail', '2026-01-01T00:00:00Z')).toBe(1);
expect(index.search({ query: 'lease' }).map((h) => h.id)).toEqual(['new']);
index.close();
});
it('reports existing ids and per-type stats', () => {
const index = open();
index.upsert([doc({ id: 'a' }), doc({ id: 'b' }), doc({ contentType: 'file', id: 'f' })]);
expect(index.existingIds('acc1', 'mail')).toEqual(new Set(['a', 'b']));
const stats = index.stats();
expect(stats.find((s) => s.contentType === 'mail')?.count).toBe(2);
expect(stats.find((s) => s.contentType === 'file')?.count).toBe(1);
index.close();
});
it('survives reopening and keeps the data', () => {
const first = open();
first.upsert([doc()]);
first.close();
const second = open();
expect(second.search({ query: 'Zurich' })).toHaveLength(1);
second.close();
});
it('tolerates a hostile query string end to end', () => {
const index = open();
index.upsert([doc()]);
for (const q of ['"', '*', 'a" OR "b', 'NEAR(', ')', 'AND', '^', ':', '-']) {
expect(() => index.search({ query: q })).not.toThrow();
}
index.close();
});
});
+83
View File
@@ -0,0 +1,83 @@
// Guarded loader for the SQLCipher native binding.
//
// WHY THIS FILE EXISTS AT ALL: `@signalapp/sqlcipher` is declared in
// package.json's `optionalDependencies`, not `dependencies`, and it MUST stay
// there. It publishes six N-API prebuilds (darwin/linux/win32 x arm64/x64) and
// **no build sources at all** - the published tarball has no `binding.gyp`, no
// `src/`, no `deps/`. Its install script is `node-gyp-build`, which falls back
// to `node-gyp rebuild` when no prebuild matches, and that fallback cannot
// succeed without sources. So on a platform with no matching prebuild the
// install FAILS.
//
// Both Dockerfiles in this repo are `FROM node:24-alpine` + `npm ci`
// (`Dockerfile:1-4`, `integration/webmail.Dockerfile:15-19`). Alpine is musl;
// there is no `linuxmusl-*` prebuild (and the glibc prebuild could not load
// there anyway). As a hard `dependencies` entry this would break the
// production image build and the integration fixture's webmail container -
// neither of which wants this feature, they just need `npm ci` to exit 0.
// `optionalDependencies` makes npm treat that install failure as non-fatal and
// simply omit the package.
//
// The cost of that choice is exactly this module: the require must be guarded
// at runtime, because "installed" is no longer guaranteed. Callers get
// `null` and the feature turns itself off, which is the correct behaviour for
// a desktop-only search index in a server that may not be a desktop.
/**
* Minimal structural type for the bits of `@signalapp/sqlcipher` we use.
*
* Deliberately hand-written rather than `typeof import('@signalapp/sqlcipher')`:
* the package is optional, so a type-only import would make `tsc` fail on any
* machine where the install was skipped - which is every Alpine CI container.
*
* NOTE the parameter shape. `@signalapp/sqlcipher` is NOT drop-in compatible
* with better-sqlite3 here: its `#checkParams` throws
* `TypeError: Params must be either object or array`, so `stmt.run(a, b, c)`
* (varargs, which better-sqlite3 accepts) is a runtime error. Always pass a
* single array or object. Found by executing it, not by reading the types.
*/
export interface SqlcipherStatement {
run(params?: readonly unknown[] | Record<string, unknown>): { changes: number; lastInsertRowid: number };
get(params?: readonly unknown[] | Record<string, unknown>): Record<string, unknown> | undefined;
all(params?: readonly unknown[] | Record<string, unknown>): Array<Record<string, unknown>>;
}
export interface SqlcipherDatabase {
exec(sql: string): void;
prepare(sql: string): SqlcipherStatement;
pragma(source: string): unknown;
close(): void;
}
export interface SqlcipherConstructor {
new (path?: string): SqlcipherDatabase;
}
let cached: SqlcipherConstructor | null | undefined;
/**
* Returns the Database constructor, or `null` when the optional native binding
* is not installed / cannot load on this platform. Never throws.
*
* Memoised on both outcomes so a missing binding costs one failed require per
* process rather than one per request.
*/
export function loadSqlcipher(): SqlcipherConstructor | null {
if (cached !== undefined) return cached;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const mod = require('@signalapp/sqlcipher') as
| { default?: SqlcipherConstructor }
| SqlcipherConstructor;
const ctor = (mod as { default?: SqlcipherConstructor }).default ?? (mod as SqlcipherConstructor);
cached = typeof ctor === 'function' ? ctor : null;
} catch {
cached = null;
}
return cached;
}
/** True when the local index can work at all in this process. */
export function isSqlcipherAvailable(): boolean {
return loadSqlcipher() !== null;
}
+311
View File
@@ -0,0 +1,311 @@
// PURE JMAP-object -> IndexDoc extractors.
//
// Deliberately free of database, network and store access so every shape
// decision here is unit-testable on its own. The JMAP shapes are awkward
// enough (JSContact keyed maps, JSCalendar participants, FileNode's `modified`
// rather than `updated`) that this is where the bugs would otherwise hide.
import type { Email, CalendarEvent, ContactCard, FileNode, EmailAddress } from '@/lib/jmap/types';
import type { IndexDoc } from './store';
/** Hard cap on indexed body text per document. Keeps one enormous mail from dominating the file. */
export const MAX_BODY_CHARS = 32_000;
/**
* Minimal HTML -> text, for mail that has no `text/plain` alternative.
*
* Not a sanitiser and not trying to be: this output is never rendered, only
* tokenised by FTS5 and possibly handed to an LLM as context. The repo's
* `dompurify` needs a DOM and this runs in Node, so a DOM-free reduction is the
* right tool. Order matters - script/style content must go before tags are
* stripped, or their contents would leak into the index as searchable text.
*/
export function htmlToText(html: string): string {
return html
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/<(script|style|head)\b[\s\S]*?<\/\1>/gi, ' ')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|tr|li|h[1-6]|blockquote)>/gi, '\n')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#(\d+);/g, (_m, d: string) => {
const code = Number(d);
return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : ' ';
})
.replace(/&#x([0-9a-f]+);/gi, (_m, h: string) => {
const code = parseInt(h, 16);
return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : ' ';
})
.replace(/[ \t\u00a0]+/g, ' ')
.replace(/\s*\n\s*/g, '\n')
.trim();
}
export function normaliseText(s: string | null | undefined): string {
if (!s) return '';
return s.replace(/\r\n?/g, '\n').replace(/[ \t\u00a0]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
}
function clamp(s: string, max = MAX_BODY_CHARS): string {
return s.length <= max ? s : s.slice(0, max);
}
function formatAddresses(list: readonly EmailAddress[] | undefined): string {
if (!list || list.length === 0) return '';
return list
.map((a) => [a.name, a.email].filter((p) => typeof p === 'string' && p.length > 0).join(' '))
.filter((s) => s.length > 0)
.join(', ');
}
/** Values of a JSContact/JSCalendar keyed map, in a stable order. */
function mapValues<T>(m: Record<string, T> | null | undefined): T[] {
if (!m || typeof m !== 'object') return [];
return Object.keys(m).sort().map((k) => m[k]);
}
function joinUnique(parts: Array<string | undefined | null>): string {
const seen = new Set<string>();
const out: string[] = [];
for (const p of parts) {
const v = typeof p === 'string' ? p.trim() : '';
if (!v || seen.has(v)) continue;
seen.add(v);
out.push(v);
}
return out.join(', ');
}
// ── mail ────────────────────────────────────────────────────────────────────
/**
* Resolves an Email's plain-text body from `bodyValues`, preferring the
* `text/plain` alternative and falling back to flattening the HTML one.
*
* `textBody`/`htmlBody` reference parts by `partId`; the text itself only
* arrives in `bodyValues` when the `Email/get` asked for it
* (`fetchTextBodyValues` / `fetchHTMLBodyValues`). A caller that forgets that
* gets an empty body rather than an error, which is exactly the kind of silent
* hole worth naming here.
*/
export function emailBodyText(email: Email): string {
const values = email.bodyValues ?? {};
const fromParts = (parts: typeof email.textBody): string =>
(parts ?? [])
.map((p) => values[p.partId]?.value ?? '')
.filter((v) => v.length > 0)
.join('\n\n');
const plain = fromParts(email.textBody);
if (plain.trim().length > 0) return normaliseText(plain);
const html = fromParts(email.htmlBody);
if (html.trim().length > 0) return normaliseText(htmlToText(html));
// Last resort: the server-computed preview. Better than nothing for a search
// index, and it costs no extra round trip.
return normaliseText(email.preview);
}
export function extractMail(jmapAccountId: string, email: Email): IndexDoc {
const body = clamp(emailBodyText(email));
return {
jmapAccountId,
contentType: 'mail',
id: email.id,
title: normaliseText(email.subject) || '(no subject)',
people: joinUnique([
formatAddresses(email.from),
formatAddresses(email.to),
formatAddresses(email.cc),
]),
body,
occurredAt: email.receivedAt ?? null,
metadata: {
threadId: email.threadId,
from: email.from?.[0]?.email ?? null,
fromName: email.from?.[0]?.name ?? null,
hasAttachment: !!email.hasAttachment,
size: email.size ?? null,
mailboxIds: Object.keys(email.mailboxIds ?? {}),
preview: normaliseText(email.preview).slice(0, 300),
},
};
}
// ── calendar ────────────────────────────────────────────────────────────────
export function extractCalendarEvent(jmapAccountId: string, event: CalendarEvent): IndexDoc {
const participants = mapValues(event.participants);
const participantText = joinUnique(
participants.flatMap((p) => [
p?.name,
p?.email,
p?.calendarAddress?.replace(/^mailto:/i, ''),
...Object.values(p?.sendTo ?? {}).map((v) =>
typeof v === 'string' ? v.replace(/^mailto:/i, '') : '',
),
]),
);
const locations = mapValues(event.locations)
.map((l) => normaliseText(l?.name))
.filter((s) => s.length > 0);
// `descriptionContentType` can legitimately be text/html.
const rawDescription = normaliseText(event.description);
const description = /html/i.test(event.descriptionContentType ?? '')
? normaliseText(htmlToText(rawDescription))
: rawDescription;
const keywords = Object.keys(event.keywords ?? {});
const categories = Object.keys(event.categories ?? {});
return {
jmapAccountId,
contentType: 'calendar',
id: event.id,
title: normaliseText(event.title) || '(untitled event)',
people: joinUnique([event.organizerCalendarAddress?.replace(/^mailto:/i, ''), participantText]),
body: clamp(
[description, locations.join(', '), keywords.join(' '), categories.join(' ')]
.filter((s) => s.length > 0)
.join('\n\n'),
),
// `utcStart` is the resolved instant the app computes; `start` is local
// wall-clock without a zone, so prefer utcStart for ordering.
occurredAt: event.utcStart ?? event.start ?? null,
metadata: {
start: event.start ?? null,
utcStart: event.utcStart ?? null,
utcEnd: event.utcEnd ?? null,
timeZone: event.timeZone ?? null,
showWithoutTime: !!event.showWithoutTime,
status: event.status ?? null,
locations,
calendarIds: Object.keys(event.calendarIds ?? {}),
participantCount: participants.length,
},
};
}
// ── contacts ────────────────────────────────────────────────────────────────
export function contactDisplayName(card: ContactCard): string {
const full = normaliseText(card.name?.full);
if (full) return full;
const components = card.name?.components ?? [];
const ordered = ['prefix', 'given', 'given2', 'additional', 'middle', 'surname', 'surname2', 'suffix'];
const byKind = components
.slice()
.sort((a, b) => ordered.indexOf(a.kind) - ordered.indexOf(b.kind))
.map((c) => c.value)
.filter((v) => typeof v === 'string' && v.trim().length > 0)
.join(' ');
if (byKind.trim()) return normaliseText(byKind);
const firstEmail = mapValues(card.emails)[0]?.address;
if (firstEmail) return firstEmail;
const org = mapValues(card.organizations)[0]?.name;
return normaliseText(org) || '(unnamed contact)';
}
export function extractContact(jmapAccountId: string, card: ContactCard): IndexDoc {
const emails = mapValues(card.emails).map((e) => e.address).filter(Boolean);
const phones = mapValues(card.phones).map((p) => p.number).filter(Boolean);
const nicknames = mapValues(card.nicknames)
.map((n) => n?.name)
.filter((v): v is string => typeof v === 'string' && v.length > 0);
const orgs = mapValues(card.organizations).map((o) => o.name).filter((v): v is string => !!v);
const titles = mapValues(card.titles).map((t) => t.name).filter(Boolean);
const notes = mapValues(card.notes).map((n) => n.note).filter(Boolean);
// `full` (RFC 9553) when present, else the legacy flat fields vCard import
// produces, else the ordered components. All three shapes occur in this type.
const addresses = mapValues(card.addresses)
.map((a) =>
normaliseText(
a?.full ||
[a?.street, a?.locality, a?.region, a?.postcode, a?.country]
.filter((p): p is string => typeof p === 'string' && p.length > 0)
.join(', ') ||
(a?.components ?? []).map((c) => c.value).join(' '),
),
)
.filter((s) => s.length > 0);
return {
jmapAccountId,
contentType: 'contact',
id: card.id,
title: contactDisplayName(card),
// Emails/phones go in `people` (weighted above body) because "who is
// this / what's their number" is the dominant contact lookup.
people: joinUnique([...emails, ...phones, ...nicknames]),
body: clamp([...orgs, ...titles, ...addresses, ...notes].filter(Boolean).join('\n')),
// A contact has no meaningful single date; JSContact `updated` is optional
// and not on this repo's type, so leave it null and rank by relevance only.
occurredAt: null,
metadata: {
kind: card.kind ?? null,
emails,
phones,
organizations: orgs,
addressBookIds: Object.keys(card.addressBookIds ?? {}),
},
};
}
// ── files ───────────────────────────────────────────────────────────────────
/**
* METADATA ONLY - filename, path, dates, size, owner. Deliberately NOT file
* content: extracting searchable text from arbitrary PDFs / office documents /
* images is a materially bigger problem (per-format parsers, OCR, size limits,
* untrusted-input parsing in a process holding the user's mail) and is a
* separate piece of work. `path` is passed in by the caller because a FileNode
* only knows its `parentId`; resolving the chain is the caller's job.
*/
export function extractFile(
jmapAccountId: string,
node: FileNode,
opts: { path?: string; ownerName?: string } = {},
): IndexDoc {
const dirPath = normaliseText(opts.path);
const isDirectory = node.type === 'd';
return {
jmapAccountId,
contentType: 'file',
id: node.id,
title: normaliseText(node.name) || '(unnamed file)',
people: joinUnique([opts.ownerName, node.accountName]),
// The path is genuinely searchable text ("that thing in Invoices/2026"),
// and the extension is worth tokenising on its own.
body: clamp(
[dirPath, isDirectory ? 'folder' : node.type, fileExtension(node.name)]
.filter((s) => s && s.length > 0)
.join('\n'),
),
// FileNode has `modified`, NOT `updated` - asking for the wrong name
// silently yields undefined (this repo hit that as #700).
occurredAt: node.modified ?? node.created ?? null,
metadata: {
path: dirPath || null,
mimeType: isDirectory ? null : node.type,
isDirectory,
size: typeof node.size === 'number' ? node.size : null,
created: node.created ?? null,
modified: node.modified ?? null,
parentId: node.parentId ?? null,
contentIndexed: false,
},
};
}
function fileExtension(name: string | undefined): string {
if (!name) return '';
const i = name.lastIndexOf('.');
return i > 0 && i < name.length - 1 ? name.slice(i + 1).toLowerCase() : '';
}
+388
View File
@@ -0,0 +1,388 @@
// A deliberately tiny server-side JMAP client, used only by the indexer.
//
// WHY NOT REUSE lib/jmap/client.ts: that class is a 7400-line renderer object.
// It holds credentials in instance fields, uses `btoa`, opens EventSource /
// WebSocket push connections, and wires itself into Zustand stores and toast
// notifications. Importing it into an API route would drag all of that into the
// server bundle for the sake of four method calls. The existing server-side
// JMAP code in this repo (lib/auth/verify-jmap-auth.ts) already sets the
// precedent: plain fetch + an Authorization header.
//
// Everything here is stateless - the caller supplies the auth header per call,
// so there is no resident credential and nothing to invalidate.
import type { CalendarEvent, ContactCard, Email, FileNode } from '@/lib/jmap/types';
const REQUEST_TIMEOUT_MS = 30_000;
export const CAP_CORE = 'urn:ietf:params:jmap:core';
export const CAP_MAIL = 'urn:ietf:params:jmap:mail';
export const CAP_CALENDARS = 'urn:ietf:params:jmap:calendars';
export const CAP_CONTACTS = 'urn:ietf:params:jmap:contacts';
export const CAP_FILENODE = 'urn:ietf:params:jmap:filenode';
export class JmapIndexError extends Error {
status: number;
constructor(message: string, status = 502) {
super(message);
this.name = 'JmapIndexError';
this.status = status;
}
}
export interface JmapSessionInfo {
apiUrl: string;
/** Server-confirmed authenticated login (JMAP Session.username). */
username?: string;
primaryAccounts: Record<string, string>;
accounts: Record<string, { name?: string; isPersonal?: boolean; accountCapabilities?: Record<string, unknown> }>;
capabilities: Record<string, unknown>;
}
/**
* Pins a URL advertised by the session to the origin we authenticated against.
*
* `lib/jmap/client.ts` does the same thing in its rewriteSessionUrls() for the
* renderer's benefit. Server-side it is a security control, not a convenience:
* we attach the user's credentials to this URL, so a session document that
* advertised an `apiUrl` on someone else's host would turn this into a
* credential-leaking SSRF. Keep the path and query, take the origin from the
* server URL we were configured with.
*/
function pinToServerOrigin(advertised: string, serverUrl: string): string {
const base = new URL(serverUrl);
let target: URL;
try {
target = new URL(advertised, base);
} catch {
throw new JmapIndexError('JMAP session advertised an unusable apiUrl');
}
return `${base.origin}${target.pathname}${target.search}`;
}
async function fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
return await fetch(url, { ...init, signal: controller.signal, redirect: 'manual' });
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new JmapIndexError('JMAP request timed out', 504);
}
throw new JmapIndexError(`JMAP request failed: ${String(error)}`);
} finally {
clearTimeout(timer);
}
}
/** Stalwart 307-redirects /.well-known/jmap to /jmap/session. */
const MAX_REDIRECTS = 3;
export async function fetchJmapSession(serverUrl: string, authHeader: string): Promise<JmapSessionInfo> {
const base = serverUrl.replace(/\/+$/, '');
const origin = new URL(base).origin;
let currentUrl = `${base}/.well-known/jmap`;
let response: Response | undefined;
// Redirects must be followed EXPLICITLY, not with `redirect: 'follow'`: we
// attach the user's credentials to every hop, so each one has to be checked to
// still be on the origin we authenticated against. A blind follow would hand
// the Authorization header to whatever host a misconfigured or hostile session
// pointed at. Same reasoning (and same bound) as lib/auth/verify-jmap-auth.ts.
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
response = await fetchWithTimeout(currentUrl, {
method: 'GET',
headers: { Authorization: authHeader },
});
if (response.status < 300 || response.status >= 400) break;
const location = response.headers.get('location');
if (!location) throw new JmapIndexError('JMAP session redirect had no Location header');
const next = new URL(location, currentUrl);
if (next.origin !== origin) {
throw new JmapIndexError(
`JMAP session redirected off-origin (${next.origin}); refusing to send credentials there`,
);
}
currentUrl = next.toString();
}
if (!response) throw new JmapIndexError('JMAP session fetch produced no response');
if (response.status === 401 || response.status === 403) {
throw new JmapIndexError('JMAP authentication failed', 401);
}
if (response.status >= 300 && response.status < 400) {
throw new JmapIndexError('Too many redirects fetching the JMAP session');
}
if (!response.ok) {
throw new JmapIndexError(`JMAP session fetch failed (${response.status})`);
}
const raw = (await response.json().catch(() => null)) as Record<string, unknown> | null;
if (!raw || typeof raw.apiUrl !== 'string') {
throw new JmapIndexError('Invalid JMAP session response');
}
return {
apiUrl: pinToServerOrigin(raw.apiUrl, serverUrl),
username: typeof raw.username === 'string' ? raw.username : undefined,
primaryAccounts: (raw.primaryAccounts as Record<string, string>) ?? {},
accounts: (raw.accounts as JmapSessionInfo['accounts']) ?? {},
capabilities: (raw.capabilities as Record<string, unknown>) ?? {},
};
}
type MethodCall = [string, Record<string, unknown>, string];
/** Raw method-response tuple, `[name, args, callId]`. `name` is 'error' on failure. */
type MethodResponse = [string, Record<string, unknown>, string];
export async function jmapRequest(
session: JmapSessionInfo,
authHeader: string,
using: readonly string[],
methodCalls: readonly MethodCall[],
): Promise<MethodResponse[]> {
const response = await fetchWithTimeout(session.apiUrl, {
method: 'POST',
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify({ using, methodCalls }),
});
if (response.status === 401 || response.status === 403) {
throw new JmapIndexError('JMAP authentication failed', 401);
}
if (response.status === 429) {
throw new JmapIndexError('JMAP server is rate limiting', 429);
}
if (!response.ok) {
throw new JmapIndexError(`JMAP request failed (${response.status})`);
}
const data = (await response.json().catch(() => null)) as { methodResponses?: MethodResponse[] } | null;
if (!data || !Array.isArray(data.methodResponses)) {
throw new JmapIndexError('Invalid JMAP response envelope');
}
return data.methodResponses;
}
function firstResult(responses: MethodResponse[], expected: string): Record<string, unknown> | null {
for (const [name, args] of responses) {
if (name === expected) return args;
// A method-level error is not fatal for an INDEX: a server that doesn't
// support one data type should not fail the whole reindex. The caller
// treats null as "nothing to index for this type".
if (name === 'error') return null;
}
return null;
}
function idsOf(args: Record<string, unknown> | null): string[] {
const ids = args?.ids;
return Array.isArray(ids) ? ids.filter((v): v is string => typeof v === 'string') : [];
}
function listOf<T>(args: Record<string, unknown> | null): T[] {
const list = args?.list;
return Array.isArray(list) ? (list as T[]) : [];
}
export function accountIdFor(session: JmapSessionInfo, capability: string): string | null {
const id = session.primaryAccounts[capability];
return typeof id === 'string' && id.length > 0 ? id : null;
}
export function hasCapability(session: JmapSessionInfo, capability: string): boolean {
return Object.prototype.hasOwnProperty.call(session.capabilities, capability);
}
/** Per-ACCOUNT capability, mirroring client.ts's supportsFiles() (#563: a server can advertise it while an account has it revoked). */
export function accountHasCapability(
session: JmapSessionInfo,
accountId: string,
capability: string,
): boolean {
const account = session.accounts[accountId];
if (!account) return false;
if (account.accountCapabilities && Object.prototype.hasOwnProperty.call(account.accountCapabilities, capability)) {
return true;
}
return account.isPersonal === false;
}
// ── mail ────────────────────────────────────────────────────────────────────
/** Properties needed to build a mail IndexDoc. Bodies come via bodyValues. */
const EMAIL_INDEX_PROPERTIES = [
'id', 'threadId', 'mailboxIds', 'keywords', 'size', 'receivedAt',
'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment',
'textBody', 'htmlBody', 'bodyValues',
] as const;
export async function getEmailsForIndex(
session: JmapSessionInfo,
authHeader: string,
accountId: string,
ids: readonly string[],
maxBodyBytes: number,
): Promise<Email[]> {
if (ids.length === 0) return [];
const responses = await jmapRequest(session, authHeader, [CAP_CORE, CAP_MAIL], [
['Email/get', {
accountId,
ids: [...ids],
properties: [...EMAIL_INDEX_PROPERTIES],
// Without these two the bodyValues map comes back EMPTY and every
// indexed body would silently fall back to `preview`.
fetchTextBodyValues: true,
fetchHTMLBodyValues: true,
maxBodyValueBytes: maxBodyBytes,
}, 'g'],
]);
return listOf<Email>(firstResult(responses, 'Email/get'));
}
export async function queryRecentEmailIds(
session: JmapSessionInfo,
authHeader: string,
accountId: string,
afterIso: string,
limit: number,
): Promise<string[]> {
const responses = await jmapRequest(session, authHeader, [CAP_CORE, CAP_MAIL], [
['Email/query', {
accountId,
filter: { after: afterIso },
sort: [{ property: 'receivedAt', isAscending: false }],
limit,
calculateTotal: false,
}, 'q'],
]);
return idsOf(firstResult(responses, 'Email/query'));
}
// ── calendar ────────────────────────────────────────────────────────────────
export async function getCalendarEventsForIndex(
session: JmapSessionInfo,
authHeader: string,
accountId: string,
ids: readonly string[],
): Promise<CalendarEvent[]> {
if (ids.length === 0) return [];
const responses = await jmapRequest(session, authHeader, [CAP_CORE, CAP_CALENDARS], [
['CalendarEvent/get', { accountId, ids: [...ids] }, 'g'],
]);
return listOf<CalendarEvent>(firstResult(responses, 'CalendarEvent/get'));
}
export async function queryCalendarEventIds(
session: JmapSessionInfo,
authHeader: string,
accountId: string,
afterIso: string,
beforeIso: string,
limit: number,
): Promise<string[]> {
const responses = await jmapRequest(session, authHeader, [CAP_CORE, CAP_CALENDARS], [
['CalendarEvent/query', {
accountId,
// LocalDateTime, per the note in lib/jmap/client.ts:307-312 - Stalwart
// parses these without a zone suffix and ignores unparseable values.
filter: { after: toLocalDateTime(afterIso), before: toLocalDateTime(beforeIso) },
limit,
calculateTotal: false,
}, 'q'],
]);
return idsOf(firstResult(responses, 'CalendarEvent/query'));
}
/** JSCalendar LocalDateTime: `YYYY-MM-DDTHH:MM:SS`, no zone designator. */
function toLocalDateTime(iso: string): string {
return iso.replace(/\.\d+/, '').replace(/Z$/, '').slice(0, 19);
}
// ── contacts ────────────────────────────────────────────────────────────────
export async function getContactsForIndex(
session: JmapSessionInfo,
authHeader: string,
accountId: string,
ids: readonly string[],
): Promise<ContactCard[]> {
if (ids.length === 0) return [];
const responses = await jmapRequest(session, authHeader, [CAP_CORE, CAP_CONTACTS], [
['ContactCard/get', { accountId, ids: [...ids] }, 'g'],
]);
return listOf<ContactCard>(firstResult(responses, 'ContactCard/get'));
}
export async function queryContactIds(
session: JmapSessionInfo,
authHeader: string,
accountId: string,
limit: number,
): Promise<string[]> {
const responses = await jmapRequest(session, authHeader, [CAP_CORE, CAP_CONTACTS], [
['ContactCard/query', { accountId, limit, calculateTotal: false }, 'q'],
]);
return idsOf(firstResult(responses, 'ContactCard/query'));
}
// ── files ───────────────────────────────────────────────────────────────────
const FILENODE_INDEX_PROPERTIES = [
'id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'modified',
] as const;
export async function getFilesForIndex(
session: JmapSessionInfo,
authHeader: string,
accountId: string,
ids: readonly string[],
): Promise<FileNode[]> {
if (ids.length === 0) return [];
const responses = await jmapRequest(session, authHeader, [CAP_CORE], [
['FileNode/get', { accountId, ids: [...ids], properties: [...FILENODE_INDEX_PROPERTIES] }, 'g'],
]);
return listOf<FileNode>(firstResult(responses, 'FileNode/get'));
}
export async function queryFileIds(
session: JmapSessionInfo,
authHeader: string,
accountId: string,
limit: number,
): Promise<string[]> {
const responses = await jmapRequest(session, authHeader, [CAP_CORE], [
['FileNode/query', { accountId, filter: {}, limit, calculateTotal: false }, 'q'],
]);
return idsOf(firstResult(responses, 'FileNode/query'));
}
/**
* Builds `id -> "Parent/Child"` paths for the given nodes, walking `parentId`
* upward. FileNode only knows its parent, so the caller has to assemble this;
* unresolvable ancestors just truncate the path rather than failing.
*/
export function buildFilePaths(nodes: readonly FileNode[]): Map<string, string> {
const byId = new Map(nodes.map((n) => [n.id, n]));
const cache = new Map<string, string>();
const resolve = (id: string, depth: number): string => {
if (depth > 32) return '';
const cached = cache.get(id);
if (cached !== undefined) return cached;
const node = byId.get(id);
if (!node) return '';
const parent = node.parentId ? resolve(node.parentId, depth + 1) : '';
const full = parent ? `${parent}/${node.name}` : node.name;
cache.set(id, full);
return full;
};
const out = new Map<string, string>();
for (const n of nodes) {
// The document's own `path` metadata is its PARENT directory chain, so a
// search for "Invoices" matches files inside it without the filename
// being duplicated into the body.
out.set(n.id, n.parentId ? resolve(n.parentId, 0) : '');
}
return out;
}
+203
View File
@@ -0,0 +1,203 @@
// Server-side client for the main process's key service (electron/key-service.ts).
//
// Asks for an account's index key over the inherited fd only when a job needs
// it, and drops it as soon as the job finishes. There is deliberately no cache:
// a resident plaintext key in a long-lived process is exactly the thing the OS
// keychain exists to avoid, and a keychain round trip costs microseconds
// against a job that makes network calls.
import net from 'node:net';
/** Set by electron/main.ts alongside VNCMAIL_DESKTOP_STORE_DIR. */
export const KEY_FD_ENV = 'VNCMAIL_DESKTOP_KEY_FD';
const REQUEST_TIMEOUT_MS = 10_000;
export type KeyErrorCode =
| 'no-channel'
| 'no-secure-storage'
| 'key-io-failed'
| 'key-unreadable'
| 'bad-request'
| 'timeout';
export class IndexKeyError extends Error {
code: KeyErrorCode;
constructor(code: KeyErrorCode, message: string) {
super(message);
this.name = 'IndexKeyError';
this.code = code;
}
}
interface Pending {
resolve: (value: { key?: string }) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
}
/**
* Channel state lives on `globalThis`, NOT in module scope.
*
* A file descriptor can be adopted as a socket exactly ONCE per process: a
* second `new net.Socket({ fd })` for an fd this process already owns throws
* `EEXIST` from libuv's uv_pipe_open. Module scope is not once-per-process -
* Next re-evaluates route modules (dev HMR, and separate module instances
* across route bundles), so a module-scoped `let socket` produced exactly that
* crash: `Could not open fd 3: Error: open EEXIST`, found by the integration
* test rather than by reading the code.
*
* A Symbol key on globalThis is the one place in a Node process that survives
* module re-evaluation, so adoption genuinely happens once.
*/
interface ChannelState {
socket: net.Socket | null;
nextId: number;
pending: Map<number, Pending>;
readBuffer: string;
}
const STATE_KEY = Symbol.for('vncmail.mailIndex.keyChannel');
function state(): ChannelState {
const holder = globalThis as unknown as Record<symbol, ChannelState | undefined>;
const existing = holder[STATE_KEY];
if (existing) return existing;
const created: ChannelState = { socket: null, nextId: 1, pending: new Map(), readBuffer: '' };
holder[STATE_KEY] = created;
return created;
}
function failAll(s: ChannelState, error: Error): void {
for (const [, p] of s.pending) {
clearTimeout(p.timer);
p.reject(error);
}
s.pending.clear();
}
function getSocket(): net.Socket {
const s = state();
if (s.socket && !s.socket.destroyed) return s.socket;
const raw = process.env[KEY_FD_ENV]?.trim();
const fd = raw ? Number(raw) : NaN;
if (!Number.isInteger(fd) || fd < 3) {
throw new IndexKeyError(
'no-channel',
`${KEY_FD_ENV} is not a usable file descriptor (got ${JSON.stringify(raw)}). ` +
`The local index only works inside the Electron desktop shell.`,
);
}
let created: net.Socket;
try {
created = new net.Socket({ fd, readable: true, writable: true });
} catch (error) {
throw new IndexKeyError('no-channel', `Could not open fd ${fd}: ${String(error)}`);
}
// The channel outlives every individual request; don't let it hold the event
// loop open on its own.
created.unref();
created.on('data', (chunk: Buffer) => {
s.readBuffer += chunk.toString('utf8');
if (s.readBuffer.length > 64 * 1024) s.readBuffer = '';
let newline: number;
while ((newline = s.readBuffer.indexOf('\n')) >= 0) {
const line = s.readBuffer.slice(0, newline);
s.readBuffer = s.readBuffer.slice(newline + 1);
if (!line.trim()) continue;
let msg: { id?: unknown; ok?: unknown; key?: unknown; code?: unknown; error?: unknown };
try {
msg = JSON.parse(line);
} catch {
continue;
}
const id = typeof msg.id === 'number' ? msg.id : null;
if (id === null) continue;
const p = s.pending.get(id);
if (!p) continue;
s.pending.delete(id);
clearTimeout(p.timer);
if (msg.ok === true) {
p.resolve({ key: typeof msg.key === 'string' ? msg.key : undefined });
} else {
const code = typeof msg.code === 'string' ? (msg.code as KeyErrorCode) : 'key-io-failed';
p.reject(new IndexKeyError(code, typeof msg.error === 'string' ? msg.error : 'Key request failed'));
}
}
});
const onGone = (error?: Error) => {
s.socket = null;
s.readBuffer = '';
failAll(s, error ?? new IndexKeyError('no-channel', 'Key service channel closed'));
};
created.on('close', () => onGone());
created.on('error', (error) => onGone(new IndexKeyError('no-channel', String(error))));
s.socket = created;
return created;
}
function request(op: 'getIndexKey' | 'deleteIndexKey', accountId: string): Promise<{ key?: string }> {
const sock = getSocket();
const s = state();
const id = s.nextId++;
return new Promise<{ key?: string }>((resolve, reject) => {
const timer = setTimeout(() => {
s.pending.delete(id);
reject(new IndexKeyError('timeout', `Key service did not answer within ${REQUEST_TIMEOUT_MS}ms`));
}, REQUEST_TIMEOUT_MS);
// Don't let a pending key request keep the process alive either.
timer.unref?.();
s.pending.set(id, { resolve, reject, timer });
try {
sock.write(`${JSON.stringify({ id, op, accountId })}\n`);
} catch (error) {
s.pending.delete(id);
clearTimeout(timer);
reject(new IndexKeyError('no-channel', `Could not write to the key service: ${String(error)}`));
}
});
}
/**
* Runs `fn` with the account's raw index key, then zeroes the buffer.
*
* Zeroing a Buffer is genuine (unlike a JS string, which cannot be scrubbed) -
* which is why the key crosses the boundary as hex and is converted to a Buffer
* exactly once, here. `store.ts` puts the hex into a `PRAGMA` string, so a copy
* does briefly exist in the JS heap; the buffer wipe bounds how long the
* long-lived copy lives, it does not pretend to eliminate every trace.
*/
export async function withIndexKey<T>(
accountId: string,
fn: (key: Buffer) => Promise<T> | T,
): Promise<T> {
const { key: hex } = await request('getIndexKey', accountId);
if (!hex) throw new IndexKeyError('key-io-failed', 'Key service returned no key');
const key = Buffer.from(hex, 'hex');
if (key.length !== 32) {
key.fill(0);
throw new IndexKeyError('key-io-failed', `Key service returned ${key.length} bytes, expected 32`);
}
try {
return await fn(key);
} finally {
key.fill(0);
}
}
/** Used when purging an account: the key goes FIRST, so an interrupted purge leaves unreadable data. */
export async function deleteIndexKey(accountId: string): Promise<void> {
await request('deleteIndexKey', accountId);
}
/** True when this process has a key channel at all (i.e. is the desktop shell's server). */
export function hasKeyChannel(): boolean {
const raw = process.env[KEY_FD_ENV]?.trim();
const fd = raw ? Number(raw) : NaN;
return Number.isInteger(fd) && fd >= 3;
}
+51
View File
@@ -0,0 +1,51 @@
// The hosted-deployment gate, and where an account's index file lives.
//
// The standalone Next.js server in `electron/main.ts` is the SAME artifact the
// production `Dockerfile` ships to multi-tenant deployments. An index that
// activated unconditionally would have a shared server start writing every
// user's mail into a server-side SQLite file. So activation is keyed on an env
// var that ONLY `electron/main.ts` sets, and that same var supplies the path -
// one variable doing both jobs, so they cannot drift apart.
import { createHash } from 'node:crypto';
import path from 'node:path';
/** Set by electron/main.ts on spawn. Absent => the feature does not exist. */
export const STORE_DIR_ENV = 'VNCMAIL_DESKTOP_STORE_DIR';
/**
* The index root, or `null` when this process is not the desktop shell's
* server. Every route must 404 on `null` - not 403, since nothing should learn
* the routes exist in a deployment that doesn't have the feature.
*/
export function getStoreDir(): string | null {
const dir = process.env[STORE_DIR_ENV]?.trim();
if (!dir) return null;
// Must be absolute: a relative path would resolve against the server's cwd,
// which differs between `electron:dev` and a packaged build.
if (!path.isAbsolute(dir)) return null;
return dir;
}
/**
* Filenames are a hash, not `username@host`, so a directory listing is not a
* plaintext inventory of the user's accounts. The account id itself lives only
* inside the encrypted file (and in the renderer's own `account-registry`,
* which already stores it in plain localStorage).
*/
export function accountFileToken(accountId: string): string {
return createHash('sha256').update(accountId, 'utf8').digest('hex').slice(0, 32);
}
export function indexDbPath(storeDir: string, accountId: string): string {
return path.join(storeDir, 'index', `${accountFileToken(accountId)}.db`);
}
export function keyFilePath(storeDir: string, accountId: string): string {
return path.join(storeDir, 'keys', `${accountFileToken(accountId)}.bin`);
}
/** WAL siblings must be removed with the database, or a purge leaks readable pages. */
export function dbSiblings(dbPath: string): string[] {
return [dbPath, `${dbPath}-wal`, `${dbPath}-shm`];
}
+332
View File
@@ -0,0 +1,332 @@
// The index jobs.
//
// TWO SHAPES, both plain request-scoped work - there is no background worker,
// no cursor, no retry ladder and no resident credential anywhere:
//
// 1. `indexDocuments()` - the PRIMARY path. The renderer's live JMAP push
// connection sees a StateChange, and calls the route with the ids that
// changed (or with no ids, meaning "refetch what's recent for this type").
// One or a handful of objects, fetched and upserted.
// 2. `catchUpAll()` - the FALLBACK. On app launch, backfill a bounded recent
// window for every supported type, because anything that changed while the
// app was closed produced no push event.
//
// Staleness between refreshes is acceptable by design: this is a search index
// for a retrieval/AI feature, not a mail replica.
import type { NextRequest } from 'next/server';
import { generateAccountId } from '@/lib/account-utils';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { logger } from '@/lib/logger';
import {
accountHasCapability, accountIdFor, buildFilePaths, CAP_CALENDARS, CAP_CONTACTS,
CAP_FILENODE, CAP_MAIL, fetchJmapSession, getCalendarEventsForIndex, getContactsForIndex,
getEmailsForIndex, getFilesForIndex, hasCapability, JmapIndexError, queryCalendarEventIds,
queryContactIds, queryFileIds, queryRecentEmailIds, type JmapSessionInfo,
} from './jmap';
import { extractCalendarEvent, extractContact, extractFile, extractMail } from './extract';
import { withIndexKey } from './key';
import { getStoreDir } from './paths';
import { MailIndex, type ContentType, type IndexDoc } from './store';
/**
* Bounded window. Small on purpose: this is the first cut of a retrieval index,
* and a wide window turns "index on every delivery" into a slow request. The
* event-driven path indexes single objects, so the window only bounds catch-up.
*/
export const INDEX_WINDOW_DAYS = 30;
/** Calendar looks forward as well as back - upcoming events are the useful ones. */
export const CALENDAR_FORWARD_DAYS = 180;
/** Per-type ceiling for one catch-up pass. */
export const CATCHUP_MAX_PER_TYPE = 500;
/** Ids accepted in one event-driven call. A push reports a handful, not thousands. */
export const MAX_IDS_PER_CALL = 200;
/** Cap on body bytes requested per message from the server. */
export const MAX_BODY_VALUE_BYTES = 256_000;
/** Contacts and files have no useful date filter, so they are simply capped. */
export const CONTACTS_MAX = 2_000;
export const FILES_MAX = 2_000;
export interface IndexSession {
serverUrl: string;
authHeader: string;
username: string;
slot: number;
/** `username@host` - the durable per-account key. NEVER the cookie slot. */
accountId: string;
}
export class IndexSessionError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'IndexSessionError';
this.status = status;
}
}
/**
* Resolves the calling request to an account and a usable Authorization header.
*
* Uses the SAME per-slot encrypted `jmap_stalwart_ctx` cookie that
* `/api/settings`, `/api/push/preview` and `/api/plugin-approval-status`
* already read (`lib/stalwart/credentials.ts`). That cookie is written by
* `/api/auth/stalwart-context`, which the renderer syncs on every login,
* session restore, SSO callback, account switch and token refresh
* (`stores/auth-store.ts`, 10 call sites), and it carries a ready-made header
* for BOTH basic and bearer accounts.
*
* Why this matters beyond convenience: it means the indexer never touches the
* OAuth refresh-token cookie. A server-side refresh would rotate the token into
* a response nobody reads while the browser kept the superseded one, and the
* next real refresh would then fail and log the user out. Reading an
* already-minted header cannot cause that.
*/
export async function resolveIndexSession(request: NextRequest): Promise<IndexSession> {
const credentials = await getStalwartCredentials(request);
if (!credentials) {
throw new IndexSessionError('No JMAP auth context for this account; sign in again.', 401);
}
const accountId = generateAccountId(credentials.username, credentials.serverUrl);
return { ...credentials, accountId };
}
export interface IndexResult {
accountId: string;
/** Per-type counts of documents written. */
written: Partial<Record<ContentType, number>>;
/** Types the server (or this account) doesn't support, so nothing was attempted. */
skipped: ContentType[];
/** Non-fatal per-type failures. One broken type must not fail the whole call. */
errors: Array<{ contentType: ContentType; message: string }>;
durationMs: number;
}
function isoDaysFromNow(days: number): string {
return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
}
/**
* Which types this session can actually index. Calendar/contacts are session
* capabilities; files is a PER-ACCOUNT capability (a server can advertise
* filenode while a specific account has it revoked - #563).
*/
export function supportedTypes(session: JmapSessionInfo): {
supported: ContentType[];
skipped: ContentType[];
accountIds: Partial<Record<ContentType, string>>;
} {
const supported: ContentType[] = [];
const skipped: ContentType[] = [];
const accountIds: Partial<Record<ContentType, string>> = {};
const mailAccount = accountIdFor(session, CAP_MAIL);
if (mailAccount) { supported.push('mail'); accountIds.mail = mailAccount; }
else skipped.push('mail');
const calAccount = accountIdFor(session, CAP_CALENDARS);
if (calAccount && hasCapability(session, CAP_CALENDARS)) {
supported.push('calendar'); accountIds.calendar = calAccount;
} else skipped.push('calendar');
const contactAccount = accountIdFor(session, CAP_CONTACTS);
if (contactAccount && hasCapability(session, CAP_CONTACTS)) {
supported.push('contact'); accountIds.contact = contactAccount;
} else skipped.push('contact');
// Files fall back to the mail account id: Stalwart exposes FileNode on the
// same account and does not always list a primaryAccounts entry for it.
const fileAccount = accountIdFor(session, CAP_FILENODE) ?? mailAccount;
if (fileAccount && accountHasCapability(session, fileAccount, CAP_FILENODE)) {
supported.push('file'); accountIds.file = fileAccount;
} else skipped.push('file');
return { supported, skipped, accountIds };
}
interface FetchArgs {
session: JmapSessionInfo;
authHeader: string;
jmapAccountId: string;
ids: readonly string[] | null;
}
/** Fetches and flattens one content type. `ids === null` means "the recent window". */
async function fetchDocs(contentType: ContentType, args: FetchArgs): Promise<IndexDoc[]> {
const { session, authHeader, jmapAccountId, ids } = args;
switch (contentType) {
case 'mail': {
const targetIds = ids ?? await queryRecentEmailIds(
session, authHeader, jmapAccountId,
isoDaysFromNow(-INDEX_WINDOW_DAYS), CATCHUP_MAX_PER_TYPE,
);
const docs: IndexDoc[] = [];
// Chunked because bodies are big: one Email/get for 500 messages with
// full bodies would be an enormous response.
for (let i = 0; i < targetIds.length; i += 25) {
const emails = await getEmailsForIndex(
session, authHeader, jmapAccountId, targetIds.slice(i, i + 25), MAX_BODY_VALUE_BYTES,
);
for (const email of emails) docs.push(extractMail(jmapAccountId, email));
}
return docs;
}
case 'calendar': {
const targetIds = ids ?? await queryCalendarEventIds(
session, authHeader, jmapAccountId,
isoDaysFromNow(-INDEX_WINDOW_DAYS), isoDaysFromNow(CALENDAR_FORWARD_DAYS),
CATCHUP_MAX_PER_TYPE,
);
const docs: IndexDoc[] = [];
for (let i = 0; i < targetIds.length; i += 50) {
const events = await getCalendarEventsForIndex(
session, authHeader, jmapAccountId, targetIds.slice(i, i + 50),
);
for (const event of events) docs.push(extractCalendarEvent(jmapAccountId, event));
}
return docs;
}
case 'contact': {
const targetIds = ids ?? await queryContactIds(session, authHeader, jmapAccountId, CONTACTS_MAX);
const docs: IndexDoc[] = [];
for (let i = 0; i < targetIds.length; i += 100) {
const cards = await getContactsForIndex(
session, authHeader, jmapAccountId, targetIds.slice(i, i + 100),
);
for (const card of cards) docs.push(extractContact(jmapAccountId, card));
}
return docs;
}
case 'file': {
const targetIds = ids ?? await queryFileIds(session, authHeader, jmapAccountId, FILES_MAX);
const nodes = [];
for (let i = 0; i < targetIds.length; i += 100) {
nodes.push(...await getFilesForIndex(
session, authHeader, jmapAccountId, targetIds.slice(i, i + 100),
));
}
// Paths need the whole set in hand, so this one can't stream per chunk.
const paths = buildFilePaths(nodes);
return nodes
// Directories are indexed too: "what's in the Invoices folder" is a
// real query, and a folder row is a few bytes.
.map((node) => extractFile(jmapAccountId, node, { path: paths.get(node.id) }));
}
}
}
export interface IndexRequest {
/** Types to touch. Empty means every supported type. */
types?: readonly ContentType[];
/**
* Per-type ids to index. Omitted/empty for a type means "refetch that type's
* recent window" (the catch-up shape).
*/
ids?: Partial<Record<ContentType, readonly string[]>>;
/** Per-type ids to REMOVE (a JMAP `destroyed`). */
removed?: Partial<Record<ContentType, readonly string[]>>;
/** Drop documents outside the retention window after writing. */
prune?: boolean;
}
/**
* Runs one index pass. Opens the encrypted store, fetches, upserts, closes.
*
* The key is fetched from the main process for the duration of this call only
* (`withIndexKey`) and zeroed afterwards - there is no cached handle and no
* resident key.
*/
export async function runIndex(
indexSession: IndexSession,
req: IndexRequest,
): Promise<IndexResult> {
const started = Date.now();
const storeDir = getStoreDir();
if (!storeDir) {
throw new IndexSessionError('The local index is not enabled in this deployment.', 404);
}
const session = await fetchJmapSession(indexSession.serverUrl, indexSession.authHeader);
// Identity cross-check. `generateAccountId` used the username from the auth
// context cookie; the server may canonicalise a short login (`linus`) to a
// full address (`linus@example.com`) - which is exactly why AccountEntry
// carries `serverIdentifiers`. Accept either form, reject anything else
// rather than writing one account's mail into another's file.
if (session.username) {
const serverAccountId = generateAccountId(session.username, indexSession.serverUrl);
if (serverAccountId !== indexSession.accountId) {
const shortMatches = session.username.split('@')[0] === indexSession.username.split('@')[0];
if (!shortMatches) {
throw new IndexSessionError(
'The JMAP session belongs to a different account than the request cookie.',
409,
);
}
}
}
const { supported, skipped, accountIds } = supportedTypes(session);
const requested = req.types && req.types.length > 0 ? req.types : supported;
const types = requested.filter((t) => supported.includes(t));
const notAttempted = [...new Set([...skipped, ...requested.filter((t) => !supported.includes(t))])];
const written: Partial<Record<ContentType, number>> = {};
const errors: IndexResult['errors'] = [];
await withIndexKey(indexSession.accountId, async (key) => {
const index = MailIndex.open({ storeDir, accountId: indexSession.accountId, key });
try {
for (const contentType of types) {
const jmapAccountId = accountIds[contentType];
if (!jmapAccountId) continue;
try {
const removed = req.removed?.[contentType];
if (removed && removed.length > 0) {
index.remove(jmapAccountId, contentType, removed.slice(0, MAX_IDS_PER_CALL));
}
const requestedIds = req.ids?.[contentType];
const ids = requestedIds && requestedIds.length > 0
? requestedIds.slice(0, MAX_IDS_PER_CALL)
: null;
const docs = await fetchDocs(contentType, {
session, authHeader: indexSession.authHeader, jmapAccountId, ids,
});
written[contentType] = index.upsert(docs);
if (req.prune && contentType === 'mail') {
// Only mail prunes by date: calendar's window looks forward,
// contacts have no date, and file rows are metadata-sized.
index.pruneOlderThan(jmapAccountId, 'mail', isoDaysFromNow(-INDEX_WINDOW_DAYS));
}
} catch (error) {
// One unsupported or misbehaving type must not fail the others.
const message = error instanceof Error ? error.message : String(error);
errors.push({ contentType, message });
if (error instanceof JmapIndexError && error.status === 401) throw error;
}
}
} finally {
index.close();
}
});
const result: IndexResult = {
accountId: indexSession.accountId,
written,
skipped: notAttempted,
errors,
durationMs: Date.now() - started,
};
logger.info('mail-index: pass complete', {
slot: indexSession.slot,
written: JSON.stringify(written),
skipped: notAttempted.join(',') || 'none',
errors: errors.length,
durationMs: result.durationMs,
});
return result;
}
+444
View File
@@ -0,0 +1,444 @@
// The encrypted local search index: schema, open/close, upsert, search.
//
// One SQLite (SQLCipher) file per account. Rows are ALSO account-scoped
// internally - `(jmap_account_id, content_type, id)` - because a single login
// exposes the user's own JMAP account plus every delegated/shared account, and
// JMAP ids are unique only WITHIN an account (this codebase already works
// around that collision in `lib/jmap/client.ts:388`'s namespaceMailboxIds).
// One file per account keeps purge trivial; the composite key keeps
// delegated accounts from merging inside it.
//
// This is a SEARCH INDEX, not a mail replica. It is allowed to be stale, it is
// allowed to be incomplete, and it can be discarded and rebuilt at any time -
// which is why the schema-version mismatch path below simply drops everything
// rather than migrating.
import fs from 'node:fs';
import path from 'node:path';
import { loadSqlcipher, type SqlcipherDatabase } from './binding';
import { dbSiblings, indexDbPath } from './paths';
export const SCHEMA_VERSION = 1;
export type ContentType = 'mail' | 'calendar' | 'contact' | 'file';
export const CONTENT_TYPES: readonly ContentType[] = ['mail', 'calendar', 'contact', 'file'];
export function isContentType(v: unknown): v is ContentType {
return typeof v === 'string' && (CONTENT_TYPES as readonly string[]).includes(v);
}
/**
* One indexable thing, already flattened to text. Produced by the pure
* extractors in `extract.ts` so that every JMAP-shape decision is unit-testable
* without a database or a server.
*/
export interface IndexDoc {
jmapAccountId: string;
contentType: ContentType;
/** JMAP id. Unique only within (jmapAccountId, contentType). */
id: string;
/** Subject / event title / contact display name / filename. */
title: string;
/** Addresses and names: sender+recipients, attendees, contact emails/phones, owner. */
people: string;
/** The bulk searchable text. Plain text only - never HTML. */
body: string;
/** ISO 8601, or null when the type has no meaningful date. Drives recency ordering. */
occurredAt: string | null;
/** Small type-specific extras returned verbatim to the caller (never searched). */
metadata: Record<string, unknown>;
}
export interface SearchHit {
contentType: ContentType;
id: string;
jmapAccountId: string;
title: string;
people: string;
occurredAt: string | null;
metadata: Record<string, unknown>;
/** FTS5 bm25 score. Lower is a better match (bm25 returns negative values). */
score: number;
/** Highlighted excerpt from the body, for feeding an LLM as context. */
snippet: string;
}
const DDL = `
CREATE TABLE IF NOT EXISTS doc (
jmap_account_id TEXT NOT NULL,
content_type TEXT NOT NULL,
id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
people TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
occurred_at TEXT,
metadata_json TEXT NOT NULL DEFAULT '{}',
indexed_at INTEGER NOT NULL,
PRIMARY KEY (jmap_account_id, content_type, id)
);
CREATE INDEX IF NOT EXISTS doc_recent
ON doc(jmap_account_id, content_type, occurred_at DESC);
-- Standalone (not external-content) FTS5: the text is duplicated into this
-- table and kept in step manually on upsert. External content would avoid the
-- duplication but requires deleting the old FTS row using its OLD column
-- values, which an upsert does not have to hand - a well-known source of
-- silently-stale FTS rows. At this scale (a bounded recent window) the
-- duplication is the cheaper correctness trade.
CREATE VIRTUAL TABLE IF NOT EXISTS doc_fts USING fts5(
title, people, body,
tokenize='unicode61 remove_diacritics 2'
);
CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);
`;
export class MailIndexUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = 'MailIndexUnavailableError';
}
}
/**
* Assert that the file we just opened is REALLY encrypted.
*
* This is not defensive boilerplate, it guards the sharpest landmine found
* while designing this: on both `node:sqlite` and plain `better-sqlite3`,
* `PRAGMA key = ...` is **silently accepted and does nothing** - no error, a
* working database, and the mail sitting on disk in cleartext. Verified by
* writing a file and recovering a canary string from the raw bytes.
*
* The check is on the VALUE, not the row count: a non-cipher binding returns
* ZERO ROWS for `PRAGMA cipher_version`, so a naive `!== ''` comparison over a
* missing row passes vacuously. Require a non-empty string.
*/
function assertEncrypted(db: SqlcipherDatabase, dbPath: string): void {
const rows = db.pragma('cipher_version');
const value =
Array.isArray(rows) && rows.length > 0 && rows[0] && typeof rows[0] === 'object'
? (rows[0] as Record<string, unknown>).cipher_version
: undefined;
if (typeof value !== 'string' || value.trim().length === 0) {
db.close();
throw new MailIndexUnavailableError(
`Refusing to use ${path.basename(dbPath)}: the SQLite binding reports no SQLCipher ` +
`support (PRAGMA cipher_version returned ${JSON.stringify(rows)}), so the index ` +
`would be written in cleartext.`,
);
}
}
export interface OpenOptions {
storeDir: string;
accountId: string;
/** Raw 32-byte key. Used as SQLCipher's raw key (no KDF) via `PRAGMA key = "x'..'"`. */
key: Buffer;
}
export class MailIndex {
private constructor(
private readonly db: SqlcipherDatabase,
readonly dbPath: string,
) {}
/**
* Opens (creating if needed) the account's index. Throws
* MailIndexUnavailableError when the native binding is absent or the file is
* not actually encrypted; the caller turns the feature off rather than
* falling back to something unencrypted.
*/
static open({ storeDir, accountId, key }: OpenOptions): MailIndex {
const Database = loadSqlcipher();
if (!Database) {
throw new MailIndexUnavailableError(
'@signalapp/sqlcipher is not installed for this platform (it is an optional dependency).',
);
}
if (key.length !== 32) {
throw new MailIndexUnavailableError(`Index key must be 32 bytes, got ${key.length}.`);
}
const dbPath = indexDbPath(storeDir, accountId);
fs.mkdirSync(path.dirname(dbPath), { recursive: true, mode: 0o700 });
let db = new Database(dbPath);
// The key pragma must be the FIRST statement on the connection. Hex form
// means SQLCipher uses these 32 bytes as the raw key with no KDF, which is
// right for a random key (a passphrase would want the KDF).
db.pragma(`key = "x'${key.toString('hex')}'"`);
assertEncrypted(db, dbPath);
// A wrong key surfaces here rather than at open: SQLCipher only reads the
// header lazily. Treat it as "unreadable" and rebuild from scratch - the
// index is derived data, so there is nothing to recover and never anything
// to prompt the user for (the key was never a user secret).
let version: number | null;
try {
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
version = readSchemaVersion(db);
} catch {
db.close();
for (const f of dbSiblings(dbPath)) {
try { fs.rmSync(f, { force: true }); } catch { /* best effort */ }
}
db = new Database(dbPath);
db.pragma(`key = "x'${key.toString('hex')}'"`);
assertEncrypted(db, dbPath);
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
version = null;
}
if (version !== null && version !== SCHEMA_VERSION) {
// Rebuildable derived data: drop, don't migrate.
db.exec('DROP TABLE IF EXISTS doc_fts; DROP TABLE IF EXISTS doc; DROP TABLE IF EXISTS meta;');
version = null;
}
if (version === null) {
db.exec(DDL);
db.prepare('INSERT OR REPLACE INTO meta (k, v) VALUES (?, ?)').run([
'schema_version',
String(SCHEMA_VERSION),
]);
}
return new MailIndex(db, dbPath);
}
close(): void {
try { this.db.close(); } catch { /* already closed */ }
}
/**
* Upserts documents and keeps the FTS rows in step. Returns the number of
* rows written. One transaction for the whole batch - a partially-applied
* batch is harmless (it is an index) but a transaction is faster.
*/
upsert(docs: readonly IndexDoc[]): number {
if (docs.length === 0) return 0;
const upsertDoc = this.db.prepare(`
INSERT INTO doc (jmap_account_id, content_type, id, title, people, body,
occurred_at, metadata_json, indexed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(jmap_account_id, content_type, id) DO UPDATE SET
title = excluded.title, people = excluded.people, body = excluded.body,
occurred_at = excluded.occurred_at, metadata_json = excluded.metadata_json,
indexed_at = excluded.indexed_at
RETURNING rowid
`);
const deleteFts = this.db.prepare('DELETE FROM doc_fts WHERE rowid = ?');
const insertFts = this.db.prepare(
'INSERT INTO doc_fts (rowid, title, people, body) VALUES (?, ?, ?, ?)',
);
const now = Date.now();
let written = 0;
this.db.exec('BEGIN');
try {
for (const d of docs) {
const row = upsertDoc.get([
d.jmapAccountId, d.contentType, d.id,
d.title, d.people, d.body,
d.occurredAt, JSON.stringify(d.metadata ?? {}), now,
]);
const rowid = row?.rowid;
if (typeof rowid !== 'number') continue;
// ON CONFLICT preserves the rowid, so delete-then-insert replaces the
// old FTS row rather than accumulating duplicates for one document.
deleteFts.run([rowid]);
insertFts.run([rowid, d.title, d.people, d.body]);
written++;
}
this.db.exec('COMMIT');
} catch (error) {
this.db.exec('ROLLBACK');
throw error;
}
return written;
}
/** Removes documents by id (a JMAP `destroyed` id, or a stale row). */
remove(jmapAccountId: string, contentType: ContentType, ids: readonly string[]): number {
if (ids.length === 0) return 0;
const findRow = this.db.prepare(
'SELECT rowid FROM doc WHERE jmap_account_id = ? AND content_type = ? AND id = ?',
);
const deleteFts = this.db.prepare('DELETE FROM doc_fts WHERE rowid = ?');
const deleteDoc = this.db.prepare(
'DELETE FROM doc WHERE jmap_account_id = ? AND content_type = ? AND id = ?',
);
let removed = 0;
this.db.exec('BEGIN');
try {
for (const id of ids) {
const row = findRow.get([jmapAccountId, contentType, id]);
if (typeof row?.rowid === 'number') deleteFts.run([row.rowid]);
removed += deleteDoc.run([jmapAccountId, contentType, id]).changes;
}
this.db.exec('COMMIT');
} catch (error) {
this.db.exec('ROLLBACK');
throw error;
}
return removed;
}
/**
* Full-text search - the retrieval surface an AI feature calls to gather
* context. `types` empty/omitted searches everything.
*/
search(opts: {
query: string;
types?: readonly ContentType[];
limit?: number;
snippetTokens?: number;
}): SearchHit[] {
const match = toFtsMatchQuery(opts.query);
if (!match) return [];
const limit = Math.min(Math.max(opts.limit ?? 20, 1), 200);
const tokens = Math.min(Math.max(opts.snippetTokens ?? 24, 4), 64);
const types = opts.types && opts.types.length > 0 ? opts.types : null;
const typeFilter = types ? ` AND d.content_type IN (${types.map(() => '?').join(',')})` : '';
// bm25 weights: a hit in the title or in a name/address is a stronger
// signal than one in a long body, and for RAG the title is what makes a
// retrieved chunk recognisable.
const rows = this.db
.prepare(`
SELECT d.content_type, d.id, d.jmap_account_id, d.title, d.people,
d.occurred_at, d.metadata_json,
bm25(doc_fts, 8.0, 4.0, 1.0) AS score,
snippet(doc_fts, 2, '[', ']', '…', ${tokens}) AS snip
FROM doc_fts
JOIN doc d ON d.rowid = doc_fts.rowid
WHERE doc_fts MATCH ?${typeFilter}
ORDER BY score ASC, d.occurred_at DESC
LIMIT ?
`)
.all([match, ...(types ?? []), limit]);
return rows.map((r) => ({
contentType: String(r.content_type) as ContentType,
id: String(r.id),
jmapAccountId: String(r.jmap_account_id),
title: String(r.title ?? ''),
people: String(r.people ?? ''),
occurredAt: r.occurred_at === null || r.occurred_at === undefined ? null : String(r.occurred_at),
metadata: safeParseObject(r.metadata_json),
score: typeof r.score === 'number' ? r.score : 0,
snippet: String(r.snip ?? ''),
}));
}
/** Per-type counts and freshness, for the Settings UI and for debugging. */
stats(): Array<{ contentType: string; count: number; newest: string | null; indexedAt: number | null }> {
return this.db
.prepare(`
SELECT content_type, COUNT(*) AS n, MAX(occurred_at) AS newest, MAX(indexed_at) AS indexed
FROM doc GROUP BY content_type ORDER BY content_type
`)
.all()
.map((r) => ({
contentType: String(r.content_type),
count: Number(r.n ?? 0),
newest: r.newest === null || r.newest === undefined ? null : String(r.newest),
indexedAt: typeof r.indexed === 'number' ? r.indexed : null,
}));
}
/** Ids already present, so a catch-up pass can skip re-fetching bodies. */
existingIds(jmapAccountId: string, contentType: ContentType): Set<string> {
const rows = this.db
.prepare('SELECT id FROM doc WHERE jmap_account_id = ? AND content_type = ?')
.all([jmapAccountId, contentType]);
return new Set(rows.map((r) => String(r.id)));
}
/** Drops documents older than the retention floor for a type. */
pruneOlderThan(jmapAccountId: string, contentType: ContentType, isoFloor: string): number {
const rows = this.db
.prepare(`
SELECT rowid FROM doc
WHERE jmap_account_id = ? AND content_type = ?
AND occurred_at IS NOT NULL AND occurred_at < ?
`)
.all([jmapAccountId, contentType, isoFloor]);
if (rows.length === 0) return 0;
const deleteFts = this.db.prepare('DELETE FROM doc_fts WHERE rowid = ?');
const deleteDoc = this.db.prepare('DELETE FROM doc WHERE rowid = ?');
this.db.exec('BEGIN');
try {
for (const r of rows) {
deleteFts.run([r.rowid]);
deleteDoc.run([r.rowid]);
}
this.db.exec('COMMIT');
} catch (error) {
this.db.exec('ROLLBACK');
throw error;
}
return rows.length;
}
}
function readSchemaVersion(db: SqlcipherDatabase): number | null {
try {
const row = db.prepare("SELECT v FROM meta WHERE k = 'schema_version'").get();
if (!row || row.v === undefined) return null;
const n = Number(row.v);
return Number.isFinite(n) ? n : null;
} catch {
// `meta` doesn't exist yet - a fresh file.
return null;
}
}
function safeParseObject(v: unknown): Record<string, unknown> {
if (typeof v !== 'string') return {};
try {
const parsed = JSON.parse(v);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}
/**
* Turns arbitrary user text into a safe FTS5 MATCH expression.
*
* FTS5's query syntax is not SQL, so parameter binding does NOT protect it: a
* bare `"` or a stray `*`/`NEAR`/`:` in user input raises
* `fts5: syntax error`, which would turn a normal search box into a 500. Every
* token is quoted (making it a literal phrase) and a trailing `*` is added to
* the last token so typing continues to match as the user types.
*
* Exported for unit testing - it is the one piece of this file with no
* database dependency and the most ways to be wrong.
*/
export function toFtsMatchQuery(raw: string): string | null {
if (typeof raw !== 'string') return null;
// Split on anything that isn't a word character or an intra-word mark. Keeps
// unicode letters (so "Müller" and "東京" survive) via the u flag.
const tokens = raw
.normalize('NFC')
.split(/[^\p{L}\p{N}_@.'-]+/u)
.map((t) => t.replace(/^['-]+|['-]+$/g, ''))
.filter((t) => t.length > 0)
.slice(0, 24);
if (tokens.length === 0) return null;
return tokens
.map((t, i) => {
const quoted = `"${t.replace(/"/g, '""')}"`;
// Prefix-match only the final token, and only if it's long enough to not
// match half the mailbox.
return i === tokens.length - 1 && t.length >= 3 ? `${quoted}*` : quoted;
})
.join(' AND ');
}
+14 -2
View File
@@ -40,12 +40,24 @@ if (basePath && !basePath.startsWith("/")) {
const nextConfig: NextConfig = {
output: "standalone",
allowedDevOrigins: ["192.168.1.51"],
// 127.0.0.1 alongside the existing LAN entry: electron/main.ts always
// loads its window at 127.0.0.1 (see ELECTRON_LOAD_URL and
// startStandaloneServer()), so dev-mode Electron runs (only used by
// integration/tests/11-electron-notification.spec.ts today) need it in
// this allowlist the same way any other cross-origin dev client would.
allowedDevOrigins: ["192.168.1.51", "127.0.0.1"],
basePath: basePath || undefined,
// esbuild ships native binaries + a README the bundler can't parse; load
// it from node_modules at runtime instead of trying to bundle it. Used by
// PLUGIN_DEV_DIR's on-the-fly bundler.
serverExternalPackages: ["esbuild"],
//
// @signalapp/sqlcipher is a native N-API addon resolved at runtime by
// node-gyp-build (a directory scan of prebuilds/), which a bundler cannot
// follow. It is also an OPTIONAL dependency - absent on musl/Alpine, where
// both Dockerfiles build - so it must never be a hard build-time import.
// lib/mail-index/binding.ts guards the require; this keeps webpack from
// trying to resolve it at all.
serverExternalPackages: ["esbuild", "@signalapp/sqlcipher"],
// Sibling repos checked out under ./repos/ are unrelated source trees that
// Turbopack's NFT can otherwise rope into the trace when dynamic fs calls
// confuse it. Keeps the build from ballooning memory tracing dead code.
+2573 -21
View File
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -1,6 +1,7 @@
{
"name": "bulwark-webmail",
"version": "1.7.8",
"main": "dist-electron/main.js",
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only",
@@ -30,7 +31,12 @@
"test:translations": "vitest run --no-isolate lib/__tests__/translations.test.ts",
"test:integration": "bash integration/run-tests.sh",
"prepare": "husky",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"build:standalone": "next build --webpack && node scripts/assemble-standalone.mjs",
"build:electron": "node scripts/build-electron.mjs",
"electron:dev": "npm run build:standalone && npm run build:electron && electron .",
"test:electron": "playwright test -c playwright.electron.config.ts",
"test:integration:electron": "npm run build:standalone && npm run build:electron && playwright test -c playwright.integration-electron.config.ts"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -55,6 +61,7 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dompurify": "^3.4.12",
"electron-updater": "^6.8.9",
"jalaali-js": "^2.0.0",
"jszip": "^3.10.1",
"lucide-react": "^1.8.0",
@@ -73,6 +80,9 @@
"webcrypto-liner": "^1.4.3",
"zustand": "^5.0.12"
},
"optionalDependencies": {
"@signalapp/sqlcipher": "^4.0.3"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@playwright/test": "^1.59.1",
@@ -88,6 +98,8 @@
"@typescript-eslint/parser": "^8.59.0",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/ui": "^4.1.5",
"electron": "^43.2.0",
"electron-builder": "^26.15.3",
"esbuild": "^0.28.0",
"eslint": "^9.39.4",
"eslint-plugin-react": "^7.37.5",
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from '@playwright/test';
// Separate from playwright.config.ts on purpose: the Electron smoke suite
// launches its own app (which boots its own standalone Next.js server via
// electron/main.ts - see scripts/build-electron.mjs), so it must NOT inherit
// the main config's `webServer` (which starts `npm run dev` on :3000 for the
// browser-based e2e/*.spec.ts suite) - the two would fight over nothing but
// still waste time starting a server this suite never touches.
export default defineConfig({
testDir: './e2e',
testMatch: 'electron-smoke.spec.ts',
timeout: 60000,
retries: 0,
use: {
trace: 'retain-on-failure',
},
// Electron tests drive their own app windows via the `_electron` fixture,
// not a browser project - one worker keeps main-process/server startup
// logs and any zombie processes easy to reason about.
workers: 1,
});
+56
View File
@@ -0,0 +1,56 @@
import { defineConfig } from '@playwright/test';
/**
* Electron-specific integration config. Reuses the same Stalwart fixture
* bring-up (globalSetup/globalTeardown) as playwright.integration.config.ts,
* but deliberately kept separate from it and scoped to only
* integration/tests/11-electron-notification.spec.ts:
*
* - No `projects` array: that test launches its own Electron process via
* _electron.launch() - it needs no Playwright-managed browser project.
* - Not run as part of the main dockerized suite: `npm run test:integration`
* (integration/run-tests.sh) runs the browser-based suite INSIDE the
* official Playwright Docker image (to get Chromium without relying on
* Playwright's own browser-download host). Electron has no such
* download step - `npm install electron` already fetched a binary for
* THIS host's platform, which would not run inside that (likely
* different-platform) container. Run this suite directly on the host
* instead - see `npm run test:integration:electron`. The main
* integration config explicitly excludes this spec file for the same
* reason, so a plain `npm run test:integration` never tries to launch it.
*/
export default defineConfig({
testDir: './integration/tests',
// 11 asserts the native notification bridge fires from a real push; 12
// asserts a real delivery reaches the encrypted local search index. 12 runs
// the REAL standalone-server boot (no ELECTRON_LOAD_URL), because that boot
// is what wires the index's store directory and its fd-3 key channel.
testMatch: /1[12]-electron-.*\.spec\.ts/,
timeout: 90_000,
expect: { timeout: 20_000 },
fullyParallel: false,
workers: 1,
// Retries unconditionally (not just CI), and more than the main config's
// 1: this suite runs the Electron shell against a `next dev` server (see
// the spec file's header comment for why - the fixture's Stalwart is
// deliberately plain HTTP), and `next dev`'s on-demand route compilation
// + Fast Refresh occasionally races the SSE stream this test depends on
// during the login -> inbox route transition, dropping that one push
// event with no error anywhere (confirmed by running the identical test
// repeatedly against an already-warm stack: same request sequence logged
// every time, but the outcome isn't always the same). Root-caused, not
// eliminated - a genuine dev-server-only timing hazard, not a bug in the
// feature this test is verifying (the same run's own logs show the WS
// circuit breaker and SSE fallback firing exactly as designed every
// single time, pass or fail).
retries: 2,
reporter: [['list']],
outputDir: 'integration/test-results-electron',
// Own global-setup (not the main config's): brings up only the `stalwart`
// compose service, not `webmail` - this suite boots a `next dev` server
// itself (see the spec file) and never talks to the containerized
// webmail on :3000. Teardown is shared - it already defaults to leaving
// the stack up unless IT_TEARDOWN=1.
globalSetup: './integration/tests/global-setup-electron.ts',
globalTeardown: './integration/tests/global-teardown.ts',
});
+7
View File
@@ -24,6 +24,13 @@ const VIDEO: VideoMode = VIDEO_MODES.includes(process.env.IT_VIDEO as VideoMode)
export default defineConfig({
testDir: './integration/tests',
// The Electron specs run under playwright.integration-electron.config.ts
// instead (see that file's header comment for why): the dockerized run
// this config drives (integration/run-tests.sh, inside the official
// Playwright image) has no Electron binary compatible with that
// container's platform, so they must never be swept in by this config's
// default testDir glob.
testIgnore: ['11-electron-notification.spec.ts', '12-electron-mail-index.spec.ts'],
// next dev compiles routes lazily and each test logs in fresh, so give
// individual tests and their polling assertions generous headroom.
timeout: 90_000,
+17 -1
View File
@@ -93,7 +93,23 @@ export async function proxy(request: NextRequest) {
? `'self' 'nonce-${nonce}' 'unsafe-eval'`
: `'self' 'nonce-${nonce}'`;
const connectSrc = isDev ? `'self' http: https: ws: wss:` : `'self' https:`;
// `wss:` alongside `https:` in production: lib/jmap/client.ts's WebSocket
// push (RFC 8887) needs it, and it adds no new trust surface - CSP's
// `https:` scheme-source here already allows fetch/XHR to ANY TLS host
// (not just the configured JMAP server; needed for ALLOW_CUSTOM_JMAP_ENDPOINT
// and multi-server JMAP_SERVERS setups where the exact origin isn't known
// at build time), so extending that same "any TLS-secured host" trust
// model to WebSocket is consistent, not a new precedent. Confirmed this
// was a real gap, not theoretical: before this fix, `new WebSocket(...)`
// against the real reference server was blocked by THIS directive before
// any network attempt happened at all (a `securitypolicyviolation` event
// with connect-src as the violated directive) - the WS feature was
// entirely inert in a production build. Plain `ws:` (unencrypted) stays
// production-excluded on purpose, same reasoning as `http:` above it: an
// https-served production app already gets unencrypted connections
// blocked as mixed content by the browser itself, so allowing bare `ws:`
// here would add no capability, only a false sense of one.
const connectSrc = isDev ? `'self' http: https: ws: wss:` : `'self' https: wss:`;
const frameAncestors = isSandboxPath
? `'self'`
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env node
// `next build --webpack` (see next.config.ts's `output: "standalone"`)
// emits .next/standalone/server.js but - deliberately, per Next's own docs -
// leaves out public/ and .next/static/. The Dockerfile copies both in by
// hand for the container image; this does the same thing for local Electron
// dev and packaging, so every path boots the exact same artifact.
import { cpSync, existsSync, rmSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const rootDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const standaloneDir = path.join(rootDir, ".next", "standalone");
if (!existsSync(standaloneDir)) {
console.error(`Missing ${standaloneDir} - run "next build --webpack" first.`);
process.exit(1);
}
const publicSrc = path.join(rootDir, "public");
const publicDest = path.join(standaloneDir, "public");
rmSync(publicDest, { recursive: true, force: true });
cpSync(publicSrc, publicDest, { recursive: true });
const staticSrc = path.join(rootDir, ".next", "static");
const staticDest = path.join(standaloneDir, ".next", "static");
rmSync(staticDest, { recursive: true, force: true });
cpSync(staticSrc, staticDest, { recursive: true });
// The native SQLCipher prebuilds for the local search index (lib/mail-index/**).
//
// Next's output file tracing DOES pick up @signalapp/sqlcipher's JS
// (package.json + dist/index.cjs) and its node-gyp-build dependency, but NOT
// the prebuilds/ directory holding the actual .node binaries - node-gyp-build
// resolves those by scanning the directory at runtime, which no static tracer
// can follow. Verified by inspecting a real `build:standalone` output: the
// package was present, `prebuilds/` was absent, so `require()` would have
// failed at runtime in every packaged build.
//
// Copying the WHOLE prebuilds directory (all six platform/arch pairs, ~11 MB)
// rather than just this host's is deliberate: electron-builder cross-builds the
// x64 and arm64 macOS targets from one runner (electron-builder.config.js), so
// the artifact has to contain a prebuild for an arch this machine isn't.
//
// Skipped silently when absent - the package is an OPTIONAL dependency and is
// legitimately missing on musl/Alpine, where both Dockerfiles build.
const sqlcipherSrc = path.join(rootDir, "node_modules", "@signalapp", "sqlcipher", "prebuilds");
if (existsSync(sqlcipherSrc)) {
const sqlcipherDest = path.join(
standaloneDir, "node_modules", "@signalapp", "sqlcipher", "prebuilds",
);
rmSync(sqlcipherDest, { recursive: true, force: true });
cpSync(sqlcipherSrc, sqlcipherDest, { recursive: true });
console.log("Copied @signalapp/sqlcipher prebuilds into the standalone output");
} else {
console.log(
"@signalapp/sqlcipher not installed (optional dependency) - " +
"the encrypted local index will be disabled at runtime",
);
}
console.log("Assembled standalone server at", standaloneDir);
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env node
// Bundles electron/main.ts and electron/preload.ts into dist-electron/*.js.
// Uses esbuild (already a devDependency for the admin plugin dev-bundler,
// lib/admin/plugin-dev.ts) rather than pulling in ts-node/tsx - the output
// is plain CommonJS, so the packaged app needs no separate TS runtime.
import { build } from "esbuild";
import { fileURLToPath } from "node:url";
import path from "node:path";
const rootDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const shared = {
bundle: true,
platform: "node",
target: "node22",
format: "cjs",
sourcemap: true,
// `electron` is provided by the Electron runtime itself; `electron-updater`
// stays external so electron-builder ships it from node_modules as a
// normal production dependency instead of us re-bundling its native-ish
// internals (see electron-builder.config.js's file collection).
external: ["electron", "electron-updater"],
logLevel: "info",
};
await build({
...shared,
entryPoints: [path.join(rootDir, "electron/main.ts")],
outfile: path.join(rootDir, "dist-electron/main.js"),
});
await build({
...shared,
entryPoints: [path.join(rootDir, "electron/preload.ts")],
outfile: path.join(rootDir, "dist-electron/preload.js"),
});
+30
View File
@@ -2836,6 +2836,33 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Update last push update timestamp
set({ lastPushUpdate: Date.now() });
// Feed the desktop shell's encrypted local search index
// (lib/mail-index/**). This is the EVENT-DRIVEN trigger for indexing: the
// push connection is already type-generic (the WS/SSE handlers pass the
// whole `changed` map through, and the WS subscribes with
// dataTypes: null), so mail, calendar, contact and file changes all
// arrive here. Scheduled at the END of this handler, not here, so the
// mail ids it passes come from the ALREADY-REFRESHED list - reading them
// first would hand over the page as it was before the new message
// arrived, i.e. index everything except the delivery that triggered it.
const scheduleIndexUpdate = () => {
void (async () => {
try {
const { indexOnStateChange } = await import('@/lib/mail-index-client');
const mailIds = get().emails.slice(0, 100).map((e) => e.id);
indexOnStateChange(change, {
// Empty (no mailbox selected yet, or a background account) means
// "no ids to offer" - the server then falls back to its own
// bounded recent-window query rather than indexing nothing.
mailIds: mailIds.length > 0 ? mailIds : undefined,
slot: useAccountStore.getState().getActiveAccount()?.cookieSlot,
});
} catch {
/* the index is optional; never let it affect mail handling */
}
})();
};
// Get the current account ID from the client (assuming primary account)
const accountId = client.getAccountId();
@@ -2896,6 +2923,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
});
}
}
// Local search index last, with the refreshed ids (see above).
scheduleIndexUpdate();
} catch (error) {
console.error('Failed to handle state change:', error);
set({