@bobfrankston/mailx-store 0.1.32 → 0.1.33

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.
Files changed (5) hide show
  1. package/charset.js +20 -0
  2. package/db.d.ts +1 -0
  3. package/db.js +72 -30
  4. package/package.json +1 -1
  5. package/store.js +30 -7
package/charset.js CHANGED
@@ -17,11 +17,31 @@ export function sniffAndFixCharset(raw) {
17
17
  const re = /charset\s*=\s*"?(iso-8859-1|us-ascii|windows-1252|latin1)"?/gi;
18
18
  if (!re.test(head))
19
19
  return raw;
20
+ // The rewrite is only sound when the raw bytes ARE the body bytes — i.e.
21
+ // an 8bit / binary part. For a quoted-printable or base64 part the raw
22
+ // .eml is pure ASCII (the high bytes live inside `=XX` / base64 chars),
23
+ // so isValidUtf8(raw) passes vacuously and we would relabel a genuine
24
+ // Windows-1252 part as utf-8 — every smart-quote / em-dash then decodes
25
+ // as mojibake (Bob's 2026-05-21 report: a QP Windows-1252 Outlook mail).
26
+ // Requiring an actual non-ASCII byte in the raw gates the heuristic to
27
+ // the only case where the UTF-8 sniff is meaningful.
28
+ if (!hasNonAscii(raw))
29
+ return raw;
20
30
  if (!isValidUtf8(raw))
21
31
  return raw;
22
32
  const fixed = head.replace(/charset\s*=\s*"?(iso-8859-1|us-ascii|windows-1252|latin1)"?/gi, "charset=utf-8");
23
33
  return Buffer.concat([Buffer.from(fixed, "latin1"), raw.subarray(head.length)]);
24
34
  }
35
+ /** True if the buffer contains at least one byte >= 0x80. A pure-ASCII
36
+ * buffer is trivially valid UTF-8, so the isValidUtf8 sniff tells us
37
+ * nothing about a quoted-printable / base64 body — gate on this first. */
38
+ function hasNonAscii(buf) {
39
+ for (let i = 0; i < buf.length; i++) {
40
+ if (buf[i] >= 0x80)
41
+ return true;
42
+ }
43
+ return false;
44
+ }
25
45
  /** Strict UTF-8 validity check: rejects overlong forms, invalid start
26
46
  * bytes, and dangling continuations. Used to confirm the body is really
27
47
  * UTF-8 before overriding a Latin-1 declaration. */
package/db.d.ts CHANGED
@@ -520,6 +520,7 @@ export declare class MailxDB {
520
520
  name: string;
521
521
  email: string;
522
522
  source: string;
523
+ sources: string[];
523
524
  useCount: number;
524
525
  }[];
525
526
  /** List all contacts (address-book view) with pagination + optional filter. */
package/db.js CHANGED
@@ -2451,6 +2451,13 @@ export class MailxDB {
2451
2451
  const tokens = query.split(/\s+/).filter(Boolean);
2452
2452
  const firstSubstr = `%${tokens[0]}%`;
2453
2453
  const firstPrefix = `${tokens[0]}%`;
2454
+ // Word-prefix patterns: the token at the start of ANY name word, not
2455
+ // just the whole name. Catches "Frankston, Bob" / "Frankston,Bob"
2456
+ // when the user types "bob" — a first-name match on a Last,First
2457
+ // contact (Bob 2026-05-21). The space variant also covers the normal
2458
+ // "Bob Frankston" middle/last-word case.
2459
+ const firstWordSpace = `% ${tokens[0]}%`;
2460
+ const firstWordComma = `%,${tokens[0]}%`;
2454
2461
  const tokenWhere = tokens.map(() => "(name LIKE ? OR email LIKE ?)").join(" AND ");
2455
2462
  const tokenParams = [];
2456
2463
  for (const t of tokens) {
@@ -2463,11 +2470,19 @@ export class MailxDB {
2463
2470
  // contacts.jsonc#preferred[]. The user's `source: "work"` /
2464
2471
  // `source: "family"` tags all rank +40 alongside the default
2465
2472
  // `preferred` label.
2473
+ // Ranking: match POSITION dominates, source is only a tiebreaker
2474
+ // within the same position tier. Position tiers are spaced 100
2475
+ // apart and source tops out at 40, so a prefix match (300+) can
2476
+ // never be outranked by a mid-string match (≤140) — Bob 2026-05-21:
2477
+ // "the match should favor starting letters rather than random
2478
+ // letters in the middle." The old 3/2/1 + 0/30/40 weighting let a
2479
+ // google contact matched mid-string (1+30=31) beat a discovered
2480
+ // contact matched on prefix (3+0=3).
2466
2481
  rows = this.db.prepare(`SELECT name, email, source, use_count, last_used,
2467
2482
  (CASE
2468
- WHEN lower(name) LIKE lower(?) THEN 3
2469
- WHEN substr(email, 1, instr(email, '@') - 1) LIKE lower(?) THEN 2
2470
- WHEN email LIKE ? OR name LIKE ? THEN 1
2483
+ WHEN lower(name) LIKE lower(?) OR lower(name) LIKE lower(?) OR lower(name) LIKE lower(?) THEN 300
2484
+ WHEN substr(email, 1, instr(email, '@') - 1) LIKE lower(?) THEN 200
2485
+ WHEN email LIKE ? OR name LIKE ? THEN 100
2471
2486
  ELSE 0
2472
2487
  END) +
2473
2488
  (CASE
@@ -2478,7 +2493,7 @@ export class MailxDB {
2478
2493
  FROM contacts
2479
2494
  WHERE ${tokenWhere}
2480
2495
  ORDER BY match_rank DESC, use_count DESC, last_used DESC
2481
- LIMIT ?`).all(firstPrefix, firstPrefix, firstSubstr, firstSubstr, ...tokenParams, limit * 2);
2496
+ LIMIT ?`).all(firstPrefix, firstWordSpace, firstWordComma, firstPrefix, firstSubstr, firstSubstr, ...tokenParams, limit * 2);
2482
2497
  }
2483
2498
  catch (e) {
2484
2499
  console.error(` [searchContacts] ranked query failed (${e?.message}) — falling back to simple LIKE`);
@@ -2499,23 +2514,38 @@ export class MailxDB {
2499
2514
  rows.sort((a, b) => score(b) - score(a));
2500
2515
  // Dedup by lowercased email — same address often appears as both
2501
2516
  // source='google' (synced from Google Contacts) and source='discovered'
2502
- // (auto-collected from sent mail). The user only wants to see the
2503
- // best entry; we keep the higher-ranked source (which the sort above
2504
- // has already put first), and silently drop the duplicate. Without
2505
- // this, the autocomplete dropdown showed two identical Kevin Healy
2506
- // rows just labeled GOOGLE and DISCOVERED.
2507
- const seenEmails = new Set();
2508
- rows = rows.filter(r => {
2517
+ // (auto-collected from sent mail). Keep the higher-ranked row (already
2518
+ // sorted first) and FOLD subsequent rows' sources into a `sources` array
2519
+ // on the survivor so the dropdown can display "google, discovered"
2520
+ // instead of showing two duplicate lines.
2521
+ const merged = new Map();
2522
+ for (const r of rows) {
2509
2523
  const k = (r.email || "").toLowerCase();
2510
- if (!k)
2511
- return true;
2512
- if (seenEmails.has(k))
2513
- return false;
2514
- seenEmails.add(k);
2515
- return true;
2516
- });
2517
- rows = rows.slice(0, limit);
2518
- return rows.map(r => ({ name: r.name, email: r.email, source: r.source, useCount: r.use_count }));
2524
+ if (!k) {
2525
+ merged.set(`__noemail_${merged.size}`, { ...r, _sources: new Set([r.source]) });
2526
+ continue;
2527
+ }
2528
+ const existing = merged.get(k);
2529
+ if (!existing) {
2530
+ merged.set(k, { ...r, _sources: new Set([r.source]) });
2531
+ }
2532
+ else {
2533
+ existing._sources.add(r.source);
2534
+ // If this row has a non-empty name and the kept one didn't,
2535
+ // promote the name — Google rows often have proper names while
2536
+ // discovered rows are bare-email.
2537
+ if (!existing.name && r.name)
2538
+ existing.name = r.name;
2539
+ }
2540
+ }
2541
+ const out = Array.from(merged.values()).slice(0, limit);
2542
+ return out.map(r => ({
2543
+ name: r.name,
2544
+ email: r.email,
2545
+ source: r.source,
2546
+ sources: Array.from(r._sources).filter(Boolean),
2547
+ useCount: r.use_count,
2548
+ }));
2519
2549
  }
2520
2550
  /** List all contacts (address-book view) with pagination + optional filter. */
2521
2551
  listContacts(query, page = 1, pageSize = 100) {
@@ -2605,6 +2635,11 @@ export class MailxDB {
2605
2635
  const ts = Date.parse(s);
2606
2636
  return isNaN(ts) ? null : ts;
2607
2637
  };
2638
+ // Strip FTS5 metacharacters from a user term. A stray `;` `(` `:` `"`
2639
+ // etc. is a hard `fts5: syntax error` (Bob 2026-05-21 log). FTS5's
2640
+ // tokenizer splits on non-word chars anyway, so removing them loses no
2641
+ // real matching ability.
2642
+ const ftsClean = (t) => t.replace(/["';:(){}\[\]^~\\/]/g, "").trim();
2608
2643
  for (const part of parts) {
2609
2644
  const fromMatch = part.match(/^from:(.+)$/i);
2610
2645
  const toMatch = part.match(/^to:(.+)$/i);
@@ -2616,16 +2651,19 @@ export class MailxDB {
2616
2651
  const isMatch = part.match(/^is:(.+)$/i);
2617
2652
  const folderMatch = part.match(/^folder:(.+)$/i);
2618
2653
  if (fromMatch) {
2619
- const term = fromMatch[1].replace(/"/g, "");
2620
- ftsQuery += `(from_name:${term} OR from_address:${term}) `;
2654
+ const term = ftsClean(fromMatch[1]);
2655
+ if (term)
2656
+ ftsQuery += `(from_name:${term}* OR from_address:${term}*) `;
2621
2657
  }
2622
2658
  else if (toMatch) {
2623
- const term = toMatch[1].replace(/"/g, "");
2624
- ftsQuery += `(to_text:${term} OR cc_text:${term}) `;
2659
+ const term = ftsClean(toMatch[1]);
2660
+ if (term)
2661
+ ftsQuery += `(to_text:${term}* OR cc_text:${term}*) `;
2625
2662
  }
2626
2663
  else if (subjectMatch) {
2627
- const term = subjectMatch[1].replace(/"/g, "");
2628
- ftsQuery += `subject:${term} `;
2664
+ const term = ftsClean(subjectMatch[1]);
2665
+ if (term)
2666
+ ftsQuery += `subject:${term}* `;
2629
2667
  }
2630
2668
  else if (dateMatch || afterMatch || beforeMatch) {
2631
2669
  const op = dateMatch ? (dateMatch[1] || "=") : (afterMatch ? ">" : "<");
@@ -2679,14 +2717,18 @@ export class MailxDB {
2679
2717
  ftsQuery += `${part} `;
2680
2718
  }
2681
2719
  else {
2682
- // Unqualified — search everything.
2720
+ // Unqualified — search everything. Strip /regex-literal/
2721
+ // slashes and FTS5 metacharacters before wildcarding.
2683
2722
  let term = part.replace(/^\/|\/$/g, "");
2684
2723
  if (term.includes("|")) {
2685
- const alts = term.split("|").filter(Boolean).map(t => `${t}*`).join(" OR ");
2686
- ftsQuery += `(${alts}) `;
2724
+ const alts = term.split("|").map(t => ftsClean(t)).filter(Boolean).map(t => `${t}*`).join(" OR ");
2725
+ if (alts)
2726
+ ftsQuery += `(${alts}) `;
2687
2727
  }
2688
2728
  else {
2689
- ftsQuery += `${term}* `;
2729
+ term = ftsClean(term);
2730
+ if (term)
2731
+ ftsQuery += `${term}* `;
2690
2732
  }
2691
2733
  }
2692
2734
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.32",
3
+ "version": "0.1.33",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
package/store.js CHANGED
@@ -300,6 +300,29 @@ export class Store {
300
300
  }
301
301
  let bodyHtml = parsed.html || "";
302
302
  const bodyText = parsed.text || "";
303
+ // Charset diagnostic. If the parsed body has U+FFFD, distinguish the
304
+ // two very different causes:
305
+ // - SOURCE already contains U+FFFD (raw EF BF BD bytes, or QP
306
+ // =EF=BF=BD) → the message was composed/replied during the pre-fix
307
+ // charset window; the original bytes are gone — UNRECOVERABLE, and
308
+ // NOT a current bug.
309
+ // - source is clean but the parse produced U+FFFD → a live
310
+ // charset-decode bug worth chasing.
311
+ if (bodyHtml.includes("�") || bodyText.includes("�")) {
312
+ let sourceCorrupt = false;
313
+ try {
314
+ const src = await this.bodyStore.readByPath(storedPath);
315
+ sourceCorrupt = src.includes(Buffer.from([0xEF, 0xBF, 0xBD]))
316
+ || src.toString("latin1").toUpperCase().includes("=EF=BF=BD");
317
+ }
318
+ catch { /* */ }
319
+ if (sourceCorrupt) {
320
+ console.error(` [charset] U+FFFD baked into SOURCE — acct=${accountId} uid=${uid} path=${storedPath} — corrupt at compose time (pre-fix), unrecoverable`);
321
+ }
322
+ else {
323
+ console.error(` [charset] U+FFFD from PARSE — acct=${accountId} uid=${uid} path=${storedPath} — LIVE charset-decode bug, source is clean`);
324
+ }
325
+ }
303
326
  // Backfill FTS body_text now that we've parsed the body. upsertMessage
304
327
  // only had the short `preview` snippet at index time; without this
305
328
  // backfill, searches miss any word that only appears deeper in the
@@ -345,17 +368,17 @@ export class Store {
345
368
  hasRemoteContent = result.hasRemoteContent;
346
369
  }
347
370
  // Header extraction — Delivered-To, Return-Path, List-Unsubscribe.
348
- // mlproc preprocesses Delivered-To server-side, so the inner
349
- // address is already clean by the time mailx sees it. We take the
350
- // last entry of the chain (the final-delivery hop). The
351
- // `relayDomains` filtering layer was retired 2026-05-13: nothing
352
- // configures it in practice and the conditional made the code
353
- // harder to reason about than the one-liner that replaces it.
371
+ // Each delivery agent PREPENDS its Delivered-To, so the FIRST
372
+ // (topmost) header is the final delivery to the user's actual
373
+ // mailbox; later ones are earlier forwarding hops / internal
374
+ // routing artifacts. Take [0]. (The old code took the LAST entry —
375
+ // on a forwarded message that grabbed a stale hop, e.g. a malformed
376
+ // `…@elkin.ws@trap-prot` address Bob 2026-05-21.)
354
377
  let deliveredTo = "";
355
378
  const rawDelivered = parsed.headers.get("delivered-to");
356
379
  if (rawDelivered) {
357
380
  const deliveredList = Array.isArray(rawDelivered) ? rawDelivered : [rawDelivered];
358
- const d = deliveredList[deliveredList.length - 1];
381
+ const d = deliveredList[0];
359
382
  deliveredTo = typeof d === "string" ? d : d?.text || d?.address || String(d);
360
383
  }
361
384
  const hdr = (key) => {