@bobfrankston/mailx-store 0.1.31 → 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.
- package/charset.js +20 -0
- package/db.d.ts +1 -0
- package/db.js +80 -30
- package/package.json +3 -3
- 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
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
|
|
2469
|
-
WHEN substr(email, 1, instr(email, '@') - 1) LIKE lower(?) THEN
|
|
2470
|
-
WHEN email LIKE ? OR name LIKE ? THEN
|
|
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).
|
|
2503
|
-
//
|
|
2504
|
-
//
|
|
2505
|
-
//
|
|
2506
|
-
|
|
2507
|
-
const
|
|
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
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
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]
|
|
2620
|
-
|
|
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]
|
|
2624
|
-
|
|
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]
|
|
2628
|
-
|
|
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 ? ">" : "<");
|
|
@@ -2670,15 +2708,27 @@ export class MailxDB {
|
|
|
2670
2708
|
extraWhere.push("LOWER(f.name) LIKE ?");
|
|
2671
2709
|
extraParams.push(`%${v.toLowerCase()}%`);
|
|
2672
2710
|
}
|
|
2711
|
+
else if (/^(AND|OR|NOT)$/.test(part)) {
|
|
2712
|
+
// FTS5 boolean operators — pass through verbatim (must be uppercase).
|
|
2713
|
+
// Without this branch, `hoddie AND git` got wildcarded into
|
|
2714
|
+
// `hoddie* AND* git*`, which FTS5 reads as three required terms
|
|
2715
|
+
// (one of them being any word starting with "AND") — so a real
|
|
2716
|
+
// match like "Peter Hoddie" + "github" returned zero hits.
|
|
2717
|
+
ftsQuery += `${part} `;
|
|
2718
|
+
}
|
|
2673
2719
|
else {
|
|
2674
|
-
// Unqualified — search everything.
|
|
2720
|
+
// Unqualified — search everything. Strip /regex-literal/
|
|
2721
|
+
// slashes and FTS5 metacharacters before wildcarding.
|
|
2675
2722
|
let term = part.replace(/^\/|\/$/g, "");
|
|
2676
2723
|
if (term.includes("|")) {
|
|
2677
|
-
const alts = term.split("|").filter(Boolean).map(t => `${t}*`).join(" OR ");
|
|
2678
|
-
|
|
2724
|
+
const alts = term.split("|").map(t => ftsClean(t)).filter(Boolean).map(t => `${t}*`).join(" OR ");
|
|
2725
|
+
if (alts)
|
|
2726
|
+
ftsQuery += `(${alts}) `;
|
|
2679
2727
|
}
|
|
2680
2728
|
else {
|
|
2681
|
-
|
|
2729
|
+
term = ftsClean(term);
|
|
2730
|
+
if (term)
|
|
2731
|
+
ftsQuery += `${term}* `;
|
|
2682
2732
|
}
|
|
2683
2733
|
}
|
|
2684
2734
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-store",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.33",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"license": "ISC",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@bobfrankston/mailx-types": "^0.1.18",
|
|
13
|
-
"@bobfrankston/mailx-settings": "^0.1.
|
|
13
|
+
"@bobfrankston/mailx-settings": "^0.1.22",
|
|
14
14
|
"@bobfrankston/mailx-bus": "^0.1.2",
|
|
15
15
|
"mailparser": "^3.7.2"
|
|
16
16
|
},
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
".transformedSnapshot": {
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@bobfrankston/mailx-types": "^0.1.18",
|
|
33
|
-
"@bobfrankston/mailx-settings": "^0.1.
|
|
33
|
+
"@bobfrankston/mailx-settings": "^0.1.22",
|
|
34
34
|
"@bobfrankston/mailx-bus": "^0.1.2",
|
|
35
35
|
"mailparser": "^3.7.2"
|
|
36
36
|
}
|
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) => {
|