@bobfrankston/mailx-store 0.1.33 → 0.1.35

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,36 +1,58 @@
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
- // 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;
30
- if (!isValidUtf8(raw))
24
+ const legacyRe = /charset\s*=\s*"?(iso-8859-1|us-ascii|windows-1252|latin1)"?/gi;
25
+ if (!legacyRe.test(head))
31
26
  return raw;
32
- const fixed = head.replace(/charset\s*=\s*"?(iso-8859-1|us-ascii|windows-1252|latin1)"?/gi, "charset=utf-8");
33
- 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");
34
56
  }
35
57
  /** True if the buffer contains at least one byte >= 0x80. A pure-ASCII
36
58
  * buffer is trivially valid UTF-8, so the isValidUtf8 sniff tells us
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
@@ -2726,17 +2733,37 @@ export class MailxDB {
2726
2733
  ftsQuery += `(${alts}) `;
2727
2734
  }
2728
2735
  else {
2729
- term = ftsClean(term);
2730
- if (term)
2731
- ftsQuery += `${term}* `;
2736
+ // Match the FTS5 unicode61 tokenizer: split on any
2737
+ // non-(letter|number|underscore) so the user's term
2738
+ // shapes the same way the index did. Without this,
2739
+ // typing "192." built "192.*" and matched nothing —
2740
+ // FTS5 had indexed "192.55.226" as the three tokens
2741
+ // 192/55/226 (Bob 2026-05-22 "type 192. it no longer
2742
+ // matches"). AND the sub-tokens (FTS5 implicit AND
2743
+ // between space-separated terms); only the LAST gets
2744
+ // a `*` so incremental typing keeps narrowing.
2745
+ const sub = ftsClean(term).split(/[^\p{L}\p{N}_]+/u).filter(Boolean);
2746
+ if (sub.length === 1) {
2747
+ ftsQuery += `${sub[0]}* `;
2748
+ }
2749
+ else if (sub.length > 1) {
2750
+ const last = sub.pop();
2751
+ ftsQuery += `${sub.join(" ")} ${last}* `;
2752
+ }
2732
2753
  }
2733
2754
  }
2734
2755
  }
2735
2756
  ftsQuery = ftsQuery.trim();
2736
- // If the user typed only qualifier-only terms (e.g. "is:flagged after:1w"),
2737
- // FTS query is empty match-all surrogate.
2738
- if (!ftsQuery)
2739
- ftsQuery = "*";
2757
+ // No real FTS term either qualifier-only ("is:flagged after:1w") or
2758
+ // every term sanitized away (user typed only punctuation). There is NO
2759
+ // valid FTS5 "match everything" string — `MATCH '*'` is itself an FTS5
2760
+ // error ("unknown special query"). So: with qualifiers, query the
2761
+ // messages table directly (no FTS join); with neither, the query is
2762
+ // garbage — return empty rather than dumping the whole mailbox.
2763
+ const hasFts = ftsQuery.length > 0;
2764
+ if (!hasFts && extraWhere.length === 0) {
2765
+ return { items: [], total: 0, page, pageSize };
2766
+ }
2740
2767
  const offset = (page - 1) * pageSize;
2741
2768
  try {
2742
2769
  let scopeWhere = "";
@@ -2761,6 +2788,13 @@ export class MailxDB {
2761
2788
  scopeWhere += " AND " + extraWhere.join(" AND ");
2762
2789
  scopeParams.push(...extraParams);
2763
2790
  }
2791
+ // FTS path joins messages_fts + MATCH; qualifier-only path skips
2792
+ // the FTS table entirely and filters `messages` directly.
2793
+ const fromJoin = hasFts
2794
+ ? "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"
2795
+ : "FROM messages m LEFT JOIN folders f ON f.id = m.folder_id AND f.account_id = m.account_id";
2796
+ const matchClause = hasFts ? "messages_fts MATCH ?" : "1=1";
2797
+ const matchParams = hasFts ? [ftsQuery] : [];
2764
2798
  // Cap COUNT at 1001 — a bare `SELECT COUNT(*)` on FTS5 has to
2765
2799
  // enumerate every match, which for a common term ("ieee", "the")
2766
2800
  // can be tens of thousands of rows and several seconds. We never
@@ -2768,19 +2802,15 @@ export class MailxDB {
2768
2802
  // count is invisible to the user and turns search from
2769
2803
  // multi-second to sub-second. UI shows "1000+" when cnt === 1001.
2770
2804
  const countRow = this.db.prepare(`SELECT COUNT(*) as cnt FROM (
2771
- SELECT 1 FROM messages m
2772
- JOIN messages_fts fts ON m.id = fts.rowid
2773
- LEFT JOIN folders f ON f.id = m.folder_id AND f.account_id = m.account_id
2774
- WHERE messages_fts MATCH ?${scopeWhere}
2805
+ SELECT 1 ${fromJoin}
2806
+ WHERE ${matchClause}${scopeWhere}
2775
2807
  LIMIT 1001
2776
- )`).get(ftsQuery, ...scopeParams);
2808
+ )`).get(...matchParams, ...scopeParams);
2777
2809
  const total = countRow?.cnt || 0;
2778
- const rows = this.db.prepare(`SELECT m.*, f.name AS folder_name FROM messages m
2779
- JOIN messages_fts fts ON m.id = fts.rowid
2780
- LEFT JOIN folders f ON f.id = m.folder_id AND f.account_id = m.account_id
2781
- WHERE messages_fts MATCH ?${scopeWhere}
2810
+ const rows = this.db.prepare(`SELECT m.*, f.name AS folder_name ${fromJoin}
2811
+ WHERE ${matchClause}${scopeWhere}
2782
2812
  ORDER BY m.date DESC
2783
- LIMIT ? OFFSET ?`).all(ftsQuery, ...scopeParams, pageSize, offset);
2813
+ LIMIT ? OFFSET ?`).all(...matchParams, ...scopeParams, pageSize, offset);
2784
2814
  const items = rows.map(r => ({
2785
2815
  id: r.id,
2786
2816
  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.33",
3
+ "version": "0.1.35",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",