@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
- // Reply / Reply-All / Forward all need an original message to populate
486
- // From, To, Subject, and the quoted body. Two failure modes used to
487
- // silently produce a blank compose:
488
- // (1) getCurrentMessage() returns null viewer still loading, message
489
- // cleared mid-folder-switch, or fetch failed.
490
- // (2) currentMessage is set but is a stub header metadata arrived
491
- // but body / from / subject haven't been populated yet.
492
- // Bail out in both cases instead of opening an empty form.
493
- if (mode === "reply" || mode === "replyAll" || mode === "forward") {
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
- init.to = [msg.from, ...msg.to.filter((a) => a.address !== msg.from.address)];
567
- init.cc = msg.cc || [];
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.336",
3
+ "version": "1.0.339",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-host",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Host abstraction for mailx — dispatches to msger or msgview",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -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
- const existingPath = this.bodyStore.getMessagePath?.(accountId, folderId, uid);
1644
- if (existingPath)
1645
- this.db.updateBodyPath(accountId, uid, existingPath);
1646
- return this.bodyStore.getMessage(accountId, folderId, uid);
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
- console.log(` [watch] ${filename} changed`);
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 === cloudContent)
3022
- continue;
3023
- // Cloud copy differs — write through so watchers / downstream
3024
- // readers see the new value. fs.watch above will fire and
3025
- // emit configChanged UI banner.
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
- if (sharedContent !== localContent) {
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
  }