@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.
- package/bin/mailx.js +6 -5
- package/bin/mailx.js.map +1 -1
- package/bin/mailx.ts +5 -4
- package/debug.md +81 -0
- package/package.json +1 -1
- package/packages/mailx-api/index.js +1 -1
- package/packages/mailx-api/index.js.map +1 -1
- package/packages/mailx-api/index.ts +1 -1
- package/packages/mailx-core/index.d.ts +1 -1
- package/packages/mailx-core/index.d.ts.map +1 -1
- package/packages/mailx-core/index.js +6 -4
- package/packages/mailx-core/index.js.map +1 -1
- package/packages/mailx-core/index.ts +5 -3
- package/packages/mailx-server/index.d.ts.map +1 -1
- package/packages/mailx-server/index.js +5 -4
- package/packages/mailx-server/index.js.map +1 -1
- package/packages/mailx-server/index.ts +4 -3
- package/packages/mailx-service/index.d.ts +1 -1
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +13 -24
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +13 -21
- package/packages/mailx-store/db.d.ts +20 -3
- package/packages/mailx-store/db.d.ts.map +1 -1
- package/packages/mailx-store/db.js +204 -107
- package/packages/mailx-store/db.js.map +1 -1
- package/packages/mailx-store/db.ts +188 -91
- /package/packages/mailx-imap/{node_modules.npmglobalize-stash-51500 → node_modules.npmglobalize-stash-62736}/.package-lock.json +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
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
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
for (
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
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
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
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
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
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
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
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
|
-
|
|
2376
|
-
|
|
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 (
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
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
|
-
|
|
2423
|
-
const
|
|
2424
|
-
|
|
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(", ")}`);
|