@bobfrankston/mailx-store 0.1.33 → 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 +47 -25
- package/db.js +32 -16
- package/index.d.ts +1 -0
- package/index.js +1 -0
- package/package.json +1 -1
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,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
|
-
*
|
|
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
|
-
// 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
|
-
|
|
33
|
-
|
|
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(
|
|
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
|
|
@@ -2733,10 +2740,16 @@ export class MailxDB {
|
|
|
2733
2740
|
}
|
|
2734
2741
|
}
|
|
2735
2742
|
ftsQuery = ftsQuery.trim();
|
|
2736
|
-
//
|
|
2737
|
-
//
|
|
2738
|
-
|
|
2739
|
-
|
|
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
|
+
}
|
|
2740
2753
|
const offset = (page - 1) * pageSize;
|
|
2741
2754
|
try {
|
|
2742
2755
|
let scopeWhere = "";
|
|
@@ -2761,6 +2774,13 @@ export class MailxDB {
|
|
|
2761
2774
|
scopeWhere += " AND " + extraWhere.join(" AND ");
|
|
2762
2775
|
scopeParams.push(...extraParams);
|
|
2763
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] : [];
|
|
2764
2784
|
// Cap COUNT at 1001 — a bare `SELECT COUNT(*)` on FTS5 has to
|
|
2765
2785
|
// enumerate every match, which for a common term ("ieee", "the")
|
|
2766
2786
|
// can be tens of thousands of rows and several seconds. We never
|
|
@@ -2768,19 +2788,15 @@ export class MailxDB {
|
|
|
2768
2788
|
// count is invisible to the user and turns search from
|
|
2769
2789
|
// multi-second to sub-second. UI shows "1000+" when cnt === 1001.
|
|
2770
2790
|
const countRow = this.db.prepare(`SELECT COUNT(*) as cnt FROM (
|
|
2771
|
-
SELECT 1
|
|
2772
|
-
|
|
2773
|
-
LEFT JOIN folders f ON f.id = m.folder_id AND f.account_id = m.account_id
|
|
2774
|
-
WHERE messages_fts MATCH ?${scopeWhere}
|
|
2791
|
+
SELECT 1 ${fromJoin}
|
|
2792
|
+
WHERE ${matchClause}${scopeWhere}
|
|
2775
2793
|
LIMIT 1001
|
|
2776
|
-
)`).get(
|
|
2794
|
+
)`).get(...matchParams, ...scopeParams);
|
|
2777
2795
|
const total = countRow?.cnt || 0;
|
|
2778
|
-
const rows = this.db.prepare(`SELECT m.*, f.name AS folder_name
|
|
2779
|
-
|
|
2780
|
-
LEFT JOIN folders f ON f.id = m.folder_id AND f.account_id = m.account_id
|
|
2781
|
-
WHERE messages_fts MATCH ?${scopeWhere}
|
|
2796
|
+
const rows = this.db.prepare(`SELECT m.*, f.name AS folder_name ${fromJoin}
|
|
2797
|
+
WHERE ${matchClause}${scopeWhere}
|
|
2782
2798
|
ORDER BY m.date DESC
|
|
2783
|
-
LIMIT ? OFFSET ?`).all(
|
|
2799
|
+
LIMIT ? OFFSET ?`).all(...matchParams, ...scopeParams, pageSize, offset);
|
|
2784
2800
|
const items = rows.map(r => ({
|
|
2785
2801
|
id: r.id,
|
|
2786
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
|