@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 +58 -7
- package/client/compose/compose.js +51 -2
- package/client/lib/api-client.js +3 -0
- package/client/lib/mailxapi.js +3 -0
- package/package.json +3 -3
- package/packages/mailx-service/index.d.ts +10 -0
- package/packages/mailx-service/index.js +40 -4
- package/packages/mailx-service/jsonrpc.js +2 -0
- package/packages/mailx-settings/index.d.ts +24 -1
- package/packages/mailx-settings/index.js +108 -5
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
(() => {
|
package/client/lib/api-client.js
CHANGED
|
@@ -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
|
}
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
566
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() {
|