@bobfrankston/mailx 1.0.443 → 1.0.445

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,7 @@ 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
261
262
  mailx -v Show version
262
263
  ```
263
264
 
@@ -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/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
  }
@@ -2243,7 +2242,7 @@ document.getElementById("btn-open-log")?.addEventListener("click", async () => {
2243
2242
  }
2244
2243
  });
2245
2244
  async function openJsoncEditor(initialFile) {
2246
- const { readJsoncFile, writeJsoncFile, readConfigHelp, formatJsonc, leanAccountsJsonc } = await import("./lib/api-client.js");
2245
+ const { readJsoncFile, writeJsoncFile, readConfigHelp, formatJsonc } = await import("./lib/api-client.js");
2247
2246
  const backdrop = document.createElement("div");
2248
2247
  backdrop.className = "mailx-modal-backdrop";
2249
2248
  const panel = document.createElement("div");
@@ -2279,7 +2278,6 @@ async function openJsoncEditor(initialFile) {
2279
2278
  <div class="mailx-modal-error" id="jsonc-error" hidden></div>
2280
2279
  <div class="mailx-modal-buttons">
2281
2280
  <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>
2283
2281
  <span class="mailx-modal-spacer"></span>
2284
2282
  <button type="button" class="mailx-modal-btn" data-action="cancel">Cancel</button>
2285
2283
  <button type="button" class="mailx-modal-btn mailx-modal-btn-primary" data-action="save">Save</button>
@@ -2446,38 +2444,6 @@ async function openJsoncEditor(initialFile) {
2446
2444
  }
2447
2445
  return;
2448
2446
  }
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
- }
2481
2447
  if (action === "save") {
2482
2448
  // Final sync-check; refuse to save if it doesn't parse
2483
2449
  const err = validateJsonc(textarea.value);
@@ -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) {
@@ -351,9 +351,6 @@ 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
- }
357
354
  export function openInWord(editId, html) {
358
355
  return ipc().openInWord?.(editId, html) ?? Promise.resolve({ ok: false, path: "", opener: "none" });
359
356
  }
@@ -134,9 +134,6 @@
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
- },
140
137
  readConfigHelp: function(name) {
141
138
  return callNode("readConfigHelp", { name: name });
142
139
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.443",
3
+ "version": "1.0.445",
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.366",
39
+ "@bobfrankston/msger": "^0.1.367",
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.366",
103
+ "@bobfrankston/msger": "^0.1.367",
104
104
  "@bobfrankston/mailx-host": "^0.1.8",
105
105
  "@capacitor/android": "^8.3.0",
106
106
  "@capacitor/cli": "^8.3.0",
@@ -316,16 +316,6 @@ 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>;
329
319
  /** Return the help section for a named config file, extracted from docs/config-help.md.
330
320
  * Matches a level-2 heading whose text equals the filename. Returns markdown. */
331
321
  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, normalizeAccount, denormalizeAccount } from "@bobfrankston/mailx-settings";
12
+ import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, getStorageInfo, getConfigDir } 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).
@@ -2053,30 +2053,6 @@ export class MailxService {
2053
2053
  });
2054
2054
  return applyEdits(content, edits);
2055
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
- }
2080
2056
  /** Return the help section for a named config file, extracted from docs/config-help.md.
2081
2057
  * Matches a level-2 heading whose text equals the filename. Returns markdown. */
2082
2058
  async readConfigHelp(name) {
@@ -177,8 +177,6 @@ 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) };
182
180
  case "closeWordEdit":
183
181
  return await svc.closeWordEdit(p.editId);
184
182
  // Client-side tracing — lets webview / iframe code ship events to the
@@ -533,36 +533,64 @@ class AndroidSyncManager {
533
533
  }
534
534
  }
535
535
  }
536
- queueOutgoingLocal(accountId, rawMessage) {
536
+ /** In-flight send tracker keyed by queueUid. Prevents
537
+ * processSendQueue from re-firing the same row when it overlaps
538
+ * with an in-progress attempt (e.g., the periodic tick fires while
539
+ * the original attemptSend's promise is still pending). Without
540
+ * this, a slow Gmail/SMTP send race-conditions into a double-send. */
541
+ sendInFlight = new Set();
542
+ async queueOutgoingLocal(accountId, rawMessage) {
537
543
  // Local-first: PERSIST to sync_actions before attempting the network
538
544
  // send, so a crash / offline / process kill between now and SMTP ACK
539
545
  // doesn't drop the message. Desktop parity — PC writes `.ltr` to disk
540
- // before calling SMTP; Android writes a sync_actions row to sql.js +
541
- // IndexedDB (which saveDbToIdb persists on scheduleSave). Unique neg
542
- // uid = Date.now() to avoid colliding with real message UIDs and to
543
- // give us a stable tracking key through the async send pipeline.
546
+ // synchronously; Android writes a sync_actions row and now FLUSHES
547
+ // sql.js → IndexedDB before returning. The previous version relied on
548
+ // the 1-second scheduleSave debounce, so a tab-close inside the debounce
549
+ // window erased the row before it was persisted — the "letter just
550
+ // disappeared" symptom user-reported 2026-04-30.
544
551
  //
545
- // User-flagged 2026-04-23: "sending on android, like on the PC must
546
- // be queued." Equivalent of PC's `~/.mailx/outbox/<acct>/*.ltr`.
552
+ // Equivalent of PC's `~/.mailx/outbox/<acct>/*.ltr` durable write.
547
553
  const queueUid = -Date.now();
548
554
  this.db.queueSyncAction(accountId, "send", queueUid, -1, { rawMessage });
555
+ await this.db.flush();
549
556
  this.attemptSend(accountId, queueUid, rawMessage);
550
557
  }
551
558
  /** Kick off a send for a message that's already in the queue. Called by
552
559
  * queueOutgoingLocal on a fresh submit AND by processSendQueue on
553
- * startup / periodic tick for anything stranded from a prior run. */
560
+ * startup / periodic tick for anything stranded from a prior run.
561
+ * Guards against double-send via sendInFlight. */
554
562
  attemptSend(accountId, queueUid, rawMessage) {
563
+ if (this.sendInFlight.has(queueUid))
564
+ return;
565
+ this.sendInFlight.add(queueUid);
566
+ // Helper to mark complete + flush + clear in-flight — used on every
567
+ // success/failure exit. Flush ensures the row deletion or attempt
568
+ // counter actually reaches IndexedDB before the next process-kill,
569
+ // matching the "persist before network" rule for the post-network
570
+ // outcome too. Without flushing on completion, a successful send
571
+ // followed by a fast app-close left the row in the queue, which
572
+ // looked like a "stuck" message on next launch.
573
+ const finishSend = (success, error) => {
574
+ if (success) {
575
+ this.db.completeSyncActionByUid(accountId, "send", queueUid);
576
+ }
577
+ else {
578
+ this.db.failSyncActionByUid(accountId, "send", queueUid, error || "send failed");
579
+ }
580
+ this.db.flush().catch(() => { });
581
+ this.sendInFlight.delete(queueUid);
582
+ };
555
583
  const provider = this.getProvider(accountId);
556
584
  if (provider && typeof provider.sendRaw === "function") {
557
585
  provider.sendRaw(rawMessage)
558
586
  .then((result) => {
559
587
  console.log(`[send] ${accountId}: sent via Gmail API (id=${result.id})`);
560
- this.db.completeSyncActionByUid(accountId, "send", queueUid);
588
+ finishSend(true);
561
589
  emitEvent({ type: "sendComplete", accountId, messageId: result.id });
562
590
  })
563
591
  .catch((e) => {
564
592
  console.error(`[send] ${accountId}: Gmail send failed: ${e.message}`);
565
- this.db.failSyncActionByUid(accountId, "send", queueUid, e.message || String(e));
593
+ finishSend(false, e.message || String(e));
566
594
  emitEvent({ type: "sendError", accountId, error: e.message });
567
595
  });
568
596
  return;
@@ -574,7 +602,7 @@ class AndroidSyncManager {
574
602
  if (!row) {
575
603
  const e = "Unknown account";
576
604
  console.error(`[send] ${accountId}: ${e}`);
577
- this.db.failSyncActionByUid(accountId, "send", queueUid, e);
605
+ finishSend(false, e);
578
606
  emitEvent({ type: "sendError", accountId, error: e });
579
607
  return;
580
608
  }
@@ -584,26 +612,26 @@ class AndroidSyncManager {
584
612
  }
585
613
  catch {
586
614
  const e = "Account config malformed";
587
- this.db.failSyncActionByUid(accountId, "send", queueUid, e);
615
+ finishSend(false, e);
588
616
  emitEvent({ type: "sendError", accountId, error: e });
589
617
  return;
590
618
  }
591
619
  if (!account.smtp) {
592
620
  const e = "No SMTP config for this account";
593
621
  console.error(`[send] ${accountId}: ${e}`);
594
- this.db.failSyncActionByUid(accountId, "send", queueUid, e);
622
+ finishSend(false, e);
595
623
  emitEvent({ type: "sendError", accountId, error: e });
596
624
  return;
597
625
  }
598
626
  this.sendViaSmtpDirect(accountId, account, rawMessage)
599
627
  .then((result) => {
600
628
  console.log(`[send] ${accountId}: sent via SMTP (${result.accepted.length} accepted, ${result.rejected.length} rejected)`);
601
- this.db.completeSyncActionByUid(accountId, "send", queueUid);
629
+ finishSend(true);
602
630
  emitEvent({ type: "sendComplete", accountId });
603
631
  })
604
632
  .catch((e) => {
605
633
  console.error(`[send] ${accountId}: SMTP send failed: ${e.message}`);
606
- this.db.failSyncActionByUid(accountId, "send", queueUid, e.message || String(e));
634
+ finishSend(false, e.message || String(e));
607
635
  emitEvent({ type: "sendError", accountId, error: e.message });
608
636
  });
609
637
  }
@@ -30,7 +30,7 @@ export interface WebSyncManager {
30
30
  }[], targetFolderId: number): Promise<void>;
31
31
  moveMessageCrossAccount(accountId: string, uid: number, folderId: number, targetAccountId: string, targetFolderId: number): Promise<void>;
32
32
  undeleteMessage(accountId: string, uid: number, folderId: number): Promise<void>;
33
- queueOutgoingLocal(accountId: string, rawMessage: string): void;
33
+ queueOutgoingLocal(accountId: string, rawMessage: string): void | Promise<void>;
34
34
  saveDraft(accountId: string, raw: string, previousDraftUid?: number, draftId?: string): Promise<number | null>;
35
35
  deleteDraft(accountId: string, draftUid: number): Promise<void>;
36
36
  reauthenticate(accountId: string): Promise<boolean>;
@@ -394,7 +394,12 @@ export class WebMailxService {
394
394
  ].join("\r\n");
395
395
  rawMessage = `${headers}\r\n\r\n${textEncoded}`;
396
396
  }
397
- this.syncManager.queueOutgoingLocal(account.id, rawMessage);
397
+ // queueOutgoingLocal on the Android bridge is async (it flushes
398
+ // sql.js → IndexedDB before returning so a tab-close in the
399
+ // debounce window can't lose the row). On the web-worker
400
+ // SyncManager it's synchronous and returns void; awaiting an
401
+ // undefined value is benign, so this works for both.
402
+ await this.syncManager.queueOutgoingLocal(account.id, rawMessage);
398
403
  for (const addr of msg.to)
399
404
  this.db.recordSentAddress(addr.name, addr.address);
400
405
  if (msg.cc)