@bobfrankston/mailx 1.0.441 → 1.0.443

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
@@ -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";
@@ -2051,11 +2059,21 @@ viewBtn?.addEventListener("click", (e) => {
2051
2059
  if (viewDropdown)
2052
2060
  viewDropdown.hidden = !viewDropdown.hidden;
2053
2061
  });
2054
- document.addEventListener("click", () => {
2055
- if (viewDropdown)
2062
+ document.addEventListener("click", (e) => {
2063
+ // Only close when the click is genuinely outside the menu container.
2064
+ // The earlier unconditional close had two problems: clicks on radio
2065
+ // buttons / checkboxes INSIDE the menu also closed it (so the user
2066
+ // couldn't toggle anything without reopening), and any handler
2067
+ // upstream that consumed the event (preventDefault paths, focus
2068
+ // shifts) could keep the menu open inappropriately. The closest()
2069
+ // check ensures inside-clicks pass through and outside-clicks close.
2070
+ const target = e.target;
2071
+ if (viewDropdown && !viewDropdown.hidden && !target?.closest("#view-menu") && !target?.closest("#view-dropdown")) {
2056
2072
  viewDropdown.hidden = true;
2057
- if (settingsDropdown)
2073
+ }
2074
+ if (settingsDropdown && !settingsDropdown.hidden && !target?.closest("#settings-menu") && !target?.closest("#settings-dropdown")) {
2058
2075
  settingsDropdown.hidden = true;
2076
+ }
2059
2077
  });
2060
2078
  // Restore saved view settings
2061
2079
  const savedTwoLine = localStorage.getItem("mailx-two-line") === "true";
@@ -2225,7 +2243,7 @@ document.getElementById("btn-open-log")?.addEventListener("click", async () => {
2225
2243
  }
2226
2244
  });
2227
2245
  async function openJsoncEditor(initialFile) {
2228
- const { readJsoncFile, writeJsoncFile, readConfigHelp, formatJsonc } = await import("./lib/api-client.js");
2246
+ const { readJsoncFile, writeJsoncFile, readConfigHelp, formatJsonc, leanAccountsJsonc } = await import("./lib/api-client.js");
2229
2247
  const backdrop = document.createElement("div");
2230
2248
  backdrop.className = "mailx-modal-backdrop";
2231
2249
  const panel = document.createElement("div");
@@ -2261,6 +2279,7 @@ async function openJsoncEditor(initialFile) {
2261
2279
  <div class="mailx-modal-error" id="jsonc-error" hidden></div>
2262
2280
  <div class="mailx-modal-buttons">
2263
2281
  <button type="button" class="mailx-modal-btn" data-action="format" title="Reformat indentation while preserving comments and trailing commas">Format</button>
2282
+ <button type="button" class="mailx-modal-btn" data-action="lean" title="accounts.jsonc only — strip default-valued fields (port, tls, auth, etc.) so the file stays compact. Comments are dropped (the lean output is regenerated from values, not the original text)">Lean</button>
2264
2283
  <span class="mailx-modal-spacer"></span>
2265
2284
  <button type="button" class="mailx-modal-btn" data-action="cancel">Cancel</button>
2266
2285
  <button type="button" class="mailx-modal-btn mailx-modal-btn-primary" data-action="save">Save</button>
@@ -2427,6 +2446,38 @@ async function openJsoncEditor(initialFile) {
2427
2446
  }
2428
2447
  return;
2429
2448
  }
2449
+ if (action === "lean") {
2450
+ // accounts.jsonc-only: strip defaulted fields and re-emit
2451
+ // a compact form. User reviews it in the editor, then Save
2452
+ // commits. Lean drops comments because the output is
2453
+ // regenerated from canonical values, not the original text.
2454
+ if (fileSelect.value !== "accounts.jsonc") {
2455
+ errorEl.textContent = "Lean is only available for accounts.jsonc.";
2456
+ errorEl.hidden = false;
2457
+ return;
2458
+ }
2459
+ btn.disabled = true;
2460
+ const orig = btn.textContent;
2461
+ btn.textContent = "Leaning…";
2462
+ try {
2463
+ const r = await leanAccountsJsonc(textarea.value);
2464
+ if (r?.content !== undefined) {
2465
+ textarea.value = r.content;
2466
+ renderGutter();
2467
+ scheduleValidate();
2468
+ errorEl.hidden = true;
2469
+ }
2470
+ }
2471
+ catch (e) {
2472
+ errorEl.textContent = `Lean failed: ${e.message}`;
2473
+ errorEl.hidden = false;
2474
+ }
2475
+ finally {
2476
+ btn.disabled = false;
2477
+ btn.textContent = orig || "Lean";
2478
+ }
2479
+ return;
2480
+ }
2430
2481
  if (action === "save") {
2431
2482
  // Final sync-check; refuse to save if it doesn't parse
2432
2483
  const err = validateJsonc(textarea.value);
@@ -92,10 +92,59 @@ 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
+ // Until this round the failures were swallowed silently — Quill CDN
97
+ // timing out or createEditor throwing left the iframe with empty body
98
+ // + dead buttons (because the rest of the script after the await never
99
+ // ran). With logClientEvent here, the failure mode is visible in the
100
+ // service log instead of "Reply just shows blank compose".
101
+ let editor;
102
+ try {
103
+ await loadEditorAssets(editorType);
104
+ }
105
+ catch (e) {
106
+ logClientEvent("compose-editor-assets-failed", { type: editorType, error: String(e?.message || e) });
107
+ throw e;
108
+ }
96
109
  const container = document.getElementById("compose-editor");
97
110
  container.classList.add(editorType === "tiptap" ? "editor-tiptap" : "editor-quill");
98
- const editor = await createEditor(container, editorType);
111
+ try {
112
+ editor = await createEditor(container, editorType);
113
+ }
114
+ catch (e) {
115
+ logClientEvent("compose-editor-create-failed", { type: editorType, error: String(e?.message || e) });
116
+ // Render a minimal contenteditable fallback so the user can still type
117
+ // SOMETHING. Without this, an editor failure leaves the compose form
118
+ // half-functional (To/Cc/Bcc work, body doesn't) and the user doesn't
119
+ // know why. The fallback is a plain div — no toolbar, no rich text.
120
+ 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>`;
121
+ const fallback = container.querySelector(".compose-fallback-editor");
122
+ editor = {
123
+ root: fallback,
124
+ setHtml: (html) => { fallback.innerHTML = html; },
125
+ getHtml: () => fallback.innerHTML,
126
+ getText: () => fallback.innerText,
127
+ focus: () => fallback.focus(),
128
+ setCursor: () => { },
129
+ getScrollContainer: () => fallback,
130
+ onContentChange: (handler) => { fallback.addEventListener("input", handler); },
131
+ onKeyDown: (handler) => { fallback.addEventListener("keydown", handler); },
132
+ insertTextAtCursor: (text) => {
133
+ const sel = window.getSelection();
134
+ if (sel && sel.rangeCount > 0) {
135
+ const range = sel.getRangeAt(0);
136
+ range.deleteContents();
137
+ range.insertNode(document.createTextNode(text));
138
+ }
139
+ else {
140
+ fallback.append(document.createTextNode(text));
141
+ }
142
+ },
143
+ };
144
+ // Surface the failure to the user in the status bar so they know
145
+ // why the toolbar is missing and the editor is plain.
146
+ setTimeout(() => showDraftStatus(`Editor failed to load (${editorType}). Plain-text fallback in use. Open log for details.`, true), 0);
147
+ }
99
148
  // Ctrl+scroll / Ctrl+= / Ctrl+- / Ctrl+0 zoom for the compose editor body.
100
149
  // Persists per-session in localStorage so zoom survives window pop/close cycles.
101
150
  (() => {
@@ -351,6 +351,9 @@ export function readConfigHelp(name) {
351
351
  export function unsubscribeOneClick(url) {
352
352
  return ipc().unsubscribeOneClick?.(url);
353
353
  }
354
+ export function leanAccountsJsonc(content) {
355
+ return ipc().leanAccountsJsonc?.(content) ?? Promise.resolve({ content });
356
+ }
354
357
  export function openInWord(editId, html) {
355
358
  return ipc().openInWord?.(editId, html) ?? Promise.resolve({ ok: false, path: "", opener: "none" });
356
359
  }
@@ -134,6 +134,9 @@
134
134
  formatJsonc: function(content) {
135
135
  return callNode("formatJsonc", { content: content });
136
136
  },
137
+ leanAccountsJsonc: function(content) {
138
+ return callNode("leanAccountsJsonc", { content: content });
139
+ },
137
140
  readConfigHelp: function(name) {
138
141
  return callNode("readConfigHelp", { name: name });
139
142
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.441",
3
+ "version": "1.0.443",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -36,7 +36,7 @@
36
36
  "@bobfrankston/iflow-node": "^0.1.8",
37
37
  "@bobfrankston/miscinfo": "^1.0.10",
38
38
  "@bobfrankston/oauthsupport": "^1.0.25",
39
- "@bobfrankston/msger": "^0.1.364",
39
+ "@bobfrankston/msger": "^0.1.366",
40
40
  "@bobfrankston/mailx-host": "^0.1.8",
41
41
  "@capacitor/android": "^8.3.0",
42
42
  "@capacitor/cli": "^8.3.0",
@@ -100,7 +100,7 @@
100
100
  "@bobfrankston/iflow-node": "^0.1.8",
101
101
  "@bobfrankston/miscinfo": "^1.0.10",
102
102
  "@bobfrankston/oauthsupport": "^1.0.25",
103
- "@bobfrankston/msger": "^0.1.364",
103
+ "@bobfrankston/msger": "^0.1.366",
104
104
  "@bobfrankston/mailx-host": "^0.1.8",
105
105
  "@capacitor/android": "^8.3.0",
106
106
  "@capacitor/cli": "^8.3.0",
@@ -316,6 +316,16 @@ export declare class MailxService {
316
316
  * `config.jsonc` is the local per-machine config (not cloud-synced). */
317
317
  readJsoncFile(name: string): Promise<string | null>;
318
318
  formatJsonc(content: string): Promise<string>;
319
+ /** Strip default-valued fields from accounts.jsonc and return the lean
320
+ * form. Called from the JSONC editor's "Lean" button so the user can
321
+ * see the tidy version before saving. Round-trips through normalize→
322
+ * denormalize so the canonicalization rules stay in one place
323
+ * (mailx-settings). Drops port: 993, tls: true, auth: "password",
324
+ * enabled: true, sig.html: false, etc. when they match the defaults
325
+ * for the email's provider. Promotes a shared `name` to the file
326
+ * level when every account has the same name. Only handles
327
+ * accounts.jsonc — other JSONC files have hand-curated shapes. */
328
+ leanAccountsJsonc(content: string): Promise<string>;
319
329
  /** Return the help section for a named config file, extracted from docs/config-help.md.
320
330
  * Matches a level-2 heading whose text equals the filename. Returns markdown. */
321
331
  readConfigHelp(name: string): Promise<string>;
@@ -9,7 +9,7 @@ import * as path from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
11
  import * as gsync from "./google-sync.js";
12
- import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, getStorageInfo, getConfigDir } from "@bobfrankston/mailx-settings";
12
+ import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, getStorageInfo, getConfigDir, normalizeAccount, denormalizeAccount } from "@bobfrankston/mailx-settings";
13
13
  import { sanitizeHtml, encodeQuotedPrintable, htmlToPlainText } from "@bobfrankston/mailx-types";
14
14
  import { simpleParser } from "mailparser";
15
15
  /** Parse `List-Unsubscribe` (RFC 2369) and `List-Unsubscribe-Post` (RFC 8058).
@@ -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;
@@ -2041,6 +2053,30 @@ export class MailxService {
2041
2053
  });
2042
2054
  return applyEdits(content, edits);
2043
2055
  }
2056
+ /** Strip default-valued fields from accounts.jsonc and return the lean
2057
+ * form. Called from the JSONC editor's "Lean" button so the user can
2058
+ * see the tidy version before saving. Round-trips through normalize→
2059
+ * denormalize so the canonicalization rules stay in one place
2060
+ * (mailx-settings). Drops port: 993, tls: true, auth: "password",
2061
+ * enabled: true, sig.html: false, etc. when they match the defaults
2062
+ * for the email's provider. Promotes a shared `name` to the file
2063
+ * level when every account has the same name. Only handles
2064
+ * accounts.jsonc — other JSONC files have hand-curated shapes. */
2065
+ async leanAccountsJsonc(content) {
2066
+ const { parse: parseJsonc } = await import("jsonc-parser");
2067
+ const errors = [];
2068
+ const cfg = parseJsonc(content, errors, { allowTrailingComma: true });
2069
+ if (errors.length)
2070
+ throw new Error(`JSONC parse error: ${errors.map((e) => e.error).join(", ")}`);
2071
+ const raw = cfg?.accounts || (Array.isArray(cfg) ? cfg : []);
2072
+ const globalName = cfg?.name;
2073
+ const normalized = raw.map((a) => normalizeAccount(a, globalName));
2074
+ const names = new Set(normalized.map((a) => a.name).filter(Boolean));
2075
+ const sharedName = names.size === 1 ? [...names][0] : globalName;
2076
+ const lean = normalized.map((a) => denormalizeAccount(a, sharedName));
2077
+ const payload = sharedName ? { name: sharedName, accounts: lean } : { accounts: lean };
2078
+ return JSON.stringify(payload, null, 2);
2079
+ }
2044
2080
  /** Return the help section for a named config file, extracted from docs/config-help.md.
2045
2081
  * Matches a level-2 heading whose text equals the filename. Returns markdown. */
2046
2082
  async readConfigHelp(name) {
@@ -177,6 +177,8 @@ async function dispatchAction(svc, action, p) {
177
177
  return await svc.unsubscribeOneClick(p.url);
178
178
  case "openInWord":
179
179
  return await svc.openInWord(p.editId, p.html);
180
+ case "leanAccountsJsonc":
181
+ return { content: await svc.leanAccountsJsonc(p.content) };
180
182
  case "closeWordEdit":
181
183
  return await svc.closeWordEdit(p.editId);
182
184
  // Client-side tracing — lets webview / iframe code ship events to the
@@ -40,6 +40,10 @@ export declare function getStorageInfo(): {
40
40
  cloudPath?: string;
41
41
  cloudError?: string;
42
42
  };
43
+ /** Fill in provider defaults for an account based on email domain.
44
+ * Exported so mailx-service's leanAccountsJsonc helper can reuse the same
45
+ * canonicalization rules that loadAccounts uses. */
46
+ export declare function normalizeAccount(acct: any, globalName?: string): AccountConfig;
43
47
  declare const DEFAULT_PREFERENCES: {
44
48
  ui: {
45
49
  theme: "system" | "dark" | "light";
@@ -76,8 +80,27 @@ declare const DEFAULT_ALLOWLIST: {
76
80
  export declare function loadAccounts(): AccountConfig[];
77
81
  /** Load accounts with cloud API fallback (async — use when cloud settings may not be mounted) */
78
82
  export declare function loadAccountsAsync(): Promise<AccountConfig[]>;
83
+ /** Strip default-valued fields from a normalized AccountConfig so the
84
+ * serialized JSONC stays compact and human-editable. The previous version
85
+ * was round-tripping every defaulted field (port: 993, tls: true, auth:
86
+ * "password", enabled: true, etc.), which bloated a 10-line accounts.jsonc
87
+ * to 60+ lines and embedded unnecessary "knowledge" about defaults.
88
+ *
89
+ * Rules:
90
+ * - Drop fields that match the provider default (host/port/tls/auth derived
91
+ * from email domain via PROVIDERS).
92
+ * - Drop `enabled: true` (default).
93
+ * - Drop `name` if equal to the file-level `globalName`.
94
+ * - Drop `imap.user` / `smtp.user` if they equal the email.
95
+ * - Drop `sig.html: false` (default; only keep when user enabled HTML sigs).
96
+ * - Keep field order: id → label → email → primary* → imap → smtp → defaultSend
97
+ * → relayDomains → deliveredToPrefix → identityDomains → syncContacts → sig.
98
+ * Curated for readability, not alphabetic. */
99
+ export declare function denormalizeAccount(acct: AccountConfig, globalName?: string): any;
79
100
  /** Save account configs */
80
- /** Save accounts — merges with cloud copy by email (multi-client safe) */
101
+ /** Save accounts — merges with cloud copy by email (multi-client safe).
102
+ * Writes the lean form via denormalizeAccount so accounts.jsonc stays
103
+ * compact and human-editable. */
81
104
  export declare function saveAccounts(accounts: AccountConfig[]): Promise<void>;
82
105
  /** Load preferences (shared + local overrides, with legacy fallback) */
83
106
  export declare function loadPreferences(): typeof DEFAULT_PREFERENCES;
@@ -339,8 +339,10 @@ const PROVIDERS = {
339
339
  smtp: { host: "smtp.mail.me.com", port: 587, tls: true, auth: "password" },
340
340
  },
341
341
  };
342
- /** Fill in provider defaults for an account based on email domain */
343
- function normalizeAccount(acct, globalName) {
342
+ /** Fill in provider defaults for an account based on email domain.
343
+ * Exported so mailx-service's leanAccountsJsonc helper can reuse the same
344
+ * canonicalization rules that loadAccounts uses. */
345
+ export function normalizeAccount(acct, globalName) {
344
346
  const email = acct.email || "";
345
347
  const localPart = email.split("@")[0]?.toLowerCase() || "";
346
348
  const domain = email.split("@")[1]?.toLowerCase() || "";
@@ -552,8 +554,100 @@ export async function loadAccountsAsync() {
552
554
  }
553
555
  return [];
554
556
  }
557
+ /** Strip default-valued fields from a normalized AccountConfig so the
558
+ * serialized JSONC stays compact and human-editable. The previous version
559
+ * was round-tripping every defaulted field (port: 993, tls: true, auth:
560
+ * "password", enabled: true, etc.), which bloated a 10-line accounts.jsonc
561
+ * to 60+ lines and embedded unnecessary "knowledge" about defaults.
562
+ *
563
+ * Rules:
564
+ * - Drop fields that match the provider default (host/port/tls/auth derived
565
+ * from email domain via PROVIDERS).
566
+ * - Drop `enabled: true` (default).
567
+ * - Drop `name` if equal to the file-level `globalName`.
568
+ * - Drop `imap.user` / `smtp.user` if they equal the email.
569
+ * - Drop `sig.html: false` (default; only keep when user enabled HTML sigs).
570
+ * - Keep field order: id → label → email → primary* → imap → smtp → defaultSend
571
+ * → relayDomains → deliveredToPrefix → identityDomains → syncContacts → sig.
572
+ * Curated for readability, not alphabetic. */
573
+ export function denormalizeAccount(acct, globalName) {
574
+ const domain = (acct.email || "").split("@")[1]?.toLowerCase() || "";
575
+ const provider = PROVIDERS[domain];
576
+ const out = {};
577
+ out.id = acct.id;
578
+ if (acct.label && acct.label !== provider?.label && acct.label !== acct.id)
579
+ out.label = acct.label;
580
+ out.email = acct.email;
581
+ if (acct.name && acct.name !== globalName)
582
+ out.name = acct.name;
583
+ if (acct.primary)
584
+ out.primary = true;
585
+ if (acct.primaryCalendar !== undefined)
586
+ out.primaryCalendar = acct.primaryCalendar;
587
+ if (acct.primaryTasks !== undefined)
588
+ out.primaryTasks = acct.primaryTasks;
589
+ if (acct.primaryContacts !== undefined)
590
+ out.primaryContacts = acct.primaryContacts;
591
+ // imap — keep only fields that differ from provider defaults.
592
+ const imapOut = {};
593
+ if (acct.imap?.host && acct.imap.host !== provider?.imap.host)
594
+ imapOut.host = acct.imap.host;
595
+ if (acct.imap?.user && acct.imap.user !== acct.email)
596
+ imapOut.user = acct.imap.user;
597
+ if (acct.imap?.password)
598
+ imapOut.password = acct.imap.password;
599
+ if (acct.imap?.port && acct.imap.port !== (provider?.imap.port ?? 993))
600
+ imapOut.port = acct.imap.port;
601
+ if (acct.imap?.tls !== undefined && acct.imap.tls !== (provider?.imap.tls ?? true))
602
+ imapOut.tls = acct.imap.tls;
603
+ if (acct.imap?.auth && acct.imap.auth !== (provider?.imap.auth ?? "password"))
604
+ imapOut.auth = acct.imap.auth;
605
+ if (Object.keys(imapOut).length > 0)
606
+ out.imap = imapOut;
607
+ // smtp — same treatment.
608
+ const smtpOut = {};
609
+ if (acct.smtp?.host && acct.smtp.host !== provider?.smtp.host)
610
+ smtpOut.host = acct.smtp.host;
611
+ if (acct.smtp?.user && acct.smtp.user !== acct.email && acct.smtp.user !== acct.imap?.user)
612
+ smtpOut.user = acct.smtp.user;
613
+ if (acct.smtp?.password)
614
+ smtpOut.password = acct.smtp.password;
615
+ if (acct.smtp?.port && acct.smtp.port !== (provider?.smtp.port ?? 587))
616
+ smtpOut.port = acct.smtp.port;
617
+ if (acct.smtp?.tls !== undefined && acct.smtp.tls !== (provider?.smtp.tls ?? true))
618
+ smtpOut.tls = acct.smtp.tls;
619
+ if (acct.smtp?.auth && acct.smtp.auth !== (provider?.smtp.auth ?? "password"))
620
+ smtpOut.auth = acct.smtp.auth;
621
+ if (Object.keys(smtpOut).length > 0)
622
+ out.smtp = smtpOut;
623
+ if (acct.defaultSend)
624
+ out.defaultSend = true;
625
+ if (acct.enabled === false)
626
+ out.enabled = false; // default true → omit
627
+ if (acct.relayDomains && acct.relayDomains.length > 0)
628
+ out.relayDomains = acct.relayDomains;
629
+ if (acct.deliveredToPrefix && acct.deliveredToPrefix.length > 0)
630
+ out.deliveredToPrefix = acct.deliveredToPrefix;
631
+ if (acct.identityDomains && acct.identityDomains.length > 0)
632
+ out.identityDomains = acct.identityDomains;
633
+ // syncContacts default: true for OAuth, false otherwise. Only emit when
634
+ // the user overrode the default.
635
+ const syncContactsDefault = provider?.imap.auth === "oauth2";
636
+ if (acct.syncContacts !== undefined && acct.syncContacts !== syncContactsDefault) {
637
+ out.syncContacts = acct.syncContacts;
638
+ }
639
+ if (acct.signature)
640
+ out.signature = acct.signature;
641
+ if (acct.sig?.text) {
642
+ // html: false is default; only keep the html flag when explicitly true.
643
+ out.sig = acct.sig.html ? { text: acct.sig.text, html: true } : { text: acct.sig.text };
644
+ }
645
+ return out;
646
+ }
555
647
  /** Save account configs */
556
- /** Save accounts — merges with cloud copy by email (multi-client safe) */
648
+ /** Save accounts — merges with cloud copy by email (multi-client safe).
649
+ * Writes the lean form via denormalizeAccount so accounts.jsonc stays
650
+ * compact and human-editable. */
557
651
  export async function saveAccounts(accounts) {
558
652
  // Merge with cloud: keep all accounts, deduplicate by normalized email
559
653
  try {
@@ -565,7 +659,9 @@ export async function saveAccounts(accounts) {
565
659
  const seen = new Set(accounts.map(a => normalizeEmail(a.email)));
566
660
  for (const ca of cloudAccts) {
567
661
  if (ca.email && !seen.has(normalizeEmail(ca.email))) {
568
- accounts.push(ca);
662
+ // Cloud entries are already lean — feed through
663
+ // normalizeAccount to coerce to AccountConfig shape.
664
+ accounts.push(normalizeAccount(ca));
569
665
  seen.add(normalizeEmail(ca.email));
570
666
  }
571
667
  }
@@ -573,7 +669,14 @@ export async function saveAccounts(accounts) {
573
669
  }
574
670
  }
575
671
  catch { /* cloud read failed — save local version */ }
576
- saveFile("accounts.jsonc", { accounts });
672
+ // Promote a shared "name" to file level when every account has the same
673
+ // name — keeps the JSONC tidy ({ "name": "Bob Frankston", "accounts": [...] }
674
+ // instead of repeating "name" on each entry).
675
+ const names = new Set(accounts.map(a => a.name).filter(Boolean));
676
+ const globalName = names.size === 1 ? [...names][0] : undefined;
677
+ const lean = accounts.map(a => denormalizeAccount(a, globalName));
678
+ const payload = globalName ? { name: globalName, accounts: lean } : { accounts: lean };
679
+ saveFile("accounts.jsonc", payload);
577
680
  }
578
681
  /** Load preferences (shared + local overrides, with legacy fallback) */
579
682
  export function loadPreferences() {