@bobfrankston/mailx 1.0.336 → 1.0.339
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/client/app.js
CHANGED
|
@@ -482,27 +482,15 @@ document.getElementById("btn-factory-reset")?.addEventListener("click", async ()
|
|
|
482
482
|
});
|
|
483
483
|
async function openCompose(mode) {
|
|
484
484
|
const current = getCurrentMessage();
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
const m = current?.message;
|
|
495
|
-
const stubReason = !current ? "no current message" :
|
|
496
|
-
!m?.from ? "msg.from missing" :
|
|
497
|
-
!m?.subject && m?.subject !== "" ? "msg.subject missing" :
|
|
498
|
-
(mode !== "forward" && !m?.messageId) ? "msg.messageId missing (can't thread reply)" :
|
|
499
|
-
null;
|
|
500
|
-
if (stubReason) {
|
|
501
|
-
console.warn(`[compose] ${mode} ignored — ${stubReason}; current=`, current);
|
|
502
|
-
alert(`Cannot ${mode === "forward" ? "forward" : "reply to"} this message yet — ` +
|
|
503
|
-
`it's still loading (${stubReason}). Please wait a moment and try again.`);
|
|
504
|
-
return;
|
|
505
|
-
}
|
|
485
|
+
// Local-first: if the row is selected we already have its headers in the
|
|
486
|
+
// local DB. Populate the compose form unconditionally; the user can edit
|
|
487
|
+
// anything missing. Don't show "still loading" alerts — the message IS
|
|
488
|
+
// loaded (it's in the list), body is a separate fetch that isn't needed
|
|
489
|
+
// for Reply's headers. Missing fields become empty strings.
|
|
490
|
+
if ((mode === "reply" || mode === "replyAll" || mode === "forward") && !current) {
|
|
491
|
+
// Only true blocker: no message selected at all.
|
|
492
|
+
console.warn(`[compose] ${mode} — no message selected`);
|
|
493
|
+
return;
|
|
506
494
|
}
|
|
507
495
|
const accounts = await getAccounts();
|
|
508
496
|
const accountId = current?.accountId || accounts[0]?.id || "";
|
|
@@ -554,21 +542,31 @@ async function openCompose(mode) {
|
|
|
554
542
|
console.log(`[compose] no identity match`);
|
|
555
543
|
return undefined;
|
|
556
544
|
}
|
|
545
|
+
// Defensive: msg.from / msg.to may be missing on rows that arrived before
|
|
546
|
+
// headers finished loading. Don't push undefined into init.to — that
|
|
547
|
+
// bubbles to the compose form as literal "undefined". Empty-out gracefully.
|
|
557
548
|
if (msg && mode === "reply") {
|
|
558
|
-
init.to = [msg.from];
|
|
549
|
+
init.to = msg.from ? [msg.from] : [];
|
|
559
550
|
init.subject = `Re: ${cleanSubject}`;
|
|
560
551
|
init.bodyHtml = quoteBody(msg);
|
|
561
|
-
init.inReplyTo = msg.messageId;
|
|
562
|
-
init.references = [...(msg.references || []), msg.messageId];
|
|
552
|
+
init.inReplyTo = msg.messageId || "";
|
|
553
|
+
init.references = [...(msg.references || []), msg.messageId].filter(Boolean);
|
|
563
554
|
init.fromAddress = detectReplyFrom();
|
|
564
555
|
}
|
|
565
556
|
else if (msg && mode === "replyAll") {
|
|
566
|
-
|
|
567
|
-
|
|
557
|
+
const toList = msg.from ? [msg.from] : [];
|
|
558
|
+
if (Array.isArray(msg.to)) {
|
|
559
|
+
for (const a of msg.to) {
|
|
560
|
+
if (a?.address && a.address !== msg.from?.address)
|
|
561
|
+
toList.push(a);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
init.to = toList;
|
|
565
|
+
init.cc = Array.isArray(msg.cc) ? msg.cc : [];
|
|
568
566
|
init.subject = `Re: ${cleanSubject}`;
|
|
569
567
|
init.bodyHtml = quoteBody(msg);
|
|
570
|
-
init.inReplyTo = msg.messageId;
|
|
571
|
-
init.references = [...(msg.references || []), msg.messageId];
|
|
568
|
+
init.inReplyTo = msg.messageId || "";
|
|
569
|
+
init.references = [...(msg.references || []), msg.messageId].filter(Boolean);
|
|
572
570
|
init.fromAddress = detectReplyFrom();
|
|
573
571
|
}
|
|
574
572
|
else if (msg && mode === "forward") {
|
package/package.json
CHANGED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Stub for `marked` — pulled in transitively via msger → msgcommon → markdown.ts.
|
|
2
|
+
// mailx-host doesn't use marked at runtime; the type chain only references it
|
|
3
|
+
// because msgcommon ships markdown.ts alongside its other modules. Without
|
|
4
|
+
// this shim, tsc errors with "Cannot find module 'marked'" while building
|
|
5
|
+
// mailx-host. The proper fix is `npm install` inside msgcommon (which has
|
|
6
|
+
// marked declared as a dep) or installing marked at the mailx root; this
|
|
7
|
+
// shim is a fallback so the build doesn't block on either.
|
|
8
|
+
declare module "marked" {
|
|
9
|
+
export const marked: {
|
|
10
|
+
parse(input: string, opts?: any): string | Promise<string>;
|
|
11
|
+
Renderer: any;
|
|
12
|
+
[k: string]: any;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -1640,10 +1640,42 @@ export class ImapManager extends EventEmitter {
|
|
|
1640
1640
|
// "gmail: 17266796 bodies cached" in the logs, which is the counter
|
|
1641
1641
|
// spinning on the same 100 rows.
|
|
1642
1642
|
if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1643
|
+
// COMINGLING GUARD: verify the cached body's Message-ID matches the
|
|
1644
|
+
// DB row's messageId. If UIDVALIDITY changed server-side (mailbox
|
|
1645
|
+
// recreated, server quirk) the same integer UID can point at a
|
|
1646
|
+
// different message — the on-disk .eml becomes stale but hasMessage()
|
|
1647
|
+
// still returns true. User-reported: "Peter Hoddie letter comingled
|
|
1648
|
+
// with a much older letter." Check fixes it regardless of root cause.
|
|
1649
|
+
const cached = await this.bodyStore.getMessage(accountId, folderId, uid);
|
|
1650
|
+
const envelope = this.db.getMessageByUid(accountId, uid, folderId);
|
|
1651
|
+
const expectedId = envelope?.messageId || "";
|
|
1652
|
+
if (expectedId) {
|
|
1653
|
+
// Scan headers only — Message-ID should land in the first few KB.
|
|
1654
|
+
const head = cached.subarray(0, Math.min(cached.length, 16 * 1024)).toString("utf-8");
|
|
1655
|
+
const m = head.match(/^Message-ID:\s*<([^>\r\n]+)>/im);
|
|
1656
|
+
const cachedId = m ? `<${m[1]}>` : "";
|
|
1657
|
+
if (cachedId && expectedId && cachedId !== expectedId) {
|
|
1658
|
+
console.error(` [body] COMINGLING DETECTED ${accountId}/${folderId}/${uid}: expected ${expectedId}, cached ${cachedId} — dropping cache, re-fetching`);
|
|
1659
|
+
try {
|
|
1660
|
+
await this.bodyStore.deleteMessage(accountId, folderId, uid);
|
|
1661
|
+
}
|
|
1662
|
+
catch { /* */ }
|
|
1663
|
+
// fall through to re-fetch path
|
|
1664
|
+
}
|
|
1665
|
+
else {
|
|
1666
|
+
const existingPath = this.bodyStore.getMessagePath?.(accountId, folderId, uid);
|
|
1667
|
+
if (existingPath)
|
|
1668
|
+
this.db.updateBodyPath(accountId, uid, existingPath);
|
|
1669
|
+
return cached;
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
else {
|
|
1673
|
+
// No messageId on the DB row (shouldn't happen but be permissive).
|
|
1674
|
+
const existingPath = this.bodyStore.getMessagePath?.(accountId, folderId, uid);
|
|
1675
|
+
if (existingPath)
|
|
1676
|
+
this.db.updateBodyPath(accountId, uid, existingPath);
|
|
1677
|
+
return cached;
|
|
1678
|
+
}
|
|
1647
1679
|
}
|
|
1648
1680
|
if (!this.configs.has(accountId))
|
|
1649
1681
|
return null;
|
|
@@ -2969,10 +3001,20 @@ export class ImapManager extends EventEmitter {
|
|
|
2969
3001
|
const files = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc"];
|
|
2970
3002
|
const configDir = getConfigDir();
|
|
2971
3003
|
const debounce = new Map();
|
|
3004
|
+
// Cache the last-seen normalized content per file. fs.watch fires on
|
|
3005
|
+
// metadata-only events (atime, attrib change) AND on no-op rewrites
|
|
3006
|
+
// that land identical bytes — both would fire a spurious banner.
|
|
3007
|
+
// Compare after the debounce window and only emit on a real change.
|
|
3008
|
+
const normalize = (s) => s.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/[ \t\r\n]+$/, "");
|
|
3009
|
+
const lastContent = new Map();
|
|
2972
3010
|
for (const filename of files) {
|
|
2973
3011
|
const full = path.join(configDir, filename);
|
|
2974
3012
|
if (!fs.existsSync(full))
|
|
2975
3013
|
continue;
|
|
3014
|
+
try {
|
|
3015
|
+
lastContent.set(filename, normalize(fs.readFileSync(full, "utf-8")));
|
|
3016
|
+
}
|
|
3017
|
+
catch { /* */ }
|
|
2976
3018
|
try {
|
|
2977
3019
|
const watcher = fs.watch(full, () => {
|
|
2978
3020
|
const prev = debounce.get(filename);
|
|
@@ -2980,7 +3022,32 @@ export class ImapManager extends EventEmitter {
|
|
|
2980
3022
|
clearTimeout(prev);
|
|
2981
3023
|
debounce.set(filename, setTimeout(() => {
|
|
2982
3024
|
debounce.delete(filename);
|
|
2983
|
-
|
|
3025
|
+
let current = "";
|
|
3026
|
+
try {
|
|
3027
|
+
current = normalize(fs.readFileSync(full, "utf-8"));
|
|
3028
|
+
}
|
|
3029
|
+
catch { /* missing */ }
|
|
3030
|
+
const previous = lastContent.get(filename) || "";
|
|
3031
|
+
if (current === previous) {
|
|
3032
|
+
console.log(` [watch] ${filename} fs.watch fired but content unchanged — no banner`);
|
|
3033
|
+
return;
|
|
3034
|
+
}
|
|
3035
|
+
// Log a short diff hint so repeat-firings are diagnosable.
|
|
3036
|
+
const prevSize = previous.length;
|
|
3037
|
+
const curSize = current.length;
|
|
3038
|
+
const firstDiff = (() => {
|
|
3039
|
+
const n = Math.min(prevSize, curSize);
|
|
3040
|
+
for (let i = 0; i < n; i++)
|
|
3041
|
+
if (previous[i] !== current[i])
|
|
3042
|
+
return i;
|
|
3043
|
+
return n;
|
|
3044
|
+
})();
|
|
3045
|
+
const prevSnip = previous.slice(Math.max(0, firstDiff - 20), firstDiff + 40).replace(/\n/g, "\\n");
|
|
3046
|
+
const curSnip = current.slice(Math.max(0, firstDiff - 20), firstDiff + 40).replace(/\n/g, "\\n");
|
|
3047
|
+
console.log(` [watch] ${filename} changed: size ${prevSize}→${curSize}, first diff at byte ${firstDiff}`);
|
|
3048
|
+
console.log(` [watch] was: ${JSON.stringify(prevSnip)}`);
|
|
3049
|
+
console.log(` [watch] now: ${JSON.stringify(curSnip)}`);
|
|
3050
|
+
lastContent.set(filename, current);
|
|
2984
3051
|
this.emit("configChanged", filename);
|
|
2985
3052
|
}, 500));
|
|
2986
3053
|
});
|
|
@@ -2999,10 +3066,15 @@ export class ImapManager extends EventEmitter {
|
|
|
2999
3066
|
// config.jsonc is per-machine / local-only — never polled.
|
|
3000
3067
|
const cloudFiles = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc"];
|
|
3001
3068
|
const CLOUD_POLL_MS = 3 * 60 * 1000;
|
|
3069
|
+
// normalize() reused from the fs.watch block above — same intent:
|
|
3070
|
+
// cloud round-trips that re-wrap newlines / add a trailing newline are
|
|
3071
|
+
// semantically identical; don't overwrite local on those.
|
|
3002
3072
|
const pollCloud = async () => {
|
|
3003
3073
|
let cloudRead;
|
|
3074
|
+
let parseJsonc;
|
|
3004
3075
|
try {
|
|
3005
3076
|
({ cloudRead } = await import("@bobfrankston/mailx-settings"));
|
|
3077
|
+
({ parseJsonc } = await import("jsonc-parser").then(m => ({ parseJsonc: m.parse })));
|
|
3006
3078
|
}
|
|
3007
3079
|
catch {
|
|
3008
3080
|
return; /* cloud module unavailable */
|
|
@@ -3018,17 +3090,25 @@ export class ImapManager extends EventEmitter {
|
|
|
3018
3090
|
localContent = fs.readFileSync(localPath, "utf-8");
|
|
3019
3091
|
}
|
|
3020
3092
|
catch { /* missing */ }
|
|
3021
|
-
if (localContent
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3093
|
+
if (localContent !== null) {
|
|
3094
|
+
if (normalize(localContent) === normalize(cloudContent))
|
|
3095
|
+
continue;
|
|
3096
|
+
// Semantic check: parse both as JSONC and compare structures.
|
|
3097
|
+
// Catches reorderings that normalize() doesn't (e.g. JSON with
|
|
3098
|
+
// same keys in different order after a cloud-side re-serialize).
|
|
3099
|
+
try {
|
|
3100
|
+
const a = parseJsonc(localContent);
|
|
3101
|
+
const b = parseJsonc(cloudContent);
|
|
3102
|
+
if (a !== undefined && b !== undefined &&
|
|
3103
|
+
JSON.stringify(a) === JSON.stringify(b))
|
|
3104
|
+
continue;
|
|
3105
|
+
}
|
|
3106
|
+
catch { /* fall through to write */ }
|
|
3107
|
+
}
|
|
3026
3108
|
fs.writeFileSync(localPath, cloudContent);
|
|
3027
3109
|
console.log(` [cloud-poll] ${filename} updated from cloud copy`);
|
|
3028
3110
|
}
|
|
3029
3111
|
catch (e) {
|
|
3030
|
-
// Drive unreachable, auth expired, file missing in cloud —
|
|
3031
|
-
// silent retry on next tick; no user-visible fallout.
|
|
3032
3112
|
console.log(` [cloud-poll] ${filename} check skipped: ${e?.message || e}`);
|
|
3033
3113
|
}
|
|
3034
3114
|
}
|
|
@@ -453,7 +453,11 @@ export function loadAccounts() {
|
|
|
453
453
|
localContent = fs.readFileSync(localPath, "utf-8");
|
|
454
454
|
}
|
|
455
455
|
catch { /* missing */ }
|
|
456
|
-
|
|
456
|
+
// Normalize before comparing — GDrive-mounted copies often
|
|
457
|
+
// differ in BOM / line endings / trailing newline without any
|
|
458
|
+
// semantic change, and that triggered the spurious banner.
|
|
459
|
+
const norm = (s) => s.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/[ \t\r\n]+$/, "");
|
|
460
|
+
if (norm(sharedContent) !== norm(localContent)) {
|
|
457
461
|
fs.mkdirSync(LOCAL_DIR, { recursive: true });
|
|
458
462
|
fs.writeFileSync(localPath, sharedContent);
|
|
459
463
|
}
|