@bobfrankston/rmfmail 1.1.24 → 1.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2129,6 +2129,26 @@ export class MailxDB {
2129
2129
  commitTransaction(): void { this.db.exec("COMMIT"); }
2130
2130
  rollbackTransaction(): void { this.db.exec("ROLLBACK"); }
2131
2131
 
2132
+ /** Run `fn` inside a transaction — nesting-safe. `node:sqlite` throws on
2133
+ * a nested BEGIN, and the async chunked walkers (seedContactsFromMessages,
2134
+ * applyContactsConfig) can interleave with the sync backfill's own
2135
+ * transactions across their `setImmediate` yields. If a transaction is
2136
+ * already open this just runs `fn` (its writes join the open txn);
2137
+ * otherwise it owns a fresh BEGIN/COMMIT. `fn` MUST be synchronous so it
2138
+ * can't span an await and leave a transaction open across a yield. */
2139
+ runInTxn<T>(fn: () => T): T {
2140
+ if (this.db.isTransaction) return fn();
2141
+ this.db.exec("BEGIN");
2142
+ try {
2143
+ const r = fn();
2144
+ this.db.exec("COMMIT");
2145
+ return r;
2146
+ } catch (e) {
2147
+ try { this.db.exec("ROLLBACK"); } catch { /* already rolled back */ }
2148
+ throw e;
2149
+ }
2150
+ }
2151
+
2132
2152
  // ── Contacts ──
2133
2153
 
2134
2154
  /** Record an address used in sent mail */
@@ -2224,7 +2244,16 @@ export class MailxDB {
2224
2244
  * Discovered is a single tier; sub-distinctions like sent-vs-received
2225
2245
  * collapse here because the user-facing UI shows them as one "discovered"
2226
2246
  * source. Recency-weighted use_count differentiates within the tier. */
2227
- seedContactsFromMessages(): number {
2247
+ /** ASYNC + chunked. This walks every cached message's address fields —
2248
+ * on a large account that is hundreds of thousands of rows. The old
2249
+ * synchronous version did `.all()` on the whole messages table in one
2250
+ * call, materialising every row and blocking the event loop for tens
2251
+ * of seconds to minutes (profiled 2026-05-15: 99% of daemon ticks were
2252
+ * in native SQLite while this ran — every getMessage IPC, every preview
2253
+ * click, queued behind it; that IS the "loading body takes forever"
2254
+ * bug). Now keyset-paginated by `m.id` with a `setImmediate` yield
2255
+ * between pages, so user IPC lands in the gaps. */
2256
+ async seedContactsFromMessages(): Promise<number> {
2228
2257
  const VALID = /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/;
2229
2258
  const now = Date.now();
2230
2259
  const agg = new Map<string, { name: string; cnt: number; last: number }>();
@@ -2242,47 +2271,69 @@ export class MailxDB {
2242
2271
  agg.set(email, { name: name || "", cnt: 1, last: date || 0 });
2243
2272
  }
2244
2273
  };
2274
+ const eatRecipients = (field: string, date: number): void => {
2275
+ if (!field) return;
2276
+ let parsed: any[];
2277
+ try { parsed = JSON.parse(field); } catch { return; }
2278
+ if (!Array.isArray(parsed)) return;
2279
+ for (const a of parsed) {
2280
+ if (!a) continue;
2281
+ bump(a.name || "", a.address || a.email || "", date);
2282
+ }
2283
+ };
2284
+ const yieldLoop = (): Promise<void> => new Promise<void>(r => setImmediate(r));
2285
+ const PAGE = 2000;
2245
2286
 
2246
2287
  // Sent folder: recipients only (skip the user's own From address).
2247
- const sentRows = this.db.prepare(
2248
- `SELECT m.to_json, m.cc_json, m.bcc_json, m.date
2249
- FROM messages m
2250
- JOIN folders f ON m.folder_id = f.id
2251
- WHERE f.special_use = 'sent'`
2252
- ).all() as { to_json: string; cc_json: string; bcc_json: string; date: number }[];
2253
- for (const r of sentRows) {
2254
- const date = r.date || 0;
2255
- for (const field of [r.to_json, r.cc_json, r.bcc_json]) {
2256
- if (!field) continue;
2257
- let parsed: any[];
2258
- try { parsed = JSON.parse(field); } catch { continue; }
2259
- if (!Array.isArray(parsed)) continue;
2260
- for (const a of parsed) {
2261
- if (!a) continue;
2262
- bump(a.name || "", a.address || a.email || "", date);
2288
+ // Keyset pagination by m.id — each page is a bounded `.all()`, and
2289
+ // the event loop is handed back between pages.
2290
+ {
2291
+ const stmt = this.db.prepare(
2292
+ `SELECT m.id AS id, m.to_json, m.cc_json, m.bcc_json, m.date
2293
+ FROM messages m
2294
+ JOIN folders f ON m.folder_id = f.id
2295
+ WHERE f.special_use = 'sent' AND m.id > ?
2296
+ ORDER BY m.id LIMIT ?`
2297
+ );
2298
+ let lastId = 0;
2299
+ for (;;) {
2300
+ const rows = stmt.all(lastId, PAGE) as { id: number; to_json: string; cc_json: string; bcc_json: string; date: number }[];
2301
+ if (rows.length === 0) break;
2302
+ for (const r of rows) {
2303
+ const date = r.date || 0;
2304
+ eatRecipients(r.to_json, date);
2305
+ eatRecipients(r.cc_json, date);
2306
+ eatRecipients(r.bcc_json, date);
2263
2307
  }
2308
+ lastId = rows[rows.length - 1].id;
2309
+ if (rows.length < PAGE) break;
2310
+ await yieldLoop();
2264
2311
  }
2265
2312
  }
2266
2313
 
2267
2314
  // Other folders: From + recipients.
2268
- const recvRows = this.db.prepare(
2269
- `SELECT m.from_name, m.from_address, m.to_json, m.cc_json, m.bcc_json, m.date
2270
- FROM messages m
2271
- LEFT JOIN folders f ON m.folder_id = f.id
2272
- WHERE f.special_use IS NULL OR f.special_use != 'sent'`
2273
- ).all() as { from_name: string; from_address: string; to_json: string; cc_json: string; bcc_json: string; date: number }[];
2274
- for (const r of recvRows) {
2275
- const date = r.date || 0;
2276
- bump(r.from_name, r.from_address, date);
2277
- for (const field of [r.to_json, r.cc_json, r.bcc_json]) {
2278
- if (!field) continue;
2279
- let parsed: any[];
2280
- try { parsed = JSON.parse(field); } catch { continue; }
2281
- if (!Array.isArray(parsed)) continue;
2282
- for (const a of parsed) {
2283
- if (!a) continue;
2284
- bump(a.name || "", a.address || a.email || "", date);
2315
+ {
2316
+ const stmt = this.db.prepare(
2317
+ `SELECT m.id AS id, m.from_name, m.from_address, m.to_json, m.cc_json, m.bcc_json, m.date
2318
+ FROM messages m
2319
+ LEFT JOIN folders f ON m.folder_id = f.id
2320
+ WHERE (f.special_use IS NULL OR f.special_use != 'sent') AND m.id > ?
2321
+ ORDER BY m.id LIMIT ?`
2322
+ );
2323
+ let lastId = 0;
2324
+ for (;;) {
2325
+ const rows = stmt.all(lastId, PAGE) as { id: number; from_name: string; from_address: string; to_json: string; cc_json: string; bcc_json: string; date: number }[];
2326
+ if (rows.length === 0) break;
2327
+ for (const r of rows) {
2328
+ const date = r.date || 0;
2329
+ bump(r.from_name, r.from_address, date);
2330
+ eatRecipients(r.to_json, date);
2331
+ eatRecipients(r.cc_json, date);
2332
+ eatRecipients(r.bcc_json, date);
2285
2333
  }
2334
+ lastId = rows[rows.length - 1].id;
2335
+ if (rows.length < PAGE) break;
2336
+ await yieldLoop();
2286
2337
  }
2287
2338
  }
2288
2339
 
@@ -2298,17 +2349,31 @@ export class MailxDB {
2298
2349
  updated_at = ?
2299
2350
  WHERE id = ?`
2300
2351
  );
2301
- for (const [email, info] of agg) {
2302
- const existing = this.db.prepare(
2303
- "SELECT id FROM contacts WHERE source = 'discovered' AND lower(email) = ?"
2304
- ).get(email) as { id: number } | undefined;
2305
- if (!existing) {
2306
- insStmt.run(info.name, email, info.last, info.cnt, now);
2307
- added++;
2308
- } else {
2309
- updStmt.run(info.cnt, info.last, info.name, info.name, now, existing.id);
2310
- bumped++;
2311
- }
2352
+ const findStmt = this.db.prepare(
2353
+ "SELECT id FROM contacts WHERE source = 'discovered' AND lower(email) = ?"
2354
+ );
2355
+ // Write phase chunked. Each chunk runs via `db.transaction()`
2356
+ // (one synchronous turn, one commit) rather than raw BEGIN/COMMIT
2357
+ // straddling an `await` — the wrapper is nesting-safe (savepoints)
2358
+ // so it can't collide with the sync backfill's own transactions
2359
+ // when the two interleave across the yield below.
2360
+ const WRITE_CHUNK = 500;
2361
+ const entries = [...agg.entries()];
2362
+ for (let i = 0; i < entries.length; i += WRITE_CHUNK) {
2363
+ const slice = entries.slice(i, i + WRITE_CHUNK);
2364
+ this.runInTxn(() => {
2365
+ for (const [email, info] of slice) {
2366
+ const existing = findStmt.get(email) as { id: number } | undefined;
2367
+ if (!existing) {
2368
+ insStmt.run(info.name, email, info.last, info.cnt, now);
2369
+ added++;
2370
+ } else {
2371
+ updStmt.run(info.cnt, info.last, info.name, info.name, now, existing.id);
2372
+ bumped++;
2373
+ }
2374
+ }
2375
+ });
2376
+ await yieldLoop();
2312
2377
  }
2313
2378
  if (added > 0 || bumped > 0) {
2314
2379
  console.log(` [contacts] seed: ${added} new + ${bumped} refreshed (discovered)`);
@@ -2327,13 +2392,13 @@ export class MailxDB {
2327
2392
  * Discovered rows from the file are MERGED with whatever the local
2328
2393
  * message-corpus seeder has produced. Each device contributes its
2329
2394
  * observed addresses; over time GDrive accumulates the union. */
2330
- applyContactsConfig(cfg: {
2395
+ async applyContactsConfig(cfg: {
2331
2396
  preferred?: { name?: string; email: string; source?: string; organization?: string; org?: string; priority?: boolean }[];
2332
2397
  denylist?: string[];
2333
2398
  denylistPatterns?: string[];
2334
2399
  priorityDomains?: string[];
2335
2400
  discovered?: { name?: string; email: string; useCount?: number; lastUsed?: number }[];
2336
- }): { preferred: number; discovered: number; purged: number; conflicts: string[] } {
2401
+ }): Promise<{ preferred: number; discovered: number; purged: number; conflicts: string[] }> {
2337
2402
  const preferred = Array.isArray(cfg.preferred) ? cfg.preferred : [];
2338
2403
  const denylist = Array.isArray(cfg.denylist) ? cfg.denylist : [];
2339
2404
  const denylistPatterns = Array.isArray(cfg.denylistPatterns) ? cfg.denylistPatterns : [];
@@ -2349,41 +2414,67 @@ export class MailxDB {
2349
2414
  .map(e => e.email);
2350
2415
  this.setPriorityIndex(prioritySenders, priorityDomains);
2351
2416
 
2417
+ const VALID = /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/;
2418
+ const now = Date.now();
2419
+ const denySet = new Set(denylist.map(e => (e || "").trim().toLowerCase()).filter(Boolean));
2420
+ const conflicts: string[] = [];
2421
+ const yieldLoop = (): Promise<void> => new Promise<void>(r => setImmediate(r));
2422
+ const CHUNK = 500;
2423
+
2352
2424
  // Wipe and rewrite preferred-tier rows owned by contacts.jsonc.
2353
2425
  // The address-book UI's legacy `upsertContact` still writes
2354
2426
  // source='manual' rows; those are owned by the address-book code
2355
2427
  // path, not contacts.jsonc, so we leave them alone here.
2356
- this.db.exec("DELETE FROM contacts WHERE source NOT IN ('google', 'discovered', 'manual')");
2357
-
2358
- const VALID = /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/;
2359
- const now = Date.now();
2428
+ // ALL writes below run inside transactions and yield the event
2429
+ // loop between chunks. The prior version did one autocommitting
2430
+ // INSERT/UPDATE per row with NO transaction — on a contacts.jsonc
2431
+ // that has accumulated thousands of `discovered` entries across
2432
+ // devices, that was thousands of fsyncs back-to-back, blocking the
2433
+ // daemon for 25-30 s (profiled 2026-05-15: applyContactsConfig was
2434
+ // ~97% of the wedge). It also re-`prepare()`d a `lower(email)`
2435
+ // full-scan SELECT every iteration. Now: one statement prep, one
2436
+ // up-front map of existing discovered rows (kills the N×N scan),
2437
+ // chunked transactions, yields between chunks.
2360
2438
  const ins = this.db.prepare(
2361
2439
  `INSERT OR IGNORE INTO contacts (source, name, email, organization, last_used, use_count, updated_at)
2362
2440
  VALUES (?, ?, ?, ?, 0, 0, ?)`
2363
2441
  );
2364
- const denySet = new Set(denylist.map(e => (e || "").trim().toLowerCase()).filter(Boolean));
2365
- const conflicts: string[] = [];
2366
2442
  let inserted = 0;
2367
- for (const entry of preferred) {
2368
- if (!entry) continue;
2369
- const email = (entry.email || "").trim();
2370
- if (!email || !VALID.test(email)) continue;
2371
- if (denySet.has(email.toLowerCase())) {
2372
- conflicts.push(email);
2373
- continue;
2443
+ // runInTxn: nesting-safe and atomic within one synchronous turn, so
2444
+ // these writes can't collide with the sync backfill's transactions
2445
+ // when the two interleave across the yields below.
2446
+ this.runInTxn(() => {
2447
+ this.db.exec("DELETE FROM contacts WHERE source NOT IN ('google', 'discovered', 'manual')");
2448
+ for (const entry of preferred) {
2449
+ if (!entry) continue;
2450
+ const email = (entry.email || "").trim();
2451
+ if (!email || !VALID.test(email)) continue;
2452
+ if (denySet.has(email.toLowerCase())) {
2453
+ conflicts.push(email);
2454
+ continue;
2455
+ }
2456
+ const source = (entry.source || "preferred").trim() || "preferred";
2457
+ const name = (entry.name || "").trim();
2458
+ const org = (entry.organization || entry.org || "").trim();
2459
+ try {
2460
+ const r = ins.run(source, name, email, org, now);
2461
+ if ((r as any).changes) inserted++;
2462
+ } catch { /* dup row, skip */ }
2374
2463
  }
2375
- const source = (entry.source || "preferred").trim() || "preferred";
2376
- const name = (entry.name || "").trim();
2377
- const org = (entry.organization || entry.org || "").trim();
2378
- try {
2379
- const r = ins.run(source, name, email, org, now);
2380
- if ((r as any).changes) inserted++;
2381
- } catch { /* dup row, skip */ }
2382
- }
2464
+ });
2465
+ await yieldLoop();
2383
2466
 
2384
2467
  // Merge discovered[] from cloud into local cache. For each entry:
2385
2468
  // existing row wins on use_count (max), name fills if empty, lastUsed
2386
2469
  // is max. Missing rows are inserted. Denylisted entries skipped.
2470
+ // Existing discovered rows are mapped ONCE up front so the merge is
2471
+ // O(N) hash lookups, not O(N) `lower(email)` table scans.
2472
+ const existingDiscovered = new Map<string, number>();
2473
+ for (const row of this.db.prepare(
2474
+ "SELECT id, lower(email) AS le FROM contacts WHERE source = 'discovered'"
2475
+ ).all() as { id: number; le: string }[]) {
2476
+ existingDiscovered.set(row.le, row.id);
2477
+ }
2387
2478
  const insDiscovered = this.db.prepare(
2388
2479
  "INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('discovered', ?, ?, ?, ?, ?)"
2389
2480
  );
@@ -2395,34 +2486,40 @@ export class MailxDB {
2395
2486
  WHERE id = ?`
2396
2487
  );
2397
2488
  let discoveredAdded = 0;
2398
- for (const entry of discovered) {
2399
- if (!entry) continue;
2400
- const email = (entry.email || "").trim();
2401
- if (!email || !VALID.test(email)) continue;
2402
- const lower = email.toLowerCase();
2403
- if (denySet.has(lower)) continue;
2404
- if (isJunkContact(lower, entry.name || "")) continue;
2405
- const name = (entry.name || "").trim();
2406
- const useCount = Math.max(0, entry.useCount || 0);
2407
- const lastUsed = Math.max(0, entry.lastUsed || 0);
2408
- const existing = this.db.prepare(
2409
- "SELECT id FROM contacts WHERE source = 'discovered' AND lower(email) = ?"
2410
- ).get(lower) as { id: number } | undefined;
2411
- if (!existing) {
2412
- insDiscovered.run(name, email, lastUsed, useCount, now);
2413
- discoveredAdded++;
2414
- } else {
2415
- updDiscovered.run(useCount, lastUsed, name, name, now, existing.id);
2416
- }
2489
+ for (let i = 0; i < discovered.length; i += CHUNK) {
2490
+ const slice = discovered.slice(i, i + CHUNK);
2491
+ this.runInTxn(() => {
2492
+ for (const entry of slice) {
2493
+ if (!entry) continue;
2494
+ const email = (entry.email || "").trim();
2495
+ if (!email || !VALID.test(email)) continue;
2496
+ const lower = email.toLowerCase();
2497
+ if (denySet.has(lower)) continue;
2498
+ if (isJunkContact(lower, entry.name || "")) continue;
2499
+ const name = (entry.name || "").trim();
2500
+ const useCount = Math.max(0, entry.useCount || 0);
2501
+ const lastUsed = Math.max(0, entry.lastUsed || 0);
2502
+ const existingId = existingDiscovered.get(lower);
2503
+ if (existingId === undefined) {
2504
+ insDiscovered.run(name, email, lastUsed, useCount, now);
2505
+ discoveredAdded++;
2506
+ } else {
2507
+ updDiscovered.run(useCount, lastUsed, name, name, now, existingId);
2508
+ }
2509
+ }
2510
+ });
2511
+ await yieldLoop();
2417
2512
  }
2418
2513
 
2419
2514
  // Purge discovered rows for any denylisted email.
2420
2515
  const purge = this.db.prepare("DELETE FROM contacts WHERE source = 'discovered' AND lower(email) = ?");
2421
2516
  let purged = 0;
2422
- for (const e of denySet) {
2423
- const r = purge.run(e);
2424
- purged += Number((r as any).changes || 0);
2425
- }
2517
+ this.runInTxn(() => {
2518
+ for (const e of denySet) {
2519
+ const r = purge.run(e);
2520
+ purged += Number((r as any).changes || 0);
2521
+ }
2522
+ });
2426
2523
 
2427
2524
  if (conflicts.length > 0) {
2428
2525
  console.warn(` [contacts] config: ${conflicts.length} preferred entries also appear in denylist — denylist wins, entries skipped: ${conflicts.join(", ")}`);