@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 +23 -9
- package/charset.js +57 -15
- package/db.d.ts +1 -0
- package/db.js +104 -46
- package/index.d.ts +1 -0
- package/index.js +1 -0
- package/package.json +1 -1
- package/store.js +30 -7
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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
|
|
18
|
-
if (!
|
|
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
|
-
|
|
23
|
-
|
|
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
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(
|
|
34
|
+
return _libmime.decodeWords(fixed);
|
|
28
35
|
}
|
|
29
36
|
catch {
|
|
30
|
-
return
|
|
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
|
|
2469
|
-
WHEN substr(email, 1, instr(email, '@') - 1) LIKE lower(?) THEN
|
|
2470
|
-
WHEN email LIKE ? OR name LIKE ? THEN
|
|
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).
|
|
2503
|
-
//
|
|
2504
|
-
//
|
|
2505
|
-
//
|
|
2506
|
-
|
|
2507
|
-
const
|
|
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
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
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]
|
|
2620
|
-
|
|
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]
|
|
2624
|
-
|
|
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]
|
|
2628
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2736
|
+
term = ftsClean(term);
|
|
2737
|
+
if (term)
|
|
2738
|
+
ftsQuery += `${term}* `;
|
|
2690
2739
|
}
|
|
2691
2740
|
}
|
|
2692
2741
|
}
|
|
2693
2742
|
ftsQuery = ftsQuery.trim();
|
|
2694
|
-
//
|
|
2695
|
-
//
|
|
2696
|
-
|
|
2697
|
-
|
|
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
|
|
2730
|
-
|
|
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(
|
|
2794
|
+
)`).get(...matchParams, ...scopeParams);
|
|
2735
2795
|
const total = countRow?.cnt || 0;
|
|
2736
|
-
const rows = this.db.prepare(`SELECT m.*, f.name AS folder_name
|
|
2737
|
-
|
|
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(
|
|
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
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
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
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[
|
|
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) => {
|