@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 +26 -28
- package/package.json +1 -1
- package/packages/mailx-imap/index.js +22 -7
- package/packages/mailx-settings/index.js +5 -1
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
|
@@ -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
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
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
|
-
|
|
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
|
}
|