@bobfrankston/mailx 1.0.338 → 1.0.340

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/bin/mailx.js CHANGED
@@ -909,6 +909,53 @@ async function main() {
909
909
  }
910
910
  }
911
911
  const db = new MailxDB(getConfigDir());
912
+ // Auto-create the sending/ recovery README on every startup. Stays in
913
+ // sync with the running version of mailx; user can ignore once the
914
+ // disk-staging fallback is no longer needed.
915
+ try {
916
+ const sendingDir = path.join(getConfigDir(), "sending");
917
+ fs.mkdirSync(sendingDir, { recursive: true });
918
+ const readmePath = path.join(sendingDir, "README.md");
919
+ const readmeBody = `# \`~/.mailx/sending/\` and \`~/.mailx/outbox/\` — outgoing-mail staging
920
+
921
+ Auto-generated by mailx on startup. Manual recovery reference for when mailx is broken or you need to feed an outgoing message into another mail program.
922
+
923
+ ## Layout
924
+
925
+ \`\`\`
926
+ ~/.mailx/
927
+ ├── outbox/<account>/
928
+ │ └── *.ltr ← THE QUEUE. Worker scans every 10s, sends, deletes on success.
929
+ └── sending/<account>/
930
+ ├── editing/ ← Last 3 draft autosaves while composing.
931
+ ├── queued/ ← Manual drop-in / crash-recovery copies.
932
+ └── sent/ ← Audit trail of successfully sent messages.
933
+ \`\`\`
934
+
935
+ In-flight files are atomically renamed to \`<file>.sending-<host>-<pid>\` while the worker is processing them — same-machine claim so two mailx instances don't double-send. Stale claims (dead PIDs on this host) are recovered on the next tick.
936
+
937
+ ## Manual fallback
938
+
939
+ - **mailx is dead, need to send a draft** — most recent file in \`sending/<account>/editing/\` is a complete RFC 822 message; copy the body into another mail client and resend.
940
+ - **Feed a raw .eml to mailx** — drop into \`sending/<account>/queued/\`. Picked up within 10s.
941
+ - **mailx says queued but server doesn't have it** — look in \`outbox/<account>/\`. \`.ltr\` still there → worker hasn't sent yet (check \`~/.mailx/logs/\`). \`.sending-<host>-<pid>\` → in flight. Gone → success.
942
+
943
+ ## Format
944
+
945
+ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable in a text editor). Every message carries \`Message-ID:\` for cross-device dedup; \`X-Mailx-Retry\` marks retry attempts.
946
+ `;
947
+ // Only rewrite if content drifted (avoids gratuitous mtime updates).
948
+ let existing = "";
949
+ try {
950
+ existing = fs.readFileSync(readmePath, "utf-8");
951
+ }
952
+ catch { /* missing */ }
953
+ if (existing !== readmeBody)
954
+ fs.writeFileSync(readmePath, readmeBody);
955
+ }
956
+ catch (e) {
957
+ console.error(` [readme] Could not write sending README: ${e?.message || e}`);
958
+ }
912
959
  const { NodeTcpTransport } = await import("@bobfrankston/node-tcp-transport");
913
960
  const imapManager = new ImapManager(db, () => new NodeTcpTransport());
914
961
  // Native client is the only option (iflow-direct)
@@ -1119,6 +1166,9 @@ async function main() {
1119
1166
  imapManager.on("configChanged", (filename) => {
1120
1167
  handle.send({ _event: "configChanged", type: "configChanged", filename });
1121
1168
  });
1169
+ imapManager.on("outboxStatus", (status) => {
1170
+ handle.send({ _event: "outboxStatus", type: "outboxStatus", ...status });
1171
+ });
1122
1172
  // syncComplete drives the folder-tree refresh that picks up newly-discovered
1123
1173
  // folders on first run (Gmail accounts have no folders in the DB until the
1124
1174
  // first sync fetches the labels). Without this forward, the UI shows the
package/client/app.js CHANGED
@@ -1335,6 +1335,9 @@ onWsEvent((event) => {
1335
1335
  statusSync.textContent = `Error: ${event.message}`;
1336
1336
  showAlert(event.message, "ws-error");
1337
1337
  break;
1338
+ case "outboxStatus":
1339
+ renderOutboxStatus(event);
1340
+ break;
1338
1341
  case "accountError": {
1339
1342
  // Show actual error + hint in banner
1340
1343
  const msg = `${event.accountId}: ${event.error}`;
@@ -2208,6 +2211,57 @@ else
2208
2211
  }
2209
2212
  }
2210
2213
  }, 5000);
2214
+ // ── Outbox queue indicator (status-queue span) ──
2215
+ // Event-driven in IPC mode (service pushes outboxStatus on every mutation).
2216
+ // Plus a 15s poll safety net for both modes so a missed event doesn't leave
2217
+ // the user staring at stale numbers. Idempotent — renderOutboxStatus just
2218
+ // overwrites the text.
2219
+ function renderOutboxStatus(s) {
2220
+ const el = document.getElementById("status-queue");
2221
+ if (!el)
2222
+ return;
2223
+ if (!s || !s.total || s.total === 0) {
2224
+ el.textContent = "";
2225
+ el.title = "";
2226
+ el.style.color = "";
2227
+ return;
2228
+ }
2229
+ const parts = [`✉ ${s.total} queued`];
2230
+ if (s.claimed > 0)
2231
+ parts.push(`${s.claimed} sending`);
2232
+ if (s.retrying > 0)
2233
+ parts.push(`${s.retrying} retrying (×${s.maxAttempts})`);
2234
+ if (s.oldestAgeSec >= 60) {
2235
+ const age = s.oldestAgeSec >= 3600
2236
+ ? `${Math.floor(s.oldestAgeSec / 3600)}h`
2237
+ : `${Math.floor(s.oldestAgeSec / 60)}m`;
2238
+ parts.push(`oldest ${age}`);
2239
+ }
2240
+ el.textContent = parts.join(" · ");
2241
+ const perAcct = s.perAccount || {};
2242
+ const detail = Object.keys(perAcct).sort().map(a => `${a}: ${perAcct[a].total} total, ${perAcct[a].claimed} sending, ${perAcct[a].retrying} retrying`).join("\n");
2243
+ el.title = detail || "";
2244
+ // Orange when retrying, red when stuck >5min, else muted.
2245
+ el.style.color = s.oldestAgeSec > 300 ? "oklch(0.65 0.2 25)"
2246
+ : s.retrying > 0 ? "oklch(0.75 0.15 60)"
2247
+ : "";
2248
+ }
2249
+ setInterval(async () => {
2250
+ try {
2251
+ const { getOutboxStatus } = await import("./lib/api-client.js");
2252
+ const s = await getOutboxStatus();
2253
+ renderOutboxStatus(s);
2254
+ }
2255
+ catch { /* service unreachable */ }
2256
+ }, 15000);
2257
+ // First read on startup so the bar isn't blank.
2258
+ (async () => {
2259
+ try {
2260
+ const { getOutboxStatus } = await import("./lib/api-client.js");
2261
+ renderOutboxStatus(await getOutboxStatus());
2262
+ }
2263
+ catch { /* */ }
2264
+ })();
2211
2265
  console.log("mailx client initialized, location:", location.href);
2212
2266
  updateNewMessageCount();
2213
2267
  // ── Midnight refresh — update date display when day changes ──
@@ -528,7 +528,7 @@ function scheduleDraftSave() {
528
528
  document.getElementById("btn-send")?.addEventListener("click", async () => {
529
529
  const btn = document.getElementById("btn-send");
530
530
  btn.disabled = true;
531
- btn.textContent = "Sending...";
531
+ btn.textContent = "Sending";
532
532
  const body = {
533
533
  from: getFromAccountId(),
534
534
  fromAddress: getFromAddress(),
@@ -540,11 +540,22 @@ document.getElementById("btn-send")?.addEventListener("click", async () => {
540
540
  bodyText: editor.getText(),
541
541
  attachments: attachments.map(a => ({ filename: a.filename, mimeType: a.mimeType, dataBase64: a.dataBase64 })),
542
542
  };
543
+ console.log(`[compose] Send clicked: from=${body.from} to=${JSON.stringify(body.to)} subject="${body.subject}" attachments=${body.attachments.length}`);
544
+ // Live countdown so the user sees the IPC is alive. Hard timeout is 120s.
545
+ const sendStart = Date.now();
546
+ const sendTick = setInterval(() => {
547
+ const sec = Math.floor((Date.now() - sendStart) / 1000);
548
+ if (sec < 2)
549
+ btn.textContent = "Sending…";
550
+ else if (sec < 10)
551
+ btn.textContent = `Queueing… (${sec}s)`;
552
+ else
553
+ btn.textContent = `Still working… (${sec}s of 120s)`;
554
+ }, 500);
543
555
  try {
544
556
  await sendMessage(body);
545
- // Delete draft after successful send — stop auto-save first so it can't
546
- // append a fresh copy after we delete. Use the stored draftId as a fallback
547
- // so any orphaned drafts with the same stable ID are cleaned up too.
557
+ clearInterval(sendTick);
558
+ console.log(`[compose] Send IPC returned OK in ${Date.now() - sendStart}ms`);
548
559
  if (draftTimer) {
549
560
  clearInterval(draftTimer);
550
561
  draftTimer = null;
@@ -555,23 +566,20 @@ document.getElementById("btn-send")?.addEventListener("click", async () => {
555
566
  closeCompose();
556
567
  }
557
568
  catch (e) {
569
+ clearInterval(sendTick);
570
+ const msg = e?.message || String(e);
571
+ console.error(`[compose] Send IPC failed after ${Date.now() - sendStart}ms: ${msg}`);
558
572
  btn.disabled = false;
559
573
  btn.textContent = "Send";
560
- const msg = e?.message || String(e);
561
- // Distinguish the IPC-timeout case from real send failures. The
562
- // service-side send() queues the message to the local DB synchronously
563
- // before attempting any IMAP/SMTP work — so if the IPC reached Node at
564
- // all, the message is queued and the background worker will retry it
565
- // with backoff (X-Mailx-Retry header, 60s settling delay, up to 10
566
- // attempts). Treating that as a failure that demands a re-click leads
567
- // to duplicate sends. Tell the user honestly: "probably queued, check
568
- // Outbox before retrying."
569
574
  if (msg.startsWith("mailxapi timeout")) {
570
- alert("Send is taking longer than expected.\n\n" +
571
- "The message has likely been queued and will be retried in the background. " +
572
- "Check the Outbox folder before clicking Send again clicking Send now may " +
573
- "produce a duplicate.\n\n" +
574
- "Your draft is preserved either way.");
575
+ // Disk-first queueing means the .ltr file should be on disk even
576
+ // if the IPC reply itself was lost. Tell the user where to look.
577
+ alert(`Send IPC timed out after 120s.\n\n` +
578
+ `If the disk-first queue was reached, the message is in:\n` +
579
+ `~/.mailx/outbox/${getFromAccountId()}/*.ltr\n\n` +
580
+ `Don't click Send again — it could double-send. Check that ` +
581
+ `directory first (and the log) before deciding.\n\n` +
582
+ `Your draft is preserved either way.`);
575
583
  }
576
584
  else {
577
585
  alert(`Send failed: ${msg}`);
@@ -62,6 +62,9 @@ export function reauthenticate(accountId) {
62
62
  export function getSyncPending() {
63
63
  return ipc().getSyncPending();
64
64
  }
65
+ export function getOutboxStatus() {
66
+ return ipc().getOutboxStatus();
67
+ }
65
68
  export function searchContacts(query) {
66
69
  return ipc().searchContacts(query);
67
70
  }
@@ -140,6 +140,7 @@
140
140
  syncAll: function() { return callNode("syncAll"); },
141
141
  syncAccount: function(accountId) { return callNode("syncAccount", { accountId: accountId }); },
142
142
  getSyncPending: function() { return callNode("getSyncPending"); },
143
+ getOutboxStatus: function() { return callNode("getOutboxStatus"); },
143
144
  reauthenticate: function(accountId) { return callNode("reauthenticate", { accountId: accountId }); },
144
145
 
145
146
  // Bulk operations
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.338",
3
+ "version": "1.0.340",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -25,7 +25,7 @@
25
25
  "@bobfrankston/miscinfo": "^1.0.9",
26
26
  "@bobfrankston/oauthsupport": "^1.0.24",
27
27
  "@bobfrankston/msger": "^0.1.344",
28
- "@bobfrankston/mailx-host": "^0.1.3",
28
+ "@bobfrankston/mailx-host": "^0.1.4",
29
29
  "@capacitor/android": "^8.3.0",
30
30
  "@capacitor/cli": "^8.3.0",
31
31
  "@capacitor/core": "^8.3.0",
@@ -89,7 +89,7 @@
89
89
  "@bobfrankston/miscinfo": "^1.0.9",
90
90
  "@bobfrankston/oauthsupport": "^1.0.24",
91
91
  "@bobfrankston/msger": "^0.1.344",
92
- "@bobfrankston/mailx-host": "^0.1.3",
92
+ "@bobfrankston/mailx-host": "^0.1.4",
93
93
  "@capacitor/android": "^8.3.0",
94
94
  "@capacitor/cli": "^8.3.0",
95
95
  "@capacitor/core": "^8.3.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-host",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Host abstraction for mailx — dispatches to msger or msgview",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -0,0 +1,14 @@
1
+ // Stub for `marked` — pulled in transitively via msger → msgcommon → markdown.ts.
2
+ // mailx-host doesn't use marked at runtime; the type chain only references it
3
+ // because msgcommon ships markdown.ts alongside its other modules. Without
4
+ // this shim, tsc errors with "Cannot find module 'marked'" while building
5
+ // mailx-host. The proper fix is `npm install` inside msgcommon (which has
6
+ // marked declared as a dep) or installing marked at the mailx root; this
7
+ // shim is a fallback so the build doesn't block on either.
8
+ declare module "marked" {
9
+ export const marked: {
10
+ parse(input: string, opts?: any): string | Promise<string>;
11
+ Renderer: any;
12
+ [k: string]: any;
13
+ };
14
+ }
@@ -23,6 +23,24 @@ export interface ImapManagerEvents {
23
23
  * the UI flip a row's "not-downloaded" indicator without re-rendering. */
24
24
  bodyCached: (accountId: string, uid: number) => void;
25
25
  syncActionFailed: (accountId: string, action: string, uid: number, error: string) => void;
26
+ /** Fired whenever the outbox queue depth or state changes (file added,
27
+ * file sent and removed, retry attempted). Lets the UI show a persistent
28
+ * queue-status indicator without polling. Aggregate status across all
29
+ * accounts is included so the listener doesn't have to reassemble it. */
30
+ outboxStatus: (status: OutboxStatus) => void;
31
+ }
32
+ /** Per-account outbox queue breakdown, plus totals for the UI. */
33
+ export interface OutboxStatus {
34
+ total: number;
35
+ retrying: number;
36
+ claimed: number;
37
+ oldestAgeSec: number;
38
+ maxAttempts: number;
39
+ perAccount: Record<string, {
40
+ total: number;
41
+ retrying: number;
42
+ claimed: number;
43
+ }>;
26
44
  }
27
45
  export declare class ImapManager extends EventEmitter {
28
46
  private configs;
@@ -231,6 +249,12 @@ export declare class ImapManager extends EventEmitter {
231
249
  * sync_actions "send" branch was removed because it duplicated the same
232
250
  * work and risked double-send when both paths fired on the same message. */
233
251
  queueOutgoingLocal(accountId: string, rawMessage: string): void;
252
+ /** Scan the local outbox + sending/queued dirs and return counts + age.
253
+ * Cheap — a handful of readdir + head-read per file. Called by both the
254
+ * polling UI (status bar) and emitted as an event after queue mutations. */
255
+ getOutboxStatus(): OutboxStatus;
256
+ /** Emit outboxStatus now. Call after any queue mutation. */
257
+ private emitOutboxStatus;
234
258
  /** Guard against concurrent processSendActions for the same account */
235
259
  private sendingAccounts;
236
260
  /** Process local send actions — APPEND to Outbox, which the outbox worker then sends */
@@ -1640,10 +1640,42 @@ export class ImapManager extends EventEmitter {
1640
1640
  // "gmail: 17266796 bodies cached" in the logs, which is the counter
1641
1641
  // spinning on the same 100 rows.
1642
1642
  if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
1643
- const existingPath = this.bodyStore.getMessagePath?.(accountId, folderId, uid);
1644
- if (existingPath)
1645
- this.db.updateBodyPath(accountId, uid, existingPath);
1646
- return this.bodyStore.getMessage(accountId, folderId, uid);
1643
+ // COMINGLING GUARD: verify the cached body's Message-ID matches the
1644
+ // DB row's messageId. If UIDVALIDITY changed server-side (mailbox
1645
+ // recreated, server quirk) the same integer UID can point at a
1646
+ // different message — the on-disk .eml becomes stale but hasMessage()
1647
+ // still returns true. User-reported: "Peter Hoddie letter comingled
1648
+ // with a much older letter." Check fixes it regardless of root cause.
1649
+ const cached = await this.bodyStore.getMessage(accountId, folderId, uid);
1650
+ const envelope = this.db.getMessageByUid(accountId, uid, folderId);
1651
+ const expectedId = envelope?.messageId || "";
1652
+ if (expectedId) {
1653
+ // Scan headers only — Message-ID should land in the first few KB.
1654
+ const head = cached.subarray(0, Math.min(cached.length, 16 * 1024)).toString("utf-8");
1655
+ const m = head.match(/^Message-ID:\s*<([^>\r\n]+)>/im);
1656
+ const cachedId = m ? `<${m[1]}>` : "";
1657
+ if (cachedId && expectedId && cachedId !== expectedId) {
1658
+ console.error(` [body] COMINGLING DETECTED ${accountId}/${folderId}/${uid}: expected ${expectedId}, cached ${cachedId} — dropping cache, re-fetching`);
1659
+ try {
1660
+ await this.bodyStore.deleteMessage(accountId, folderId, uid);
1661
+ }
1662
+ catch { /* */ }
1663
+ // fall through to re-fetch path
1664
+ }
1665
+ else {
1666
+ const existingPath = this.bodyStore.getMessagePath?.(accountId, folderId, uid);
1667
+ if (existingPath)
1668
+ this.db.updateBodyPath(accountId, uid, existingPath);
1669
+ return cached;
1670
+ }
1671
+ }
1672
+ else {
1673
+ // No messageId on the DB row (shouldn't happen but be permissive).
1674
+ const existingPath = this.bodyStore.getMessagePath?.(accountId, folderId, uid);
1675
+ if (existingPath)
1676
+ this.db.updateBodyPath(accountId, uid, existingPath);
1677
+ return cached;
1678
+ }
1647
1679
  }
1648
1680
  if (!this.configs.has(accountId))
1649
1681
  return null;
@@ -2421,15 +2453,122 @@ export class ImapManager extends EventEmitter {
2421
2453
  * sync_actions "send" branch was removed because it duplicated the same
2422
2454
  * work and risked double-send when both paths fired on the same message. */
2423
2455
  queueOutgoingLocal(accountId, rawMessage) {
2456
+ // Loud logging so a "vanished message" report is diagnosable from the log alone.
2424
2457
  const outboxDir = path.join(getConfigDir(), "outbox", accountId);
2425
- fs.mkdirSync(outboxDir, { recursive: true });
2458
+ try {
2459
+ fs.mkdirSync(outboxDir, { recursive: true });
2460
+ }
2461
+ catch (e) {
2462
+ console.error(` [outbox] FAIL mkdirSync ${outboxDir}: ${e?.message || e}`);
2463
+ throw new Error(`Cannot create outbox dir ${outboxDir}: ${e?.message || e}`);
2464
+ }
2426
2465
  const now = new Date();
2427
2466
  const pad2 = (n) => String(n).padStart(2, "0");
2428
2467
  const filename = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}_${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}-${String(Math.floor(Math.random() * 10000)).padStart(4, "0")}.ltr`;
2429
2468
  const filePath = path.join(outboxDir, filename);
2430
- fs.writeFileSync(filePath, rawMessage);
2431
- console.log(` [outbox] Queued ${filePath}`);
2432
- this.processLocalQueue(accountId).catch((e) => console.error(` [outbox] processLocalQueue error: ${e?.message || e}`));
2469
+ try {
2470
+ fs.writeFileSync(filePath, rawMessage);
2471
+ }
2472
+ catch (e) {
2473
+ console.error(` [outbox] FAIL writeFileSync ${filePath}: ${e?.message || e}`);
2474
+ throw new Error(`Cannot write outbox file ${filePath}: ${e?.message || e}`);
2475
+ }
2476
+ // Immediate readback verification — if this DOESN'T print, the user's
2477
+ // "neither in outbox nor file system" report has a real explanation.
2478
+ const written = fs.existsSync(filePath);
2479
+ const size = written ? fs.statSync(filePath).size : 0;
2480
+ console.log(` [outbox] WROTE ${filePath} (${size} bytes, exists=${written})`);
2481
+ this.emitOutboxStatus();
2482
+ // CRITICAL: defer to next tick. processLocalQueue runs ~30+ lines
2483
+ // of synchronous fs work BEFORE its first await — calling it inline
2484
+ // blocks the IPC ack on all that work.
2485
+ setImmediate(() => {
2486
+ this.processLocalQueue(accountId)
2487
+ .catch((e) => console.error(` [outbox] processLocalQueue error: ${e?.message || e}`))
2488
+ .finally(() => this.emitOutboxStatus());
2489
+ });
2490
+ }
2491
+ /** Scan the local outbox + sending/queued dirs and return counts + age.
2492
+ * Cheap — a handful of readdir + head-read per file. Called by both the
2493
+ * polling UI (status bar) and emitted as an event after queue mutations. */
2494
+ getOutboxStatus() {
2495
+ const configDir = getConfigDir();
2496
+ const perAccount = {};
2497
+ let total = 0;
2498
+ let retrying = 0;
2499
+ let claimed = 0;
2500
+ let oldestMs = 0;
2501
+ let maxAttempts = 0;
2502
+ const now = Date.now();
2503
+ const scan = (accountId, dir) => {
2504
+ if (!fs.existsSync(dir))
2505
+ return;
2506
+ for (const f of fs.readdirSync(dir)) {
2507
+ const isClaim = /\.sending-[^-]+-\d+$/.test(f);
2508
+ const isActive = isClaim || f.endsWith(".ltr") || f.endsWith(".eml");
2509
+ if (!isActive)
2510
+ continue;
2511
+ total++;
2512
+ const acctSlot = perAccount[accountId] ||= { total: 0, retrying: 0, claimed: 0 };
2513
+ acctSlot.total++;
2514
+ if (isClaim) {
2515
+ claimed++;
2516
+ acctSlot.claimed++;
2517
+ }
2518
+ const fp = path.join(dir, f);
2519
+ try {
2520
+ const st = fs.statSync(fp);
2521
+ const age = now - st.mtimeMs;
2522
+ if (age > oldestMs)
2523
+ oldestMs = age;
2524
+ // Only read header region to count retry attempts — tiny I/O.
2525
+ const fd = fs.openSync(fp, "r");
2526
+ try {
2527
+ const buf = Buffer.alloc(4096);
2528
+ const n = fs.readSync(fd, buf, 0, 4096, 0);
2529
+ const head = buf.slice(0, n).toString("utf-8");
2530
+ const info = parseRetryInfo(head);
2531
+ if (info.attemptCount > 0) {
2532
+ retrying++;
2533
+ acctSlot.retrying++;
2534
+ }
2535
+ if (info.attemptCount > maxAttempts)
2536
+ maxAttempts = info.attemptCount;
2537
+ }
2538
+ finally {
2539
+ fs.closeSync(fd);
2540
+ }
2541
+ }
2542
+ catch { /* ignore per-file errors */ }
2543
+ }
2544
+ };
2545
+ const outboxRoot = path.join(configDir, "outbox");
2546
+ const sendingRoot = path.join(configDir, "sending");
2547
+ try {
2548
+ if (fs.existsSync(outboxRoot)) {
2549
+ for (const acct of fs.readdirSync(outboxRoot))
2550
+ scan(acct, path.join(outboxRoot, acct));
2551
+ }
2552
+ if (fs.existsSync(sendingRoot)) {
2553
+ for (const acct of fs.readdirSync(sendingRoot)) {
2554
+ scan(acct, path.join(sendingRoot, acct, "queued"));
2555
+ }
2556
+ }
2557
+ }
2558
+ catch { /* */ }
2559
+ return {
2560
+ total, retrying, claimed,
2561
+ oldestAgeSec: Math.floor(oldestMs / 1000),
2562
+ maxAttempts,
2563
+ perAccount,
2564
+ };
2565
+ }
2566
+ /** Emit outboxStatus now. Call after any queue mutation. */
2567
+ emitOutboxStatus() {
2568
+ try {
2569
+ this.emit("outboxStatus", this.getOutboxStatus());
2570
+ }
2571
+ catch { /* */ }
2433
2572
  }
2434
2573
  /** Guard against concurrent processSendActions for the same account */
2435
2574
  sendingAccounts = new Set();
@@ -2948,6 +3087,8 @@ export class ImapManager extends EventEmitter {
2948
3087
  }
2949
3088
  }
2950
3089
  }
3090
+ // After each full tick, refresh the UI indicator.
3091
+ this.emitOutboxStatus();
2951
3092
  };
2952
3093
  setTimeout(() => processAll(), 3000);
2953
3094
  this.outboxInterval = setInterval(processAll, 10000);
@@ -2969,10 +3110,20 @@ export class ImapManager extends EventEmitter {
2969
3110
  const files = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc"];
2970
3111
  const configDir = getConfigDir();
2971
3112
  const debounce = new Map();
3113
+ // Cache the last-seen normalized content per file. fs.watch fires on
3114
+ // metadata-only events (atime, attrib change) AND on no-op rewrites
3115
+ // that land identical bytes — both would fire a spurious banner.
3116
+ // Compare after the debounce window and only emit on a real change.
3117
+ const normalize = (s) => s.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/[ \t\r\n]+$/, "");
3118
+ const lastContent = new Map();
2972
3119
  for (const filename of files) {
2973
3120
  const full = path.join(configDir, filename);
2974
3121
  if (!fs.existsSync(full))
2975
3122
  continue;
3123
+ try {
3124
+ lastContent.set(filename, normalize(fs.readFileSync(full, "utf-8")));
3125
+ }
3126
+ catch { /* */ }
2976
3127
  try {
2977
3128
  const watcher = fs.watch(full, () => {
2978
3129
  const prev = debounce.get(filename);
@@ -2980,7 +3131,32 @@ export class ImapManager extends EventEmitter {
2980
3131
  clearTimeout(prev);
2981
3132
  debounce.set(filename, setTimeout(() => {
2982
3133
  debounce.delete(filename);
2983
- console.log(` [watch] ${filename} changed`);
3134
+ let current = "";
3135
+ try {
3136
+ current = normalize(fs.readFileSync(full, "utf-8"));
3137
+ }
3138
+ catch { /* missing */ }
3139
+ const previous = lastContent.get(filename) || "";
3140
+ if (current === previous) {
3141
+ console.log(` [watch] ${filename} fs.watch fired but content unchanged — no banner`);
3142
+ return;
3143
+ }
3144
+ // Log a short diff hint so repeat-firings are diagnosable.
3145
+ const prevSize = previous.length;
3146
+ const curSize = current.length;
3147
+ const firstDiff = (() => {
3148
+ const n = Math.min(prevSize, curSize);
3149
+ for (let i = 0; i < n; i++)
3150
+ if (previous[i] !== current[i])
3151
+ return i;
3152
+ return n;
3153
+ })();
3154
+ const prevSnip = previous.slice(Math.max(0, firstDiff - 20), firstDiff + 40).replace(/\n/g, "\\n");
3155
+ const curSnip = current.slice(Math.max(0, firstDiff - 20), firstDiff + 40).replace(/\n/g, "\\n");
3156
+ console.log(` [watch] ${filename} changed: size ${prevSize}→${curSize}, first diff at byte ${firstDiff}`);
3157
+ console.log(` [watch] was: ${JSON.stringify(prevSnip)}`);
3158
+ console.log(` [watch] now: ${JSON.stringify(curSnip)}`);
3159
+ lastContent.set(filename, current);
2984
3160
  this.emit("configChanged", filename);
2985
3161
  }, 500));
2986
3162
  });
@@ -2999,11 +3175,9 @@ export class ImapManager extends EventEmitter {
2999
3175
  // config.jsonc is per-machine / local-only — never polled.
3000
3176
  const cloudFiles = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc"];
3001
3177
  const CLOUD_POLL_MS = 3 * 60 * 1000;
3002
- // Normalize before comparing: strip BOM, CRLF→LF, trailing whitespace.
3003
- // Without this, cloud round-trips that re-wrap newlines or add a
3004
- // trailing newline trigger a local overwrite every poll, which fires
3005
- // fs.watch, which shows the spurious "accounts.jsonc changed" banner.
3006
- const normalize = (s) => s.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/[ \t\r\n]+$/, "");
3178
+ // normalize() reused from the fs.watch block above — same intent:
3179
+ // cloud round-trips that re-wrap newlines / add a trailing newline are
3180
+ // semantically identical; don't overwrite local on those.
3007
3181
  const pollCloud = async () => {
3008
3182
  let cloudRead;
3009
3183
  let parseJsonc;
@@ -34,6 +34,8 @@ export declare class MailxService {
34
34
  getSyncPending(): {
35
35
  pending: number;
36
36
  };
37
+ /** Outbox queue depth + retry status for the UI status bar. Cheap to call. */
38
+ getOutboxStatus(): any;
37
39
  syncAll(): Promise<void>;
38
40
  syncAccount(accountId: string): Promise<void>;
39
41
  /** Force re-authentication for an account (deletes token, opens browser consent) */
@@ -409,6 +409,10 @@ export class MailxService {
409
409
  getSyncPending() {
410
410
  return { pending: this.db.getTotalPendingSyncCount() };
411
411
  }
412
+ /** Outbox queue depth + retry status for the UI status bar. Cheap to call. */
413
+ getOutboxStatus() {
414
+ return this.imapManager.getOutboxStatus();
415
+ }
412
416
  async syncAll() {
413
417
  await this.imapManager.syncAll();
414
418
  }
@@ -440,6 +444,9 @@ export class MailxService {
440
444
  // locally. Everything else (contacts recording, IMAP APPEND,
441
445
  // SMTP) happens after the IPC ACK. Settings come from cache so
442
446
  // a stalled GDrive mount doesn't block the send.
447
+ const t0 = Date.now();
448
+ const lap = (label) => console.log(` [send] +${Date.now() - t0}ms ${label}`);
449
+ console.log(` [send] ENTRY from=${msg?.from} to=${JSON.stringify(msg?.to)} subject="${msg?.subject}" attachments=${msg?.attachments?.length || 0}`);
443
450
  const accounts = this.getCachedAccounts();
444
451
  let account = accounts.find(a => a.id === msg.from);
445
452
  if (!account) {
@@ -447,8 +454,12 @@ export class MailxService {
447
454
  this._accountsCache = null;
448
455
  account = this.getCachedAccounts().find(a => a.id === msg.from);
449
456
  }
450
- if (!account)
457
+ if (!account) {
458
+ const ids = accounts.map(a => a.id).join(", ");
459
+ console.error(` [send] FAIL: Unknown account "${msg.from}". Known accounts: [${ids}]`);
451
460
  throw new Error(`Unknown account: ${msg.from}`);
461
+ }
462
+ lap("account resolved");
452
463
  // Vet every recipient address — refuse to send if any field contains a
453
464
  // non-email (e.g. "Bob Frankston <Bob Frankston>" from a bad contact
454
465
  // autocomplete). This catches garbage BEFORE it hits SMTP, where the
@@ -531,7 +542,9 @@ export class MailxService {
531
542
  ].join("\r\n");
532
543
  rawMessage = `${headers}\r\n\r\n${bodyEncoded}`;
533
544
  }
545
+ lap(`MIME assembled (${rawMessage.length} bytes${hasAttachments ? `, ${msg.attachments.length} attachment(s)` : ""})`);
534
546
  this.imapManager.queueOutgoingLocal(account.id, rawMessage);
547
+ lap("queued to disk");
535
548
  console.log(` Queued locally: ${msg.subject} via ${account.id} from ${fromHeader}`);
536
549
  // Contacts recording is off the critical path — deferred until after
537
550
  // the IPC ACK so a slow DB write can't stall the send.
@@ -604,8 +617,13 @@ export class MailxService {
604
617
  /** Move messages to the account's configured spam folder (accounts.jsonc "spam" path).
605
618
  * Throws if the account has no spam folder configured or the folder doesn't exist locally. */
606
619
  async markAsSpamMessages(accountId, uids) {
607
- const settings = loadSettings();
608
- const account = settings.accounts.find(a => a.id === accountId);
620
+ // Cached accounts — same reason as send/saveDraft: a stalled GDrive
621
+ // mount could turn `Mark as spam` into a 120s IPC timeout.
622
+ let account = this.getCachedAccounts().find(a => a.id === accountId);
623
+ if (!account) {
624
+ this._accountsCache = null;
625
+ account = this.getCachedAccounts().find(a => a.id === accountId);
626
+ }
609
627
  if (!account)
610
628
  throw new Error(`Account ${accountId} not found`);
611
629
  const spamPath = account.spam;
@@ -760,8 +778,15 @@ export class MailxService {
760
778
  // a prerequisite. X-Mailx-Draft-ID is carried in the MIME headers
761
779
  // so the reconciler can de-duplicate on the server by header search
762
780
  // even without the previousDraftUid round-trip.
763
- const settings = loadSettings();
764
- const account = settings.accounts.find(a => a.id === accountId);
781
+ // Account lookup uses the cached list — `loadSettings()` reads
782
+ // accounts.jsonc from the GDrive mount and could itself stall for
783
+ // 120s, which was the actual `mailxapi timeout: saveDraft` source
784
+ // (the IMAP work was fire-and-forget, but loadSettings wasn't).
785
+ let account = this.getCachedAccounts().find(a => a.id === accountId);
786
+ if (!account) {
787
+ this._accountsCache = null;
788
+ account = this.getCachedAccounts().find(a => a.id === accountId);
789
+ }
765
790
  if (!account)
766
791
  throw new Error(`Unknown account: ${accountId}`);
767
792
  // Generate or reuse a stable draft ID for dedup
@@ -89,6 +89,8 @@ async function dispatchAction(svc, action, p) {
89
89
  return { ok: true };
90
90
  case "getSyncPending":
91
91
  return svc.getSyncPending();
92
+ case "getOutboxStatus":
93
+ return svc.getOutboxStatus();
92
94
  case "reauthenticate":
93
95
  return { ok: await svc.reauthenticate(p.accountId) };
94
96
  // Search & contacts