@bobfrankston/mailx 1.0.339 → 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.339",
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",
@@ -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 */
@@ -2453,15 +2453,122 @@ export class ImapManager extends EventEmitter {
2453
2453
  * sync_actions "send" branch was removed because it duplicated the same
2454
2454
  * work and risked double-send when both paths fired on the same message. */
2455
2455
  queueOutgoingLocal(accountId, rawMessage) {
2456
+ // Loud logging so a "vanished message" report is diagnosable from the log alone.
2456
2457
  const outboxDir = path.join(getConfigDir(), "outbox", accountId);
2457
- 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
+ }
2458
2465
  const now = new Date();
2459
2466
  const pad2 = (n) => String(n).padStart(2, "0");
2460
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`;
2461
2468
  const filePath = path.join(outboxDir, filename);
2462
- fs.writeFileSync(filePath, rawMessage);
2463
- console.log(` [outbox] Queued ${filePath}`);
2464
- 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 { /* */ }
2465
2572
  }
2466
2573
  /** Guard against concurrent processSendActions for the same account */
2467
2574
  sendingAccounts = new Set();
@@ -2980,6 +3087,8 @@ export class ImapManager extends EventEmitter {
2980
3087
  }
2981
3088
  }
2982
3089
  }
3090
+ // After each full tick, refresh the UI indicator.
3091
+ this.emitOutboxStatus();
2983
3092
  };
2984
3093
  setTimeout(() => processAll(), 3000);
2985
3094
  this.outboxInterval = setInterval(processAll, 10000);
@@ -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