@bobfrankston/mailx 1.0.442 → 1.0.444

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/README.md CHANGED
@@ -258,6 +258,10 @@ mailx -repair Re-sync message metadata (fix garbled subjects)
258
258
  mailx -rebuild Wipe local cache and re-download everything from IMAP
259
259
  mailx -setup Interactive first-time setup (CLI)
260
260
  mailx -test Test IMAP/SMTP connectivity for all accounts
261
+ mailx -reauth Clear cached OAuth tokens; next start re-consents
262
+ mailx -lean-accounts Strip defaulted fields from accounts.jsonc on
263
+ Google Drive (port, tls, auth, enabled, ...).
264
+ Add --dry-run to preview without writing.
261
265
  mailx -v Show version
262
266
  ```
263
267
 
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lean-accounts.js — strip default-valued fields from an accounts.jsonc.
4
+ *
5
+ * Reads any accounts.jsonc (the one mailx writes when it bloats it with
6
+ * port: 993, tls: true, auth: "password", enabled: true, sig.html: false,
7
+ * etc.) and emits a compact form. Same logic as the Lean button in the
8
+ * config editor, but standalone so it works without mailx running.
9
+ *
10
+ * Usage:
11
+ * node lean-accounts.js <path> prints lean output to stdout
12
+ * node lean-accounts.js <path> --inplace overwrites the file
13
+ *
14
+ * Drops:
15
+ * - imap/smtp host/port/tls/auth that match the email's provider defaults
16
+ * - imap/smtp user when it equals the email
17
+ * - enabled: true (default)
18
+ * - sig.html: false (default)
19
+ * - per-account name when it matches the file-level "name"
20
+ *
21
+ * Promotes a shared "name" to file-level when every account has the same.
22
+ *
23
+ * Requires: jsonc-parser (any version that exports `parse` with comment+
24
+ * trailing-comma tolerance). The mailx repo's node_modules has it.
25
+ */
26
+
27
+ import fs from "node:fs";
28
+ import path from "node:path";
29
+ import { fileURLToPath, pathToFileURL } from "node:url";
30
+
31
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
32
+
33
+ // ── Provider defaults: same set mailx-settings uses ────────────────────────
34
+ const PROVIDERS = {
35
+ "gmail.com": {
36
+ label: "Gmail",
37
+ imap: { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2" },
38
+ smtp: { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2" },
39
+ },
40
+ "googlemail.com": {
41
+ label: "Gmail",
42
+ imap: { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2" },
43
+ smtp: { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2" },
44
+ },
45
+ "outlook.com": {
46
+ label: "Outlook",
47
+ imap: { host: "outlook.office365.com", port: 993, tls: true, auth: "oauth2" },
48
+ smtp: { host: "smtp.office365.com", port: 587, tls: true, auth: "oauth2" },
49
+ },
50
+ "hotmail.com": {
51
+ label: "Outlook",
52
+ imap: { host: "outlook.office365.com", port: 993, tls: true, auth: "oauth2" },
53
+ smtp: { host: "smtp.office365.com", port: 587, tls: true, auth: "oauth2" },
54
+ },
55
+ "yahoo.com": {
56
+ label: "Yahoo",
57
+ imap: { host: "imap.mail.yahoo.com", port: 993, tls: true, auth: "oauth2" },
58
+ smtp: { host: "smtp.mail.yahoo.com", port: 587, tls: true, auth: "oauth2" },
59
+ },
60
+ "icloud.com": {
61
+ label: "iCloud",
62
+ imap: { host: "imap.mail.me.com", port: 993, tls: true, auth: "oauth2" },
63
+ smtp: { host: "smtp.mail.me.com", port: 587, tls: true, auth: "oauth2" },
64
+ },
65
+ };
66
+
67
+ function denormalizeAccount(acct, globalName) {
68
+ const domain = (acct.email || "").split("@")[1]?.toLowerCase() || "";
69
+ const provider = PROVIDERS[domain];
70
+ const out = {};
71
+ out.id = acct.id;
72
+ if (acct.label && acct.label !== provider?.label && acct.label !== acct.id) out.label = acct.label;
73
+ out.email = acct.email;
74
+ if (acct.name && acct.name !== globalName) out.name = acct.name;
75
+ if (acct.primary) out.primary = true;
76
+ if (acct.primaryCalendar !== undefined) out.primaryCalendar = acct.primaryCalendar;
77
+ if (acct.primaryTasks !== undefined) out.primaryTasks = acct.primaryTasks;
78
+ if (acct.primaryContacts !== undefined) out.primaryContacts = acct.primaryContacts;
79
+
80
+ const imapOut = {};
81
+ if (acct.imap?.host && acct.imap.host !== provider?.imap.host) imapOut.host = acct.imap.host;
82
+ if (acct.imap?.user && acct.imap.user !== acct.email) imapOut.user = acct.imap.user;
83
+ if (acct.imap?.password) imapOut.password = acct.imap.password;
84
+ if (acct.imap?.port && acct.imap.port !== (provider?.imap.port ?? 993)) imapOut.port = acct.imap.port;
85
+ if (acct.imap?.tls !== undefined && acct.imap.tls !== (provider?.imap.tls ?? true)) imapOut.tls = acct.imap.tls;
86
+ if (acct.imap?.auth && acct.imap.auth !== (provider?.imap.auth ?? "password")) imapOut.auth = acct.imap.auth;
87
+ if (Object.keys(imapOut).length > 0) out.imap = imapOut;
88
+
89
+ const smtpOut = {};
90
+ if (acct.smtp?.host && acct.smtp.host !== provider?.smtp.host) smtpOut.host = acct.smtp.host;
91
+ if (acct.smtp?.user && acct.smtp.user !== acct.email && acct.smtp.user !== acct.imap?.user) smtpOut.user = acct.smtp.user;
92
+ if (acct.smtp?.password) smtpOut.password = acct.smtp.password;
93
+ if (acct.smtp?.port && acct.smtp.port !== (provider?.smtp.port ?? 587)) smtpOut.port = acct.smtp.port;
94
+ if (acct.smtp?.tls !== undefined && acct.smtp.tls !== (provider?.smtp.tls ?? true)) smtpOut.tls = acct.smtp.tls;
95
+ if (acct.smtp?.auth && acct.smtp.auth !== (provider?.smtp.auth ?? "password")) smtpOut.auth = acct.smtp.auth;
96
+ if (Object.keys(smtpOut).length > 0) out.smtp = smtpOut;
97
+
98
+ if (acct.defaultSend) out.defaultSend = true;
99
+ if (acct.enabled === false) out.enabled = false;
100
+ if (acct.relayDomains?.length > 0) out.relayDomains = acct.relayDomains;
101
+ if (acct.deliveredToPrefix?.length > 0) out.deliveredToPrefix = acct.deliveredToPrefix;
102
+ if (acct.identityDomains?.length > 0) out.identityDomains = acct.identityDomains;
103
+
104
+ const syncContactsDefault = provider?.imap.auth === "oauth2";
105
+ if (acct.syncContacts !== undefined && acct.syncContacts !== syncContactsDefault) {
106
+ out.syncContacts = acct.syncContacts;
107
+ }
108
+ if (acct.signature) out.signature = acct.signature;
109
+ if (acct.sig?.text) {
110
+ out.sig = acct.sig.html ? { text: acct.sig.text, html: true } : { text: acct.sig.text };
111
+ }
112
+ return out;
113
+ }
114
+
115
+ async function main() {
116
+ const args = process.argv.slice(2);
117
+ const inputPath = args.find(a => !a.startsWith("--"));
118
+ const inplace = args.includes("--inplace");
119
+ if (!inputPath) {
120
+ console.error("Usage: node lean-accounts.js <path-to-accounts.jsonc> [--inplace]");
121
+ process.exit(2);
122
+ }
123
+ if (!fs.existsSync(inputPath)) {
124
+ console.error(`File not found: ${inputPath}`);
125
+ process.exit(1);
126
+ }
127
+
128
+ // Resolve jsonc-parser from the local mailx repo's node_modules so the
129
+ // script doesn't require a global install. The repo root is two levels
130
+ // up from bin/ — and we walk up further if invoked from elsewhere.
131
+ let parseJsonc;
132
+ for (const dir of [
133
+ path.join(__dirname, "..", "node_modules", "jsonc-parser"),
134
+ path.join(__dirname, "..", "..", "node_modules", "jsonc-parser"),
135
+ path.join(process.cwd(), "node_modules", "jsonc-parser"),
136
+ ]) {
137
+ if (fs.existsSync(path.join(dir, "package.json"))) {
138
+ const mod = await import(pathToFileURL(path.join(dir, "lib", "esm", "main.js")).href).catch(() => null) ||
139
+ await import("jsonc-parser").catch(() => null);
140
+ if (mod) { parseJsonc = mod.parse; break; }
141
+ }
142
+ }
143
+ if (!parseJsonc) {
144
+ try { parseJsonc = (await import("jsonc-parser")).parse; }
145
+ catch { console.error("jsonc-parser not found. Install or run from inside the mailx repo."); process.exit(1); }
146
+ }
147
+
148
+ const raw = fs.readFileSync(inputPath, "utf-8");
149
+ const errors = [];
150
+ const cfg = parseJsonc(raw, errors, { allowTrailingComma: true });
151
+ if (errors.length) {
152
+ console.error(`JSONC parse error: ${errors.map(e => JSON.stringify(e)).join(", ")}`);
153
+ process.exit(1);
154
+ }
155
+
156
+ const accounts = cfg?.accounts || (Array.isArray(cfg) ? cfg : []);
157
+ // Promote shared name to file-level when every entry has the same.
158
+ const names = new Set(accounts.map(a => a.name).filter(Boolean));
159
+ const sharedName = names.size === 1 ? [...names][0] : cfg?.name;
160
+ const lean = accounts.map(a => denormalizeAccount(a, sharedName));
161
+ const payload = sharedName ? { name: sharedName, accounts: lean } : { accounts: lean };
162
+ const output = JSON.stringify(payload, null, 2) + "\n";
163
+
164
+ if (inplace) {
165
+ fs.writeFileSync(inputPath, output);
166
+ console.error(`Wrote lean ${inputPath}`);
167
+ } else {
168
+ process.stdout.write(output);
169
+ }
170
+ }
171
+
172
+ main().catch(e => { console.error(e); process.exit(1); });
package/bin/mailx.js CHANGED
@@ -17,6 +17,9 @@
17
17
  * mailx -repair Re-sync metadata (fix corrupt subjects) keeping .eml files
18
18
  * mailx -reauth Clear cached OAuth tokens; next start re-consents
19
19
  * (use when new Google scopes have been added)
20
+ * mailx -lean-accounts Strip defaulted fields from the GDrive copy of
21
+ * accounts.jsonc (port, tls, auth, enabled, ...).
22
+ * Add --dry-run to preview without writing.
20
23
  */
21
24
  import fs from "node:fs";
22
25
  import path from "node:path";
@@ -89,7 +92,7 @@ function pidAlive(pid) {
89
92
  // on an old UI with no indication that the install has been upgraded.
90
93
  // Skip this logic for command-only flags (kill, rebuild, setup, ...) and for
91
94
  // the internal --daemon respawn.
92
- const __commandFlags = ["kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "import", "log", "reauth"];
95
+ const __commandFlags = ["kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "import", "log", "reauth", "lean-accounts"];
93
96
  const __isCommandInvocation = process.argv.slice(2).some(a => __commandFlags.includes(a.replace(/^--?/, "")));
94
97
  if (!isDaemon && !__isCommandInvocation) {
95
98
  const inst = readInstanceFile();
@@ -147,8 +150,9 @@ const testMode = hasFlag("test");
147
150
  const rebuildMode = hasFlag("rebuild");
148
151
  const repairMode = hasFlag("repair");
149
152
  const importMode = hasFlag("import");
153
+ const leanAccountsMode = hasFlag("lean-accounts");
150
154
  // Validate arguments
151
- const knownFlags = ["verbose", "kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "log", "import", "email", "mail", "daemon"];
155
+ const knownFlags = ["verbose", "kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "log", "import", "lean-accounts", "email", "mail", "daemon"];
152
156
  for (const arg of args) {
153
157
  const flag = arg.replace(/^--?/, "");
154
158
  if (arg.startsWith("-") && !knownFlags.includes(flag)) {
@@ -363,6 +367,52 @@ if (repairMode) {
363
367
  console.log(" Run 'mailx' to re-sync from IMAP with correct encoding.");
364
368
  process.exit(0);
365
369
  }
370
+ // Strip default-valued fields from the GDrive copy of accounts.jsonc.
371
+ // Operates on the cloud version directly — the local ~/.mailx/accounts.jsonc
372
+ // is just a cache and gets updated on the next mailx run anyway.
373
+ if (leanAccountsMode) {
374
+ const { parse: parseJsonc } = await import("jsonc-parser");
375
+ const { initCloudConfig, normalizeAccount, denormalizeAccount } = await import("@bobfrankston/mailx-settings");
376
+ const { cloudRead, cloudWrite } = await import("@bobfrankston/mailx-settings");
377
+ await initCloudConfig("gdrive");
378
+ const raw = await cloudRead("accounts.jsonc");
379
+ if (!raw) {
380
+ console.error("No accounts.jsonc found in GDrive.");
381
+ process.exit(1);
382
+ }
383
+ const errors = [];
384
+ const cfg = parseJsonc(raw, errors, { allowTrailingComma: true });
385
+ if (errors.length) {
386
+ console.error(`JSONC parse error: ${errors.map((e) => JSON.stringify(e)).join(", ")}`);
387
+ process.exit(1);
388
+ }
389
+ const accountsRaw = cfg?.accounts || (Array.isArray(cfg) ? cfg : []);
390
+ if (accountsRaw.length === 0) {
391
+ console.error("No accounts found in GDrive accounts.jsonc.");
392
+ process.exit(1);
393
+ }
394
+ const globalName = cfg?.name;
395
+ const normalized = accountsRaw.map(a => normalizeAccount(a, globalName));
396
+ const names = new Set(normalized.map((a) => a.name).filter(Boolean));
397
+ const sharedName = names.size === 1 ? [...names][0] : globalName;
398
+ const lean = normalized.map((a) => denormalizeAccount(a, sharedName));
399
+ const payload = sharedName ? { name: sharedName, accounts: lean } : { accounts: lean };
400
+ const output = JSON.stringify(payload, null, 2);
401
+ const before = raw.length;
402
+ const after = output.length;
403
+ console.log(`Read ${accountsRaw.length} account(s) from GDrive.`);
404
+ console.log(`Before: ${before} bytes`);
405
+ console.log(`After: ${after} bytes (${Math.round(100 * (1 - after / before))}% smaller)`);
406
+ if (process.argv.includes("--dry-run") || process.argv.includes("-n")) {
407
+ console.log("--- Lean output (dry run, not written) ---");
408
+ console.log(output);
409
+ }
410
+ else {
411
+ await cloudWrite("accounts.jsonc", output);
412
+ console.log("Wrote lean accounts.jsonc back to GDrive.");
413
+ }
414
+ process.exit(0);
415
+ }
366
416
  // Import accounts from a local file into GDrive
367
417
  if (importMode) {
368
418
  const importPath = args.find(a => !a.startsWith("-"));
package/client/app.js CHANGED
@@ -696,9 +696,17 @@ async function openCompose(mode) {
696
696
  sessionStorage.setItem("composeInit", JSON.stringify(init));
697
697
  // Inline compose: load compose.html in an overlay iframe (same origin, same IPC)
698
698
  // Popup windows don't work in IPC mode (custom protocol doesn't propagate to child windows)
699
- showComposeOverlay();
699
+ // Title reflects mode + subject so the user can see what they're replying to
700
+ // ("Re: Stars of STEM 2026" instead of just "Compose"). Forward shows the
701
+ // forward target subject; new compose stays generic.
702
+ const titlePrefix = mode === "reply" ? "Reply" :
703
+ mode === "replyAll" ? "Reply All" :
704
+ mode === "forward" ? "Forward" :
705
+ "Compose";
706
+ const titleSubject = mode === "new" ? "" : (msg?.subject || init.subject || "");
707
+ showComposeOverlay(titleSubject ? `${titlePrefix}: ${titleSubject}` : titlePrefix);
700
708
  }
701
- function showComposeOverlay() {
709
+ function showComposeOverlay(title = "Compose") {
702
710
  const wrapper = document.createElement("div");
703
711
  wrapper.className = "compose-overlay";
704
712
  // Full-screen on small/short screens, floating on larger
@@ -712,7 +720,7 @@ function showComposeOverlay() {
712
720
  // Title bar — drag to move, close button
713
721
  const titleBar = document.createElement("div");
714
722
  titleBar.style.cssText = "display:flex;align-items:center;justify-content:space-between;padding:4px 8px;background:#e8ecf0;border-radius:8px 8px 0 0;cursor:move;user-select:none;flex-shrink:0;";
715
- titleBar.textContent = "Compose";
723
+ titleBar.textContent = title;
716
724
  const closeBtn = document.createElement("button");
717
725
  closeBtn.textContent = "✕";
718
726
  closeBtn.title = "Save draft and close";
@@ -1916,23 +1924,22 @@ document.addEventListener("keydown", (e) => {
1916
1924
  e.preventDefault();
1917
1925
  openCompose("new");
1918
1926
  }
1919
- // Ctrl+F = Forward
1920
- if (e.ctrlKey && e.key === "f") {
1921
- e.preventDefault();
1922
- openCompose("forward");
1923
- }
1924
- // Ctrl+R = Reply
1925
- if (e.ctrlKey && e.key === "r" && !e.shiftKey) {
1927
+ // Ctrl+R = Reply (without Shift)
1928
+ if (e.ctrlKey && e.key === "r" && !e.shiftKey && !e.altKey && !e.metaKey) {
1926
1929
  e.preventDefault();
1927
1930
  openCompose("reply");
1928
1931
  }
1929
1932
  // Ctrl+Shift+R = Reply All
1930
- if (e.ctrlKey && e.shiftKey && e.key === "R") {
1933
+ if (e.ctrlKey && e.shiftKey && e.key === "R" && !e.altKey && !e.metaKey) {
1931
1934
  e.preventDefault();
1932
1935
  openCompose("replyAll");
1933
1936
  }
1934
- // Ctrl+F = Forward
1935
- if (e.ctrlKey && e.key.toLowerCase() === "f" && !e.shiftKey) {
1937
+ // Ctrl+F = Forward (without Shift). Use toLowerCase so a Caps-Lock or
1938
+ // shifted state doesn't bypass us. Single handler the previous
1939
+ // duplicate fired openCompose twice, which double-loaded the compose
1940
+ // iframe and the second copy got an empty sessionStorage (the first
1941
+ // had already consumed it), producing an empty Forward form.
1942
+ if (e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && (e.key === "f" || e.key === "F")) {
1936
1943
  e.preventDefault();
1937
1944
  openCompose("forward");
1938
1945
  }
@@ -1113,6 +1113,17 @@ ${csp}
1113
1113
  if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
1114
1114
  // Zoom keys handled by parent-side installPreviewControls; don't double-send.
1115
1115
  if (e.ctrlKey && (e.key === "=" || e.key === "+" || e.key === "-" || e.key === "0")) return;
1116
+ // Preventing the iframe's default for keys we forward to the parent
1117
+ // is essential — the parent's preventDefault on the synthetic
1118
+ // keydown can't suppress the browser's reaction to the ORIGINAL
1119
+ // event (Ctrl+R reload, Ctrl+F find, etc.). Suppress here so the
1120
+ // browser doesn't act before the parent processes the action.
1121
+ var k = (e.key || "").toLowerCase();
1122
+ var isShortcut = e.ctrlKey && !e.altKey && !e.metaKey && (
1123
+ k === "r" || k === "f" || k === "n" || k === "a" || k === "d" ||
1124
+ k === "z" || k === "y" || k === "k"
1125
+ );
1126
+ if (isShortcut) e.preventDefault();
1116
1127
  window.parent.postMessage({
1117
1128
  type: "previewKey",
1118
1129
  key: e.key, code: e.code,
@@ -92,10 +92,62 @@ catch { /* private-mode / SecurityError — default quill */ }
92
92
  }
93
93
  catch { /* non-fatal */ }
94
94
  })();
95
- await loadEditorAssets(editorType);
95
+ // Whatever happens in editor init, surface failures to the mailx log
96
+ // AND fall through to a plain-contenteditable fallback so the rest of
97
+ // the compose script (Discard, X, Send, save-draft) still runs. Earlier
98
+ // versions re-threw asset-load failures, which left compose with dead
99
+ // buttons and the user with no recourse — exactly the "Reply window
100
+ // won't close" symptom we hit when Quill's CDN was unreachable.
101
+ let editor;
102
+ let editorAssetError = null;
103
+ try {
104
+ await loadEditorAssets(editorType);
105
+ }
106
+ catch (e) {
107
+ editorAssetError = e;
108
+ logClientEvent("compose-editor-assets-failed", { type: editorType, error: String(e?.message || e) });
109
+ }
96
110
  const container = document.getElementById("compose-editor");
97
111
  container.classList.add(editorType === "tiptap" ? "editor-tiptap" : "editor-quill");
98
- const editor = await createEditor(container, editorType);
112
+ try {
113
+ if (editorAssetError)
114
+ throw editorAssetError;
115
+ editor = await createEditor(container, editorType);
116
+ }
117
+ catch (e) {
118
+ logClientEvent("compose-editor-create-failed", { type: editorType, error: String(e?.message || e) });
119
+ // Render a minimal contenteditable fallback so the user can still type
120
+ // SOMETHING. Without this, an editor failure leaves the compose form
121
+ // half-functional (To/Cc/Bcc work, body doesn't) and the user doesn't
122
+ // know why. The fallback is a plain div — no toolbar, no rich text.
123
+ container.innerHTML = `<div class="compose-fallback-editor" contenteditable="true" style="border:1px solid #c00;padding:8px;min-height:200px;background:#fff" data-fallback="true"></div>`;
124
+ const fallback = container.querySelector(".compose-fallback-editor");
125
+ editor = {
126
+ root: fallback,
127
+ setHtml: (html) => { fallback.innerHTML = html; },
128
+ getHtml: () => fallback.innerHTML,
129
+ getText: () => fallback.innerText,
130
+ focus: () => fallback.focus(),
131
+ setCursor: () => { },
132
+ getScrollContainer: () => fallback,
133
+ onContentChange: (handler) => { fallback.addEventListener("input", handler); },
134
+ onKeyDown: (handler) => { fallback.addEventListener("keydown", handler); },
135
+ insertTextAtCursor: (text) => {
136
+ const sel = window.getSelection();
137
+ if (sel && sel.rangeCount > 0) {
138
+ const range = sel.getRangeAt(0);
139
+ range.deleteContents();
140
+ range.insertNode(document.createTextNode(text));
141
+ }
142
+ else {
143
+ fallback.append(document.createTextNode(text));
144
+ }
145
+ },
146
+ };
147
+ // Surface the failure to the user in the status bar so they know
148
+ // why the toolbar is missing and the editor is plain.
149
+ setTimeout(() => showDraftStatus(`Editor failed to load (${editorType}). Plain-text fallback in use. Open log for details.`, true), 0);
150
+ }
99
151
  // Ctrl+scroll / Ctrl+= / Ctrl+- / Ctrl+0 zoom for the compose editor body.
100
152
  // Persists per-session in localStorage so zoom survives window pop/close cycles.
101
153
  (() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.442",
3
+ "version": "1.0.444",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -559,11 +559,23 @@ export class MailxService {
559
559
  // in PATH) fall back to the OS default handler so the user still gets
560
560
  // *some* editor. Report which one ran so the UI can say "Opening in
561
561
  // Word…" vs "Opening in default editor…".
562
- const { spawnSync, spawn } = await import("node:child_process");
562
+ //
563
+ // CRITICAL: must use async spawn (not spawnSync). spawnSync blocks
564
+ // the Node event loop until the spawned process exits — and on
565
+ // Windows, `cmd /c start ... <gui-app>` sometimes does not return
566
+ // immediately when the GUI app hangs around. That froze the entire
567
+ // mailx IPC bridge on Edit-in-Word click; subsequent clicks
568
+ // (Discard, X, anything) hung waiting for a response that never
569
+ // came back. Async spawn launches and returns immediately;
570
+ // success/failure of the GUI launch is invisible from here, but
571
+ // the file is written and the watcher is armed regardless.
572
+ const { spawn } = await import("node:child_process");
563
573
  const tryLaunch = (cmd, args) => {
564
574
  try {
565
- const r = spawnSync(cmd, args, { stdio: "ignore", windowsHide: true });
566
- return r.status === 0 && !r.error;
575
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore", windowsHide: true });
576
+ child.on("error", () => { });
577
+ child.unref();
578
+ return true;
567
579
  }
568
580
  catch {
569
581
  return false;