@bobfrankston/mailx 1.0.443 → 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
@@ -1924,23 +1924,22 @@ document.addEventListener("keydown", (e) => {
1924
1924
  e.preventDefault();
1925
1925
  openCompose("new");
1926
1926
  }
1927
- // Ctrl+F = Forward
1928
- if (e.ctrlKey && e.key === "f") {
1929
- e.preventDefault();
1930
- openCompose("forward");
1931
- }
1932
- // Ctrl+R = Reply
1933
- 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) {
1934
1929
  e.preventDefault();
1935
1930
  openCompose("reply");
1936
1931
  }
1937
1932
  // Ctrl+Shift+R = Reply All
1938
- if (e.ctrlKey && e.shiftKey && e.key === "R") {
1933
+ if (e.ctrlKey && e.shiftKey && e.key === "R" && !e.altKey && !e.metaKey) {
1939
1934
  e.preventDefault();
1940
1935
  openCompose("replyAll");
1941
1936
  }
1942
- // Ctrl+F = Forward
1943
- 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")) {
1944
1943
  e.preventDefault();
1945
1944
  openCompose("forward");
1946
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,23 +92,26 @@ catch { /* private-mode / SecurityError — default quill */ }
92
92
  }
93
93
  catch { /* non-fatal */ }
94
94
  })();
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".
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
101
  let editor;
102
+ let editorAssetError = null;
102
103
  try {
103
104
  await loadEditorAssets(editorType);
104
105
  }
105
106
  catch (e) {
107
+ editorAssetError = e;
106
108
  logClientEvent("compose-editor-assets-failed", { type: editorType, error: String(e?.message || e) });
107
- throw e;
108
109
  }
109
110
  const container = document.getElementById("compose-editor");
110
111
  container.classList.add(editorType === "tiptap" ? "editor-tiptap" : "editor-quill");
111
112
  try {
113
+ if (editorAssetError)
114
+ throw editorAssetError;
112
115
  editor = await createEditor(container, editorType);
113
116
  }
114
117
  catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.443",
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",