@bobfrankston/mailx-store 0.1.32 → 0.1.34

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/charset.d.ts CHANGED
@@ -1,15 +1,29 @@
1
1
  /**
2
- * Charset normalization for incoming email bodies.
2
+ * Charset normalization for incoming email bodies. Two corrections, both
3
+ * applied by rewriting the part's `charset=` declaration before the parser
4
+ * decodes it:
3
5
  *
4
- * Many senders (esp. PHPMailer-driven marketing) declare
5
- * `charset=iso-8859-1` but emit UTF-8 bytes. simpleParser honors the
6
- * declared charset and produces "â??" garbage for every non-ASCII
7
- * codepoint (em-dash, smart quotes, …). When the raw body bytes are
8
- * valid UTF-8, rewrite the charset header before parsing. We only
9
- * override the obviously-wrong legacy declarations; explicit utf-8 /
10
- * koi8 / etc. pass through.
6
+ * 1. Mis-declared UTF-8: PHPMailer-style senders declare `iso-8859-1`
7
+ * (the PHP default) but emit UTF-8 bytes. When the raw body really is
8
+ * valid UTF-8, rewrite the declaration to utf-8.
9
+ *
10
+ * 2. iso-8859-1 windows-1252: senders that genuinely mean a legacy
11
+ * 8-bit charset overwhelmingly emit Windows-1252, not strict
12
+ * ISO-8859-1 smart quotes / em-dash / euro live in 0x80-0x9F, which
13
+ * ISO-8859-1 leaves as unusable C1 control codes. The WHATWG / browser
14
+ * standard is to decode `iso-8859-1` AS `windows-1252`; iconv-lite
15
+ * does not, so we rewrite the declaration. windows-1252 is a strict
16
+ * superset of printable ISO-8859-1, so a genuine ISO-8859-1 char is
17
+ * never lost.
11
18
  */
12
19
  /** Returns either the original buffer (no change needed) or a copy with
13
- * the leading charset declaration rewritten to utf-8. */
20
+ * the leading charset declaration corrected. */
14
21
  export declare function sniffAndFixCharset(raw: Buffer): Buffer;
22
+ /** String form of sniffAndFixCharset — for the preview path, which parses
23
+ * the message as a string (extractPreview). Same two corrections, applied
24
+ * to the charset declaration in the text. The UTF-8-misdeclare correction
25
+ * can't be done on a string (the bytes are already decoded), so this only
26
+ * does correction 2 (iso-8859-1 → windows-1252), which is a pure
27
+ * declaration rewrite and the common case for previews. */
28
+ export declare function fixCharsetDeclString(s: string): string;
15
29
  //# sourceMappingURL=charset.d.ts.map
package/charset.js CHANGED
@@ -1,26 +1,68 @@
1
1
  /**
2
- * Charset normalization for incoming email bodies.
2
+ * Charset normalization for incoming email bodies. Two corrections, both
3
+ * applied by rewriting the part's `charset=` declaration before the parser
4
+ * decodes it:
3
5
  *
4
- * Many senders (esp. PHPMailer-driven marketing) declare
5
- * `charset=iso-8859-1` but emit UTF-8 bytes. simpleParser honors the
6
- * declared charset and produces "â??" garbage for every non-ASCII
7
- * codepoint (em-dash, smart quotes, …). When the raw body bytes are
8
- * valid UTF-8, rewrite the charset header before parsing. We only
9
- * override the obviously-wrong legacy declarations; explicit utf-8 /
10
- * koi8 / etc. pass through.
6
+ * 1. Mis-declared UTF-8: PHPMailer-style senders declare `iso-8859-1`
7
+ * (the PHP default) but emit UTF-8 bytes. When the raw body really is
8
+ * valid UTF-8, rewrite the declaration to utf-8.
9
+ *
10
+ * 2. iso-8859-1 windows-1252: senders that genuinely mean a legacy
11
+ * 8-bit charset overwhelmingly emit Windows-1252, not strict
12
+ * ISO-8859-1 smart quotes / em-dash / euro live in 0x80-0x9F, which
13
+ * ISO-8859-1 leaves as unusable C1 control codes. The WHATWG / browser
14
+ * standard is to decode `iso-8859-1` AS `windows-1252`; iconv-lite
15
+ * does not, so we rewrite the declaration. windows-1252 is a strict
16
+ * superset of printable ISO-8859-1, so a genuine ISO-8859-1 char is
17
+ * never lost.
11
18
  */
12
19
  /** Returns either the original buffer (no change needed) or a copy with
13
- * the leading charset declaration rewritten to utf-8. */
20
+ * the leading charset declaration corrected. */
14
21
  export function sniffAndFixCharset(raw) {
15
22
  const HEAD_LIMIT = 16384;
16
23
  const head = raw.subarray(0, Math.min(HEAD_LIMIT, raw.length)).toString("latin1");
17
- const re = /charset\s*=\s*"?(iso-8859-1|us-ascii|windows-1252|latin1)"?/gi;
18
- if (!re.test(head))
19
- return raw;
20
- if (!isValidUtf8(raw))
24
+ const legacyRe = /charset\s*=\s*"?(iso-8859-1|us-ascii|windows-1252|latin1)"?/gi;
25
+ if (!legacyRe.test(head))
21
26
  return raw;
22
- const fixed = head.replace(/charset\s*=\s*"?(iso-8859-1|us-ascii|windows-1252|latin1)"?/gi, "charset=utf-8");
23
- return Buffer.concat([Buffer.from(fixed, "latin1"), raw.subarray(head.length)]);
27
+ // Correction 1 — mis-declared UTF-8. Only sound when the raw bytes ARE
28
+ // the body bytes (an 8bit part): for a quoted-printable / base64 part
29
+ // the raw .eml is pure ASCII, so isValidUtf8 passes vacuously and we'd
30
+ // wrongly relabel a real Windows-1252 part as utf-8 (Bob 2026-05-21).
31
+ // Requiring a real non-ASCII byte gates the UTF-8 sniff to where it
32
+ // means something.
33
+ if (hasNonAscii(raw) && isValidUtf8(raw)) {
34
+ const fixed = head.replace(legacyRe, "charset=utf-8");
35
+ return Buffer.concat([Buffer.from(fixed, "latin1"), raw.subarray(head.length)]);
36
+ }
37
+ // Correction 2 — iso-8859-1 / latin1 → windows-1252. Applies whether
38
+ // the part is QP or 8bit; the rewrite is purely on the declaration.
39
+ // (Bob 2026-05-22: an Intuit mail declared ISO-8859-1, QP-encoded a
40
+ // 0x92 apostrophe → latin1 decode produced a U+0092 control char.)
41
+ const isoRe = /charset\s*=\s*"?(iso-8859-1|latin1)"?/gi;
42
+ if (isoRe.test(head)) {
43
+ const fixed = head.replace(isoRe, "charset=windows-1252");
44
+ return Buffer.concat([Buffer.from(fixed, "latin1"), raw.subarray(head.length)]);
45
+ }
46
+ return raw;
47
+ }
48
+ /** String form of sniffAndFixCharset — for the preview path, which parses
49
+ * the message as a string (extractPreview). Same two corrections, applied
50
+ * to the charset declaration in the text. The UTF-8-misdeclare correction
51
+ * can't be done on a string (the bytes are already decoded), so this only
52
+ * does correction 2 (iso-8859-1 → windows-1252), which is a pure
53
+ * declaration rewrite and the common case for previews. */
54
+ export function fixCharsetDeclString(s) {
55
+ return s.replace(/charset\s*=\s*"?(iso-8859-1|latin1)"?/gi, "charset=windows-1252");
56
+ }
57
+ /** True if the buffer contains at least one byte >= 0x80. A pure-ASCII
58
+ * buffer is trivially valid UTF-8, so the isValidUtf8 sniff tells us
59
+ * nothing about a quoted-printable / base64 body — gate on this first. */
60
+ function hasNonAscii(buf) {
61
+ for (let i = 0; i < buf.length; i++) {
62
+ if (buf[i] >= 0x80)
63
+ return true;
64
+ }
65
+ return false;
24
66
  }
25
67
  /** Strict UTF-8 validity check: rejects overlong forms, invalid start
26
68
  * bytes, and dangling continuations. Used to confirm the body is really
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
@@ -23,11 +23,18 @@ const _libmime = _libmimeMod.default || _libmimeMod;
23
23
  function decodeHeaderWords(s) {
24
24
  if (!s || s.indexOf("=?") < 0)
25
25
  return s || "";
26
+ // Normalize a POSIX-locale charset to the bare charset before decoding.
27
+ // Some mailers emit `=?en_US.UTF-8?Q?...?=` — `en_US.UTF-8` is a locale
28
+ // string (language_TERRITORY.CODESET), not an RFC 2047 charset; libmime /
29
+ // iconv-lite don't recognize it and the encoded-word stays raw in the
30
+ // subject (Bob 2026-05-22, NNSquad mail). The real charset is the CODESET
31
+ // after the dot. Rewrite `=?lang_REGION.CHARSET?` → `=?CHARSET?`.
32
+ const fixed = s.replace(/=\?[A-Za-z]{1,8}_[A-Za-z]{1,8}\.([A-Za-z0-9][A-Za-z0-9._-]*)((?:\*[^?]*)?\?)/g, "=?$1$2");
26
33
  try {
27
- return _libmime.decodeWords(s);
34
+ return _libmime.decodeWords(fixed);
28
35
  }
29
36
  catch {
30
- return s;
37
+ return fixed;
31
38
  }
32
39
  }
33
40
  /** Addresses that have no business in autocomplete. Patterns load from
@@ -2451,6 +2458,13 @@ export class MailxDB {
2451
2458
  const tokens = query.split(/\s+/).filter(Boolean);
2452
2459
  const firstSubstr = `%${tokens[0]}%`;
2453
2460
  const firstPrefix = `${tokens[0]}%`;
2461
+ // Word-prefix patterns: the token at the start of ANY name word, not
2462
+ // just the whole name. Catches "Frankston, Bob" / "Frankston,Bob"
2463
+ // when the user types "bob" — a first-name match on a Last,First
2464
+ // contact (Bob 2026-05-21). The space variant also covers the normal
2465
+ // "Bob Frankston" middle/last-word case.
2466
+ const firstWordSpace = `% ${tokens[0]}%`;
2467
+ const firstWordComma = `%,${tokens[0]}%`;
2454
2468
  const tokenWhere = tokens.map(() => "(name LIKE ? OR email LIKE ?)").join(" AND ");
2455
2469
  const tokenParams = [];
2456
2470
  for (const t of tokens) {
@@ -2463,11 +2477,19 @@ export class MailxDB {
2463
2477
  // contacts.jsonc#preferred[]. The user's `source: "work"` /
2464
2478
  // `source: "family"` tags all rank +40 alongside the default
2465
2479
  // `preferred` label.
2480
+ // Ranking: match POSITION dominates, source is only a tiebreaker
2481
+ // within the same position tier. Position tiers are spaced 100
2482
+ // apart and source tops out at 40, so a prefix match (300+) can
2483
+ // never be outranked by a mid-string match (≤140) — Bob 2026-05-21:
2484
+ // "the match should favor starting letters rather than random
2485
+ // letters in the middle." The old 3/2/1 + 0/30/40 weighting let a
2486
+ // google contact matched mid-string (1+30=31) beat a discovered
2487
+ // contact matched on prefix (3+0=3).
2466
2488
  rows = this.db.prepare(`SELECT name, email, source, use_count, last_used,
2467
2489
  (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
2490
+ WHEN lower(name) LIKE lower(?) OR lower(name) LIKE lower(?) OR lower(name) LIKE lower(?) THEN 300
2491
+ WHEN substr(email, 1, instr(email, '@') - 1) LIKE lower(?) THEN 200
2492
+ WHEN email LIKE ? OR name LIKE ? THEN 100
2471
2493
  ELSE 0
2472
2494
  END) +
2473
2495
  (CASE
@@ -2478,7 +2500,7 @@ export class MailxDB {
2478
2500
  FROM contacts
2479
2501
  WHERE ${tokenWhere}
2480
2502
  ORDER BY match_rank DESC, use_count DESC, last_used DESC
2481
- LIMIT ?`).all(firstPrefix, firstPrefix, firstSubstr, firstSubstr, ...tokenParams, limit * 2);
2503
+ LIMIT ?`).all(firstPrefix, firstWordSpace, firstWordComma, firstPrefix, firstSubstr, firstSubstr, ...tokenParams, limit * 2);
2482
2504
  }
2483
2505
  catch (e) {
2484
2506
  console.error(` [searchContacts] ranked query failed (${e?.message}) — falling back to simple LIKE`);
@@ -2499,23 +2521,38 @@ export class MailxDB {
2499
2521
  rows.sort((a, b) => score(b) - score(a));
2500
2522
  // Dedup by lowercased email — same address often appears as both
2501
2523
  // 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 => {
2524
+ // (auto-collected from sent mail). Keep the higher-ranked row (already
2525
+ // sorted first) and FOLD subsequent rows' sources into a `sources` array
2526
+ // on the survivor so the dropdown can display "google, discovered"
2527
+ // instead of showing two duplicate lines.
2528
+ const merged = new Map();
2529
+ for (const r of rows) {
2509
2530
  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 }));
2531
+ if (!k) {
2532
+ merged.set(`__noemail_${merged.size}`, { ...r, _sources: new Set([r.source]) });
2533
+ continue;
2534
+ }
2535
+ const existing = merged.get(k);
2536
+ if (!existing) {
2537
+ merged.set(k, { ...r, _sources: new Set([r.source]) });
2538
+ }
2539
+ else {
2540
+ existing._sources.add(r.source);
2541
+ // If this row has a non-empty name and the kept one didn't,
2542
+ // promote the name — Google rows often have proper names while
2543
+ // discovered rows are bare-email.
2544
+ if (!existing.name && r.name)
2545
+ existing.name = r.name;
2546
+ }
2547
+ }
2548
+ const out = Array.from(merged.values()).slice(0, limit);
2549
+ return out.map(r => ({
2550
+ name: r.name,
2551
+ email: r.email,
2552
+ source: r.source,
2553
+ sources: Array.from(r._sources).filter(Boolean),
2554
+ useCount: r.use_count,
2555
+ }));
2519
2556
  }
2520
2557
  /** List all contacts (address-book view) with pagination + optional filter. */
2521
2558
  listContacts(query, page = 1, pageSize = 100) {
@@ -2605,6 +2642,11 @@ export class MailxDB {
2605
2642
  const ts = Date.parse(s);
2606
2643
  return isNaN(ts) ? null : ts;
2607
2644
  };
2645
+ // Strip FTS5 metacharacters from a user term. A stray `;` `(` `:` `"`
2646
+ // etc. is a hard `fts5: syntax error` (Bob 2026-05-21 log). FTS5's
2647
+ // tokenizer splits on non-word chars anyway, so removing them loses no
2648
+ // real matching ability.
2649
+ const ftsClean = (t) => t.replace(/["';:(){}\[\]^~\\/]/g, "").trim();
2608
2650
  for (const part of parts) {
2609
2651
  const fromMatch = part.match(/^from:(.+)$/i);
2610
2652
  const toMatch = part.match(/^to:(.+)$/i);
@@ -2616,16 +2658,19 @@ export class MailxDB {
2616
2658
  const isMatch = part.match(/^is:(.+)$/i);
2617
2659
  const folderMatch = part.match(/^folder:(.+)$/i);
2618
2660
  if (fromMatch) {
2619
- const term = fromMatch[1].replace(/"/g, "");
2620
- ftsQuery += `(from_name:${term} OR from_address:${term}) `;
2661
+ const term = ftsClean(fromMatch[1]);
2662
+ if (term)
2663
+ ftsQuery += `(from_name:${term}* OR from_address:${term}*) `;
2621
2664
  }
2622
2665
  else if (toMatch) {
2623
- const term = toMatch[1].replace(/"/g, "");
2624
- ftsQuery += `(to_text:${term} OR cc_text:${term}) `;
2666
+ const term = ftsClean(toMatch[1]);
2667
+ if (term)
2668
+ ftsQuery += `(to_text:${term}* OR cc_text:${term}*) `;
2625
2669
  }
2626
2670
  else if (subjectMatch) {
2627
- const term = subjectMatch[1].replace(/"/g, "");
2628
- ftsQuery += `subject:${term} `;
2671
+ const term = ftsClean(subjectMatch[1]);
2672
+ if (term)
2673
+ ftsQuery += `subject:${term}* `;
2629
2674
  }
2630
2675
  else if (dateMatch || afterMatch || beforeMatch) {
2631
2676
  const op = dateMatch ? (dateMatch[1] || "=") : (afterMatch ? ">" : "<");
@@ -2679,22 +2724,32 @@ export class MailxDB {
2679
2724
  ftsQuery += `${part} `;
2680
2725
  }
2681
2726
  else {
2682
- // Unqualified — search everything.
2727
+ // Unqualified — search everything. Strip /regex-literal/
2728
+ // slashes and FTS5 metacharacters before wildcarding.
2683
2729
  let term = part.replace(/^\/|\/$/g, "");
2684
2730
  if (term.includes("|")) {
2685
- const alts = term.split("|").filter(Boolean).map(t => `${t}*`).join(" OR ");
2686
- ftsQuery += `(${alts}) `;
2731
+ const alts = term.split("|").map(t => ftsClean(t)).filter(Boolean).map(t => `${t}*`).join(" OR ");
2732
+ if (alts)
2733
+ ftsQuery += `(${alts}) `;
2687
2734
  }
2688
2735
  else {
2689
- ftsQuery += `${term}* `;
2736
+ term = ftsClean(term);
2737
+ if (term)
2738
+ ftsQuery += `${term}* `;
2690
2739
  }
2691
2740
  }
2692
2741
  }
2693
2742
  ftsQuery = ftsQuery.trim();
2694
- // If the user typed only qualifier-only terms (e.g. "is:flagged after:1w"),
2695
- // FTS query is empty match-all surrogate.
2696
- if (!ftsQuery)
2697
- ftsQuery = "*";
2743
+ // No real FTS term either qualifier-only ("is:flagged after:1w") or
2744
+ // every term sanitized away (user typed only punctuation). There is NO
2745
+ // valid FTS5 "match everything" string — `MATCH '*'` is itself an FTS5
2746
+ // error ("unknown special query"). So: with qualifiers, query the
2747
+ // messages table directly (no FTS join); with neither, the query is
2748
+ // garbage — return empty rather than dumping the whole mailbox.
2749
+ const hasFts = ftsQuery.length > 0;
2750
+ if (!hasFts && extraWhere.length === 0) {
2751
+ return { items: [], total: 0, page, pageSize };
2752
+ }
2698
2753
  const offset = (page - 1) * pageSize;
2699
2754
  try {
2700
2755
  let scopeWhere = "";
@@ -2719,6 +2774,13 @@ export class MailxDB {
2719
2774
  scopeWhere += " AND " + extraWhere.join(" AND ");
2720
2775
  scopeParams.push(...extraParams);
2721
2776
  }
2777
+ // FTS path joins messages_fts + MATCH; qualifier-only path skips
2778
+ // the FTS table entirely and filters `messages` directly.
2779
+ const fromJoin = hasFts
2780
+ ? "FROM messages m JOIN messages_fts fts ON m.id = fts.rowid LEFT JOIN folders f ON f.id = m.folder_id AND f.account_id = m.account_id"
2781
+ : "FROM messages m LEFT JOIN folders f ON f.id = m.folder_id AND f.account_id = m.account_id";
2782
+ const matchClause = hasFts ? "messages_fts MATCH ?" : "1=1";
2783
+ const matchParams = hasFts ? [ftsQuery] : [];
2722
2784
  // Cap COUNT at 1001 — a bare `SELECT COUNT(*)` on FTS5 has to
2723
2785
  // enumerate every match, which for a common term ("ieee", "the")
2724
2786
  // can be tens of thousands of rows and several seconds. We never
@@ -2726,19 +2788,15 @@ export class MailxDB {
2726
2788
  // count is invisible to the user and turns search from
2727
2789
  // multi-second to sub-second. UI shows "1000+" when cnt === 1001.
2728
2790
  const countRow = this.db.prepare(`SELECT COUNT(*) as cnt FROM (
2729
- SELECT 1 FROM messages m
2730
- JOIN messages_fts fts ON m.id = fts.rowid
2731
- LEFT JOIN folders f ON f.id = m.folder_id AND f.account_id = m.account_id
2732
- WHERE messages_fts MATCH ?${scopeWhere}
2791
+ SELECT 1 ${fromJoin}
2792
+ WHERE ${matchClause}${scopeWhere}
2733
2793
  LIMIT 1001
2734
- )`).get(ftsQuery, ...scopeParams);
2794
+ )`).get(...matchParams, ...scopeParams);
2735
2795
  const total = countRow?.cnt || 0;
2736
- const rows = this.db.prepare(`SELECT m.*, f.name AS folder_name FROM messages m
2737
- JOIN messages_fts fts ON m.id = fts.rowid
2738
- LEFT JOIN folders f ON f.id = m.folder_id AND f.account_id = m.account_id
2739
- WHERE messages_fts MATCH ?${scopeWhere}
2796
+ const rows = this.db.prepare(`SELECT m.*, f.name AS folder_name ${fromJoin}
2797
+ WHERE ${matchClause}${scopeWhere}
2740
2798
  ORDER BY m.date DESC
2741
- LIMIT ? OFFSET ?`).all(ftsQuery, ...scopeParams, pageSize, offset);
2799
+ LIMIT ? OFFSET ?`).all(...matchParams, ...scopeParams, pageSize, offset);
2742
2800
  const items = rows.map(r => ({
2743
2801
  id: r.id,
2744
2802
  accountId: r.account_id,
package/index.d.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  export { MailxDB } from "./db.js";
6
6
  export { FileMessageStore } from "./file-store.js";
7
7
  export { parseSerial, prewarmParseWorker } from "./parse-serial.js";
8
+ export { sniffAndFixCharset, fixCharsetDeclString } from "./charset.js";
8
9
  export { Store } from "./store.js";
9
10
  export type { StoreMessage } from "./store.js";
10
11
  export { StoreBus, storeBus } from "@bobfrankston/mailx-bus";
package/index.js CHANGED
@@ -5,6 +5,7 @@
5
5
  export { MailxDB } from "./db.js";
6
6
  export { FileMessageStore } from "./file-store.js";
7
7
  export { parseSerial, prewarmParseWorker } from "./parse-serial.js";
8
+ export { sniffAndFixCharset, fixCharsetDeclString } from "./charset.js";
8
9
  // Store — the nexus. Owns DB + .eml files + operations + bus.
9
10
  export { Store } from "./store.js";
10
11
  // Store-event bus lives in `@bobfrankston/mailx-bus` so the browser-side
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.34",
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) => {