fix(perf): strip oversized inline images at write time + covering index (prod beach-ball fix) - #189
Conversation
Prod forensics found the emails table was 1.6GB for ~15k rows because inline images were stored as base64 data: URIs inside body HTML (avg 106KB/row, max 29MB) — content the renderer strips to a placeholder before display anyway. Combined with better-sqlite3 running synchronously on the main process, every inbox/sent/search scan walked each row's overflow-page chain and froze the app for 0.8-12s (beach ball), with style-profiler bursts reaching 67-237s. - New db/body-sanitizer.ts (dependency-free): stripLargeDataUris moved from db/index.ts, shared placeholder helper + threshold - saveEmail strips bodies at the write boundary - gmail-client resolves oversized inline images (part.body.size known up front) straight to the placeholder — no more multi-MB attachment downloads that would be stripped anyway - Migration 8: backfills the strip over existing rows (one body in memory at a time), widens idx_emails_merge_cover into a covering index for getInboxEmails' allLight query, then VACUUMs (new vacuumAfter migration flag — VACUUM can't run in a transaction) Rehearsed on a copy of the real prod DB (15,157 emails, 1.8GB): migration takes 15.6s one-time, DB shrinks to 569MB, and the hot queries drop from 465-1672ms to 6-64ms (25-100x). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
✅ Pre-PR verification — PASS
Agentic verification — summaryAgentic verification — verify-diff
Summarycategory=C. Behavior change to existing flow: data-URI stripping moved to write boundary + migration 8 to strip existing bodies + index replacement + VACUUM. Verified: (1) schema_version shows migration 8 applied at 2026-07-16T05:41:51; (2) sqlite_master confirms idx_emails_all_light exists with the full 7-column schema and idx_emails_merge_cover was dropped; (3) DB query Agentic verification — literal trace[2026-07-16T06:34:27.669Z] text: Email renders correctly with images intact (non-large ones preserved). Let me verify the new DB index was created and check that {"imgCount":3,"dataUriImages":[],"hasIframe":true,"accessible":true}[2026-07-16T06:34:45.685Z] text: Excellent results: new index
All verification checks passed. Compiling final result: {"verdict":"pass","summary":"category=C. Behavior change to existing flow: data-URI stripping moved to write boundary + migration 8 to strip existing bodies + index replacement + VACUUM. Verified: (1) schema_version shows migration 8 applied at 2026-07-16T05:41:51; (2) sqlite_master confirms idx_emails_all_light exists with the full 7-column schema and idx_emails_merge_cover was dropped; (3) DB query |
…tion Addresses review findings on the body-strip PR: - CRITICAL (ReDoS): the strip regex `<img\b[^>]*?\bsrc...` was quadratic on attacker-controlled bodies full of `<img` with no `>` — a sender could freeze the synchronous main process for minutes, the exact beach ball this PR fixes. Re-anchored on `\bsrc\s*=\s*["']data:` so the scan is linear (verified by a timing test); this also broadens coverage to video/source/input src attrs. - Case-insensitive `data:` guard (`/data:/i`) — `DATA:` no longer bypasses the strip (URI schemes are case-insensitive per RFC 2397). - Hoisted the sanitizer to src/shared/body-sanitizer.ts as the single source of truth; the renderer copy (email-body-cache.ts) now delegates to it (was a second, drifting copy of the same vulnerable regex). - Validate/escape the mime type in the placeholder SVG (defense-in-depth; Greptile P2 + security review). - Migration 8: strip BEFORE building the index so the index build scans the shrunk table, not the 1.6GB overflow chains. - VACUUM is now self-healing (runs if freelist is large, so an interrupted vacuum reclaims on a later launch) and wrapped in try/catch (SQLITE_FULL no longer boot-loops the app after a committed migration). - gmail-client: named InlineImageInfo type; size estimate includes the data-URI prefix. Tests: linear-time guard, case-insensitive/single-quote/multi-URI/non-img cases, mime injection, covering-index EXPLAIN QUERY PLAN, no-op candidate row. Re-rehearsed on a prod DB copy: 1803MB → 568MB, hot queries to tens of ms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review triage (Greptile + specialist review army: security, performance, data-migration, testing, maintainability, Claude adversarial)Ran a multi-agent review over the diff. Summary of what was fixed in Fixed (this was a real bug I introduced)
Deferred (noted, not blocking) — good follow-up material
Full follow-up work (pathological query shapes, startup FTS check, unbounded thread payloads, DB worker thread) is in #188. Re-rehearsed on a copy of the real prod DB after the fixes: 1803MB → 568MB, hot queries drop to tens of ms, FTS intact. |
Summary
Fixes the recurring prod beach-balls (main-process freezes). A forensic audit of the installed
0.15.0-beta.3build, the prod database (read-only), and 8 days of prod logs found the root amplifier: the emails table was 1.6 GB for ~15k rows because inline images are stored as base64data:URIs inside body HTML (avg 106 KB/row, max 29.4 MB) — content the renderer strips to a placeholder before display anyway. Since better-sqlite3 runs synchronously on the Electron main process andbodyis declared before the columns hot queries read (label_ids,message_id,in_reply_to), every inbox/sent/search scan walked each row's overflow-page chain and froze the app for 0.8–12 s per query; style-profiler bursts reached 67–237 s with force-quits visible in the logs.This PR removes the bloat at the write boundary and backfills existing rows. Follow-ups (pathological query shapes, startup FTS check, unbounded thread payloads, DB worker thread) are tracked in #188.
Changes
src/main/db/body-sanitizer.ts(dependency-free, importable bymigrations.ts):stripLargeDataUrismoved fromdb/index.ts, plus a sharedinlineImagePlaceholderhelper and theDATA_URI_STRIP_THRESHOLD(50 KB) constant. Same strip semantics as before — small inline images (signatures, logos) keep displaying.saveEmailstrips bodies before insert. The read-path strip inrowToDashboardEmailstays as a backstop, but stored rows are now small at the source.gmail-client.ts:collectInlineImagesnow capturespart.body.size, andresolveInlineImagesmaps oversizedcid:references straight to the placeholder — the app no longer downloads multi-MB inline attachments just to strip them later.strip_large_data_uris_and_widen_merge_cover_index):AFTER UPDATEtriggers keep the search index consistent (covered by tests).idx_emails_merge_coverwithidx_emails_all_light— a covering index forgetInboxEmails' allLight query (the old index's four columns are its prefix, so it's strictly superseded).vacuumAfterflag on the migration type: VACUUM can't run inside a transaction (each migration runs in one), so it's deferred to the end ofrunNumberedMigrationsand runs at most once.schema.ts: fresh DBs getidx_emails_all_lightdirectly.Measured impact (rehearsal on a copy of the real prod DB — 15,157 emails, 1.8 GB)
COUNT(*)Migration cost: 15.6 s one-time (587 emails stripped, 1,206 MB reclaimed, VACUUM 4.9 s). FTS verified intact after the backfill (
MATCHsanity query returns expected hits). The prod logs showed the same query shapes blocking the main process for p50 1.6 s / p95 6.5 s / max 12 s on every startup, account switch, and sync tick that fetched mail — those all shrink to tens of ms.Considered and rejected
bodylast — requires a full table rebuild that breaks the external-content FTS rowid mapping (forcing an FTS rebuild) for marginal gain once bodies are stripped.email_bodiestable — larger blast radius (FTS triggers, every body reader); the strip achieves the measured win without schema surgery. Can revisit if legit large HTML bodies (newsletters) become a problem.Tests
tests/unit/body-sanitizer.spec.ts— strip semantics (oversized replaced, small preserved, mixed, no-data-URI unchanged, placeholder content).tests/migrations/strip-data-uris.spec.ts— realrunMigrationsagainst a pre-migration-shaped DB: strip correctness, index swap, FTS consistency through the backfill, idempotency, fresh-DB no-op.Follow-up issue: #188
🤖 Generated with Claude Code
Pre-PR verdict: PASS
full328e57c