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:
@@ -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
|
||||||
@@ -38,6 +38,14 @@ yarn-error.log*
|
|||||||
# vercel
|
# vercel
|
||||||
.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
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { usePromptDialog } from "@/hooks/use-prompt-dialog";
|
|||||||
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
|
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { playNotificationSound } from "@/lib/notification-sound";
|
import { playNotificationSound } from "@/lib/notification-sound";
|
||||||
|
import { isElectronShell, showElectronNotification } from "@/lib/electron-bridge";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||||
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
|
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)`);
|
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 () => {
|
return () => {
|
||||||
|
clearTimeout(catchUpTimer);
|
||||||
cleanups.forEach((fn) => fn());
|
cleanups.forEach((fn) => fn());
|
||||||
};
|
};
|
||||||
}, [isAuthenticated, client, activeAccountId, connectedAccountsSignature, handleStateChange, setPushConnected, buildPopulatedUnifiedAccounts, refreshCrossCounts, refreshUnifiedCounts]);
|
}, [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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedEmail?.id, isScheduledView]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (newEmailNotification) {
|
if (newEmailNotification) {
|
||||||
const { emailNotificationsEnabled, emailNotificationSound, notificationSoundChoice } = useSettingsStore.getState();
|
const { emailNotificationsEnabled, emailNotificationSound, notificationSoundChoice } = useSettingsStore.getState();
|
||||||
if (emailNotificationsEnabled && emailNotificationSound) {
|
if (emailNotificationsEnabled && emailNotificationSound) {
|
||||||
playNotificationSound(notificationSoundChoice);
|
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);
|
debug.log('email', 'New email received:', newEmailNotification.subject);
|
||||||
clearNewEmailNotification();
|
clearNewEmailNotification();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { getPathPrefix } from '@/lib/browser-navigation';
|
||||||
import { clearCachedData } from '@/lib/clear-cached-data';
|
import { clearCachedData } from '@/lib/clear-cached-data';
|
||||||
import { SpamSiegeGame } from './spam-siege-game';
|
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 APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||||
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
|
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
|
||||||
@@ -218,6 +219,9 @@ export function AboutDataSettings() {
|
|||||||
</Button>
|
</Button>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
</SettingsSection>
|
</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
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
@@ -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}"],
|
files: ["**/*.test.{ts,tsx}", "**/*.spec.{ts,tsx}"],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
@@ -70,6 +82,8 @@ export default [
|
|||||||
{
|
{
|
||||||
ignores: [
|
ignores: [
|
||||||
".next/**",
|
".next/**",
|
||||||
|
"dist-electron/**",
|
||||||
|
"dist-electron-builds/**",
|
||||||
"node_modules/**",
|
"node_modules/**",
|
||||||
"repos/**",
|
"repos/**",
|
||||||
"data/admin/plugins/**",
|
"data/admin/plugins/**",
|
||||||
@@ -81,6 +95,12 @@ export default [
|
|||||||
"benchmark/**",
|
"benchmark/**",
|
||||||
"examples/**",
|
"examples/**",
|
||||||
"integration/**",
|
"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/**",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ stalwart/stalwart-cli
|
|||||||
# Playwright/test artifacts
|
# Playwright/test artifacts
|
||||||
node_modules/
|
node_modules/
|
||||||
test-results/
|
test-results/
|
||||||
|
test-results-electron/
|
||||||
playwright-report/
|
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');
|
||||||
|
}
|
||||||
@@ -75,6 +75,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
getMaxDelayedSend(): number { return 30 * 24 * 60 * 60; }
|
getMaxDelayedSend(): number { return 30 * 24 * 60 * 60; }
|
||||||
hasDelayedSend(): boolean { return true; }
|
hasDelayedSend(): boolean { return true; }
|
||||||
getEventSourceUrl(): string | null { return null; }
|
getEventSourceUrl(): string | null { return null; }
|
||||||
|
getWebSocketUrl(): string | null { return null; }
|
||||||
supportsEmailSubmission(): boolean { return true; }
|
supportsEmailSubmission(): boolean { return true; }
|
||||||
supportsQuota(): boolean { return true; }
|
supportsQuota(): boolean { return true; }
|
||||||
supportsVacationResponse(): boolean { return true; }
|
supportsVacationResponse(): boolean { return true; }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ export interface IJMAPClient {
|
|||||||
getMaxDelayedSend(accountId?: string): number;
|
getMaxDelayedSend(accountId?: string): number;
|
||||||
hasDelayedSend(accountId?: string): boolean;
|
hasDelayedSend(accountId?: string): boolean;
|
||||||
getEventSourceUrl(): string | null;
|
getEventSourceUrl(): string | null;
|
||||||
|
getWebSocketUrl(): string | null;
|
||||||
supportsEmailSubmission(): boolean;
|
supportsEmailSubmission(): boolean;
|
||||||
supportsQuota(): boolean;
|
supportsQuota(): boolean;
|
||||||
supportsVacationResponse(): boolean;
|
supportsVacationResponse(): boolean;
|
||||||
|
|||||||
+440
-7
@@ -953,6 +953,36 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
if (session.eventSourceUrl) {
|
if (session.eventSourceUrl) {
|
||||||
session.eventSourceUrl = this.rewriteSessionUrl(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> {
|
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;
|
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 {
|
getAccountId(): string {
|
||||||
return this.accountId;
|
return this.accountId;
|
||||||
}
|
}
|
||||||
@@ -5982,12 +6028,51 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
private visibilityHandler: (() => void) | null = null;
|
private visibilityHandler: (() => void) | null = null;
|
||||||
private onlineHandler: (() => 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> = {
|
private static readonly STATE_TYPE_MAP: Record<string, string> = {
|
||||||
'Mailbox/get': 'Mailbox',
|
'Mailbox/get': 'Mailbox',
|
||||||
'Email/get': 'Email',
|
'Email/get': 'Email',
|
||||||
'Calendar/get': 'Calendar',
|
'Calendar/get': 'Calendar',
|
||||||
'CalendarEvent/get': 'CalendarEvent',
|
'CalendarEvent/get': 'CalendarEvent',
|
||||||
'SieveScript/get': 'SieveScript',
|
'SieveScript/get': 'SieveScript',
|
||||||
|
'ContactCard/get': 'ContactCard',
|
||||||
|
'FileNode/get': 'FileNode',
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly POLLING_INTERVAL = 3_000;
|
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_RECONNECT_DELAY = 3_000;
|
||||||
private static readonly SSE_PING_TIMEOUT = 90_000; // 3x the 30s ping interval
|
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 {
|
setupPushNotifications(): boolean {
|
||||||
const eventSourceUrl = this.getEventSourceUrl();
|
const wsUrl = this.effectiveWebSocketUrl();
|
||||||
if (eventSourceUrl) {
|
if (wsUrl) {
|
||||||
this.connectSSE(eventSourceUrl);
|
this.wsReconnectAttempts = 0;
|
||||||
// SSE covers the primary account only; keep shared accounts fresh too.
|
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();
|
this.startSecondaryAccountPoll();
|
||||||
} else {
|
} else {
|
||||||
// The fallback poll already covers every session account.
|
const eventSourceUrl = this.getEventSourceUrl();
|
||||||
this.startPollingFallback();
|
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();
|
this.setupBrowserEventListeners();
|
||||||
return true;
|
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
|
* 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,
|
* 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 };
|
return { using, methodCalls };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6310,6 +6707,27 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
this.eventSource = null;
|
this.eventSource = null;
|
||||||
}
|
}
|
||||||
this.stopSSEPingMonitor();
|
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.cleanupBrowserEventListeners();
|
||||||
this.stateChangeCallback = null;
|
this.stateChangeCallback = null;
|
||||||
this.pollingStates = {};
|
this.pollingStates = {};
|
||||||
@@ -6350,7 +6768,22 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
this.onlineHandler = () => {
|
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();
|
const eventSourceUrl = this.getEventSourceUrl();
|
||||||
if (eventSourceUrl && !this.sseAbortController) {
|
if (eventSourceUrl && !this.sseAbortController) {
|
||||||
this.connectSSE(eventSourceUrl);
|
this.connectSSE(eventSourceUrl);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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&D <tag> "q" x')).toBe('R&D <tag> "q" x');
|
||||||
|
expect(htmlToText('€10 €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('� �')).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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(/ /gi, ' ')
|
||||||
|
.replace(/&/gi, '&')
|
||||||
|
.replace(/</gi, '<')
|
||||||
|
.replace(/>/gi, '>')
|
||||||
|
.replace(/"/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() : '';
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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`];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -40,12 +40,24 @@ if (basePath && !basePath.startsWith("/")) {
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
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,
|
basePath: basePath || undefined,
|
||||||
// esbuild ships native binaries + a README the bundler can't parse; load
|
// 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
|
// it from node_modules at runtime instead of trying to bundle it. Used by
|
||||||
// PLUGIN_DEV_DIR's on-the-fly bundler.
|
// 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
|
// Sibling repos checked out under ./repos/ are unrelated source trees that
|
||||||
// Turbopack's NFT can otherwise rope into the trace when dynamic fs calls
|
// Turbopack's NFT can otherwise rope into the trace when dynamic fs calls
|
||||||
// confuse it. Keeps the build from ballooning memory tracing dead code.
|
// confuse it. Keeps the build from ballooning memory tracing dead code.
|
||||||
|
|||||||
Generated
+2573
-21
File diff suppressed because it is too large
Load Diff
+13
-1
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.7.8",
|
"version": "1.7.8",
|
||||||
|
"main": "dist-electron/main.js",
|
||||||
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
||||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
@@ -30,7 +31,12 @@
|
|||||||
"test:translations": "vitest run --no-isolate lib/__tests__/translations.test.ts",
|
"test:translations": "vitest run --no-isolate lib/__tests__/translations.test.ts",
|
||||||
"test:integration": "bash integration/run-tests.sh",
|
"test:integration": "bash integration/run-tests.sh",
|
||||||
"prepare": "husky",
|
"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": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@@ -55,6 +61,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dompurify": "^3.4.12",
|
"dompurify": "^3.4.12",
|
||||||
|
"electron-updater": "^6.8.9",
|
||||||
"jalaali-js": "^2.0.0",
|
"jalaali-js": "^2.0.0",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
@@ -73,6 +80,9 @@
|
|||||||
"webcrypto-liner": "^1.4.3",
|
"webcrypto-liner": "^1.4.3",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@signalapp/sqlcipher": "^4.0.3"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
@@ -88,6 +98,8 @@
|
|||||||
"@typescript-eslint/parser": "^8.59.0",
|
"@typescript-eslint/parser": "^8.59.0",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"@vitest/ui": "^4.1.5",
|
"@vitest/ui": "^4.1.5",
|
||||||
|
"electron": "^43.2.0",
|
||||||
|
"electron-builder": "^26.15.3",
|
||||||
"esbuild": "^0.28.0",
|
"esbuild": "^0.28.0",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
@@ -24,6 +24,13 @@ const VIDEO: VideoMode = VIDEO_MODES.includes(process.env.IT_VIDEO as VideoMode)
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './integration/tests',
|
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
|
// next dev compiles routes lazily and each test logs in fresh, so give
|
||||||
// individual tests and their polling assertions generous headroom.
|
// individual tests and their polling assertions generous headroom.
|
||||||
timeout: 90_000,
|
timeout: 90_000,
|
||||||
|
|||||||
@@ -93,7 +93,23 @@ export async function proxy(request: NextRequest) {
|
|||||||
? `'self' 'nonce-${nonce}' 'unsafe-eval'`
|
? `'self' 'nonce-${nonce}' 'unsafe-eval'`
|
||||||
: `'self' 'nonce-${nonce}'`;
|
: `'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
|
const frameAncestors = isSandboxPath
|
||||||
? `'self'`
|
? `'self'`
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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"),
|
||||||
|
});
|
||||||
@@ -2836,6 +2836,33 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
// Update last push update timestamp
|
// Update last push update timestamp
|
||||||
set({ lastPushUpdate: Date.now() });
|
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)
|
// Get the current account ID from the client (assuming primary account)
|
||||||
const accountId = client.getAccountId();
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to handle state change:', error);
|
console.error('Failed to handle state change:', error);
|
||||||
set({
|
set({
|
||||||
|
|||||||
Reference in New Issue
Block a user