@bobfrankston/mailx 1.0.336 → 1.0.338

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.338",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -2999,10 +2999,17 @@ export class ImapManager extends EventEmitter {
2999
2999
  // config.jsonc is per-machine / local-only — never polled.
3000
3000
  const cloudFiles = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc"];
3001
3001
  const CLOUD_POLL_MS = 3 * 60 * 1000;
3002
+ // Normalize before comparing: strip BOM, CRLF→LF, trailing whitespace.
3003
+ // Without this, cloud round-trips that re-wrap newlines or add a
3004
+ // trailing newline trigger a local overwrite every poll, which fires
3005
+ // fs.watch, which shows the spurious "accounts.jsonc changed" banner.
3006
+ const normalize = (s) => s.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/[ \t\r\n]+$/, "");
3002
3007
  const pollCloud = async () => {
3003
3008
  let cloudRead;
3009
+ let parseJsonc;
3004
3010
  try {
3005
3011
  ({ cloudRead } = await import("@bobfrankston/mailx-settings"));
3012
+ ({ parseJsonc } = await import("jsonc-parser").then(m => ({ parseJsonc: m.parse })));
3006
3013
  }
3007
3014
  catch {
3008
3015
  return; /* cloud module unavailable */
@@ -3018,17 +3025,25 @@ export class ImapManager extends EventEmitter {
3018
3025
  localContent = fs.readFileSync(localPath, "utf-8");
3019
3026
  }
3020
3027
  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.
3028
+ if (localContent !== null) {
3029
+ if (normalize(localContent) === normalize(cloudContent))
3030
+ continue;
3031
+ // Semantic check: parse both as JSONC and compare structures.
3032
+ // Catches reorderings that normalize() doesn't (e.g. JSON with
3033
+ // same keys in different order after a cloud-side re-serialize).
3034
+ try {
3035
+ const a = parseJsonc(localContent);
3036
+ const b = parseJsonc(cloudContent);
3037
+ if (a !== undefined && b !== undefined &&
3038
+ JSON.stringify(a) === JSON.stringify(b))
3039
+ continue;
3040
+ }
3041
+ catch { /* fall through to write */ }
3042
+ }
3026
3043
  fs.writeFileSync(localPath, cloudContent);
3027
3044
  console.log(` [cloud-poll] ${filename} updated from cloud copy`);
3028
3045
  }
3029
3046
  catch (e) {
3030
- // Drive unreachable, auth expired, file missing in cloud —
3031
- // silent retry on next tick; no user-visible fallout.
3032
3047
  console.log(` [cloud-poll] ${filename} check skipped: ${e?.message || e}`);
3033
3048
  }
3034
3049
  }
@@ -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
  }