12908ab7069f729c89f5bfff261a8e58eeb827c3
2
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
0271df4338 |
fix(mail-index): real end-to-end verification, and the three bugs it found
Adds integration/tests/12-electron-mail-index.spec.ts (3 tests, all passing
against the real Stalwart fixture) and fixes what running it exposed. None of
these were visible from reading the code.
1. JMAP session fetch never followed a redirect. Stalwart 307-redirects
/.well-known/jmap to /jmap/session, and fetchJmapSession used
`redirect: 'manual'` and treated any non-2xx as failure - so every reindex
died with "JMAP session fetch failed (307)". Now follows up to 3 hops and
REFUSES to follow off-origin, because the user's credentials ride on every
hop; a blind `redirect: 'follow'` would hand the Authorization header to
whatever host a misconfigured session pointed at. Same bound and same
reasoning as lib/auth/verify-jmap-auth.ts.
2. The fd-3 key channel could only be adopted once per process, but its state
was module-scoped. Next re-evaluates route modules, so a second instance hit
`Could not open fd 3: Error: open EEXIST` from libuv. State moved to a
Symbol on globalThis - the one place in a Node process that survives module
re-evaluation.
3. Next's output file tracing does NOT carry @signalapp/sqlcipher's prebuilds/
into .next/standalone. It traced the package's JS and its node-gyp-build
dependency, but node-gyp-build resolves the .node binary by scanning a
directory at runtime, which no static tracer can follow - so `require()`
would have failed in every packaged build. scripts/assemble-standalone.mjs
now copies it, alongside the public/ and .next/static copies it already does
for the same "standalone output omits things" reason. All six platform/arch
prebuilds are copied, not just this host's, because electron-builder
cross-builds the x64 and arm64 macOS targets from one runner.
The three tests, and why it takes three - two constraints made a single
configuration impossible, and both were measured rather than assumed:
* The renderer cannot reach this fixture from a production build. Its CSP
pins connect-src to `'self' https: wss:` and the fixture's Stalwart is
plain HTTP. 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 (observed: a standalone server started with
NODE_ENV=development still served the production CSP).
* The fd-3 channel cannot survive `next dev`, which forks its server with an
IPC channel that claims fd 3 (EEXIST); fd 4 there is not a pipe either
(ENOTTY).
So: PIPELINE drives the real standalone server over HTTP from Node with a
real fd-3 key channel (no browser, so no CSP) and asserts a real SMTP
delivery is findable by a word from its BODY, with a real snippet and
contextBlock, idempotent catch-up, working type filters, and - reading the
raw bytes of the .db AND its -wal - that nothing is recoverable in cleartext.
TRIGGER proves the event-driven wiring: a real delivery makes the renderer
POST /api/offline/reindex off its live push. WIRING launches the real shell
with no ELECTRON_LOAD_URL and asserts the routes are reachable (401, not 404
or 503) with real safeStorage behind them.
Each test now gets its own --user-data-dir. That is load-bearing, not hygiene:
Electron reuses one profile across launches, and a leftover jmap_stalwart_ctx
cookie from an earlier run made the WIRING test's 401 assertion pass as a 200.
Verified: typecheck clean; unit suite 2379 tests with the SAME 3 pre-existing
failures as the base commit
|
||
|
|
b966d285a9 |
feat(mail-index): encrypted SQLite/FTS5 index over mail, calendar, contacts, files
An on-device, SQLCipher-encrypted full-text index the app can retrieve from to
feed an LLM ("prompt against"), for the Electron desktop shell only.
Shape: no persistent background worker and no resident credential. Indexing is
a normal request-scoped API route, triggered by the renderer's EXISTING live
JMAP push connection - so it reacts to each delivery/change rather than polling.
- lib/mail-index/binding.ts guarded require of the optional native binding
- lib/mail-index/paths.ts the VNCMAIL_DESKTOP_STORE_DIR gate + hashed paths
- lib/mail-index/store.ts schema, upsert, FTS5 search, encryption assertion
- lib/mail-index/extract.ts PURE JMAP-object -> document extractors
- lib/mail-index/jmap.ts minimal stateless server-side JMAP client
- lib/mail-index/key.ts per-job key fetch over the inherited fd
- lib/mail-index/reindex.ts the job + slot->account resolution
- electron/key-service.ts safeStorage wrap/unwrap, served over fd 3
- app/api/offline/reindex POST, event-driven + catch-up
- app/api/offline/search GET, the retrieval surface (hits + contextBlock)
- lib/mail-index-client.ts renderer client; StateChange -> index call
- components/settings/local-index-settings.tsx status + manual catch-up
Decisions worth knowing:
* `@signalapp/sqlcipher` is an OPTIONAL dependency with a guarded runtime
require. It publishes six N-API prebuilds and NO build sources, and both
Dockerfiles are node:24-alpine (musl, no matching prebuild) - as a hard
dependency it would break the production image and the integration fixture's
webmail container, neither of which wants this feature.
* Credentials come from the existing per-slot encrypted `jmap_stalwart_ctx`
cookie via lib/stalwart/credentials.ts - the same helper /api/settings and
/api/push/preview already use. It carries a ready-made header for basic AND
bearer accounts, so the indexer never touches the OAuth refresh-token cookie;
a server-side refresh would rotate a token into a response nobody reads and
silently log the user out.
* The encryption key crosses main -> server over an INHERITED FILE DESCRIPTOR,
never an environment variable: env is readable by any process running as the
same OS user, which would defeat using the OS keychain at all. Fetched per
job and zeroed after, so there is no long-lived key copy.
* safeStorage's Linux `basic_text` backend (no keyring) is treated as refusal,
not degradation - it "encrypts" with a hardcoded public password, which would
look like an encrypted mailbox while providing nothing.
getSelectedStorageBackend() is Linux-only and platform-guarded.
* Every store open asserts `PRAGMA cipher_version` returns a non-empty STRING,
not merely a row: a non-cipher binding returns ZERO ROWS, so a row-count check
would pass vacuously while writing the mailbox to disk in cleartext.
* Files are indexed by name/path/date/size only - NOT by extracted content.
Text extraction from arbitrary PDFs/office documents is a separate problem.
* Account-scoped composite keys `(jmap_account_id, content_type, id)` are kept
even though there is one file per account: one login exposes delegated/shared
JMAP accounts too, and JMAP ids are unique only within an account.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|