{
+ const rows = db
+ .prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='emails'")
+ .all() as Array<{ name: string }>;
+ return new Set(rows.map((r) => r.name));
+}
+
+test.describe("migration 8: strip large data URIs + widen merge-cover index", () => {
+ test("strips oversized inline images from stored bodies, keeps small ones", () => {
+ const db = preMigration8Db();
+ runMigrations(db);
+
+ const fat = db.prepare("SELECT body FROM emails WHERE id = 'fat'").get() as { body: string };
+ expect(fat.body).not.toContain(BIG_URI);
+ expect(fat.body).toContain("data:image/svg+xml");
+ expect(fat.body).toContain("numbers attached
");
+
+ const small = db.prepare("SELECT body FROM emails WHERE id = 'small'").get() as {
+ body: string;
+ };
+ expect(small.body).toContain(SMALL_URI);
+ });
+
+ test("replaces idx_emails_merge_cover with idx_emails_all_light", () => {
+ const db = preMigration8Db();
+ runMigrations(db);
+
+ const names = indexNames(db);
+ expect(names.has("idx_emails_all_light")).toBe(true);
+ expect(names.has("idx_emails_merge_cover")).toBe(false);
+ });
+
+ test("FTS index stays consistent through the backfill UPDATEs", () => {
+ const db = preMigration8Db();
+ runMigrations(db);
+
+ // The AFTER UPDATE trigger re-indexes the row with unchanged body_text;
+ // search must still find the stripped email exactly once.
+ const hits = db
+ .prepare("SELECT rowid FROM emails_fts WHERE emails_fts MATCH 'quarterly'")
+ .all();
+ expect(hits.length).toBe(1);
+ });
+
+ test("is idempotent: a second run changes nothing", () => {
+ const db = preMigration8Db();
+ runMigrations(db);
+ const bodyAfterFirst = (
+ db.prepare("SELECT body FROM emails WHERE id = 'fat'").get() as { body: string }
+ ).body;
+
+ runMigrations(db);
+ const bodyAfterSecond = (
+ db.prepare("SELECT body FROM emails WHERE id = 'fat'").get() as { body: string }
+ ).body;
+ expect(bodyAfterSecond).toBe(bodyAfterFirst);
+ expect(indexNames(db).has("idx_emails_all_light")).toBe(true);
+ });
+
+ test("a candidate row whose only data URIs are small survives byte-identical", () => {
+ const db = preMigration8Db();
+ // Pad an all-small-URI body past the candidate threshold so it's SELECTed
+ // but stripLargeDataUris leaves it unchanged (exercises the no-op skip).
+ const body = `${"x".repeat(DATA_URI_STRIP_THRESHOLD)}
`;
+ db.prepare(
+ `INSERT INTO emails (id, account_id, thread_id, subject, from_address, to_address, body, body_text, date, fetched_at, label_ids)
+ VALUES ('padded', 'acct1', 't3', 's', 'a@b.com', 'c@d.com', ?, 'x', '2026-07-01T00:00:00Z', 0, '["INBOX"]')`,
+ ).run(body);
+
+ runMigrations(db);
+
+ const after = db.prepare("SELECT body FROM emails WHERE id = 'padded'").get() as {
+ body: string;
+ };
+ expect(after.body).toBe(body);
+ });
+
+ test("idx_emails_all_light covers the getInboxEmails allLight query", () => {
+ if (!DatabaseCtor) throw new Error("better-sqlite3 not loadable");
+ // Fresh DB via SCHEMA (not the pre-migration shape) — asserts the covering
+ // property this PR exists to deliver, on the index schema.ts ships.
+ const db = new DatabaseCtor(":memory:");
+ db.pragma("journal_mode = MEMORY");
+ db.exec(SCHEMA);
+ const plan = db
+ .prepare(
+ "EXPLAIN QUERY PLAN SELECT id, account_id, thread_id, message_id, in_reply_to, date, label_ids FROM emails WHERE account_id = ?",
+ )
+ .all("acct1") as Array<{ detail: string }>;
+ expect(plan.map((r) => r.detail).join("\n")).toContain(
+ "USING COVERING INDEX idx_emails_all_light",
+ );
+ });
+
+ test("fresh DB (no emails table yet) is a safe no-op", () => {
+ if (!DatabaseCtor) throw new Error("better-sqlite3 not loadable");
+ const db = new DatabaseCtor(":memory:");
+ db.pragma("journal_mode = MEMORY");
+ // Migrations run before SCHEMA in initDatabase — must not throw
+ runMigrations(db);
+ const version = db.prepare("SELECT MAX(version) as v FROM schema_version").get() as {
+ v: number;
+ };
+ expect(version.v).toBeGreaterThanOrEqual(8);
+ });
+});
diff --git a/tests/unit/body-sanitizer.spec.ts b/tests/unit/body-sanitizer.spec.ts
new file mode 100644
index 00000000..cb309692
--- /dev/null
+++ b/tests/unit/body-sanitizer.spec.ts
@@ -0,0 +1,110 @@
+/**
+ * Unit tests for shared/body-sanitizer.ts — the write-boundary strip of
+ * oversized inline data: URIs from email bodies (see migration 8 for the prod
+ * numbers that motivated it).
+ */
+import { test, expect } from "@playwright/test";
+import {
+ stripLargeDataUris,
+ inlineImagePlaceholder,
+ DATA_URI_STRIP_THRESHOLD,
+} from "../../src/shared/body-sanitizer";
+
+function dataUriOfLength(totalLength: number, scheme = "data:image/png;base64,"): string {
+ return scheme + "A".repeat(totalLength - scheme.length);
+}
+
+test.describe("stripLargeDataUris", () => {
+ test("replaces an oversized img data URI with a placeholder", () => {
+ const big = dataUriOfLength(DATA_URI_STRIP_THRESHOLD);
+ const body = `hi
`;
+ const stripped = stripLargeDataUris(body);
+
+ expect(stripped).not.toContain(big);
+ expect(stripped).toContain("data:image/svg+xml");
+ expect(stripped).toContain("too%20large%20to%20display%20inline");
+ // Surrounding HTML is untouched
+ expect(stripped).toContain("hi
");
+ expect(stripped).toContain('alt="x"');
+ expect(stripped.length).toBeLessThan(body.length / 10);
+ });
+
+ test("keeps data URIs under the threshold (signatures, logos)", () => {
+ const small = dataUriOfLength(1_000);
+ // Pad the body over the threshold so the early-return doesn't mask the check
+ const body = `
` + "x".repeat(DATA_URI_STRIP_THRESHOLD);
+ expect(stripLargeDataUris(body)).toContain(small);
+ });
+
+ test("strips only the oversized URI when sizes are mixed", () => {
+ const small = dataUriOfLength(1_000);
+ const big = dataUriOfLength(DATA_URI_STRIP_THRESHOLD + 1);
+ const body = `
`;
+ const stripped = stripLargeDataUris(body);
+ expect(stripped).toContain(small);
+ expect(stripped).not.toContain(big);
+ });
+
+ test("strips multiple oversized URIs in one body", () => {
+ const a = dataUriOfLength(DATA_URI_STRIP_THRESHOLD, "data:image/png;base64,");
+ const b = dataUriOfLength(DATA_URI_STRIP_THRESHOLD, "data:image/gif;base64,");
+ const stripped = stripLargeDataUris(`
`);
+ expect(stripped).not.toContain(a);
+ expect(stripped).not.toContain(b);
+ expect(stripped.match(/data:image\/svg\+xml/g)?.length).toBe(2);
+ });
+
+ test("strips single-quoted, uppercase IMG tags (case-insensitive)", () => {
+ const big = dataUriOfLength(DATA_URI_STRIP_THRESHOLD);
+ const stripped = stripLargeDataUris(`
`);
+ expect(stripped).not.toContain(big);
+ expect(stripped).toContain("data:image/svg+xml");
+ });
+
+ test("strips an uppercase DATA: scheme (RFC 2397 case-insensitive) — no bypass", () => {
+ const big = dataUriOfLength(DATA_URI_STRIP_THRESHOLD, "DATA:image/png;base64,");
+ const stripped = stripLargeDataUris(`
`);
+ expect(stripped).not.toContain(big);
+ expect(stripped).toContain("data:image/svg+xml");
+ });
+
+ test("strips oversized data URIs on non-img elements (video/source)", () => {
+ const big = dataUriOfLength(DATA_URI_STRIP_THRESHOLD, "data:video/mp4;base64,");
+ const stripped = stripLargeDataUris(``);
+ expect(stripped).not.toContain(big);
+ expect(stripped).toContain("data:image/svg+xml");
+ });
+
+ test("is linear-time on adversarial input (many
)", () => {
+ // A quadratic matcher hangs for seconds here; the linear one returns fast.
+ const body = "data:x" + "
{
+ const evil =
+ "data:image/png;base64," + "A".repeat(DATA_URI_STRIP_THRESHOLD);
+ const stripped = stripLargeDataUris(`
`);
+ // mime failed validation → fell back to "image"; no raw markup leaked
+ expect(stripped).not.toContain("