@bobfrankston/mailx 1.0.437 → 1.0.438

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
@@ -1250,6 +1250,11 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
1250
1250
  imapManager.on("syncActionFailed", (accountId, action, uid, error) => {
1251
1251
  handle.send({ _event: "syncActionFailed", type: "syncActionFailed", accountId, action, uid, error });
1252
1252
  });
1253
+ // External-edit (Word) save events. The service watches the temp file
1254
+ // and emits this when Word writes; compose.ts listens and reloads Quill.
1255
+ imapManager.on("wordEditUpdated", (payload) => {
1256
+ handle.send({ _event: "wordEditUpdated", type: "wordEditUpdated", ...payload });
1257
+ });
1253
1258
  // Cloud-write/read failures from mailx-settings → push to UI as a banner so
1254
1259
  // silent fall-back-to-local can no longer swallow Drive errors.
1255
1260
  const { onCloudError } = await import("@bobfrankston/mailx-settings");
package/client/app.js CHANGED
@@ -2813,6 +2813,34 @@ optEditorTiptap?.addEventListener("change", () => {
2813
2813
  if (optEditorTiptap.checked)
2814
2814
  saveEditorSetting("tiptap");
2815
2815
  });
2816
+ // External editor preference (Edit-in-Word handoff target). Stored under
2817
+ // settings.externalEditor so the service can read it via loadSettings().
2818
+ // "auto" tries Word → LibreOffice → OS default; explicit values force
2819
+ // that editor (still falling back to OS default if it isn't installed).
2820
+ const optExtEditAuto = document.getElementById("opt-extedit-auto");
2821
+ const optExtEditWord = document.getElementById("opt-extedit-word");
2822
+ const optExtEditLibre = document.getElementById("opt-extedit-libre");
2823
+ getSettings().then((s) => {
2824
+ const v = s.externalEditor || "auto";
2825
+ if (optExtEditAuto)
2826
+ optExtEditAuto.checked = v === "auto";
2827
+ if (optExtEditWord)
2828
+ optExtEditWord.checked = v === "word";
2829
+ if (optExtEditLibre)
2830
+ optExtEditLibre.checked = v === "libreoffice";
2831
+ }).catch(() => { });
2832
+ function saveExtEditor(v) {
2833
+ getSettings().then((settings) => {
2834
+ settings.externalEditor = v;
2835
+ saveSettings(settings);
2836
+ }).catch(() => { });
2837
+ }
2838
+ optExtEditAuto?.addEventListener("change", () => { if (optExtEditAuto.checked)
2839
+ saveExtEditor("auto"); });
2840
+ optExtEditWord?.addEventListener("change", () => { if (optExtEditWord.checked)
2841
+ saveExtEditor("word"); });
2842
+ optExtEditLibre?.addEventListener("change", () => { if (optExtEditLibre.checked)
2843
+ saveExtEditor("libreoffice"); });
2816
2844
  // ── AI feature toggles ──
2817
2845
  // One umbrella settings record (AutocompleteSettings) holds the provider config
2818
2846
  // + per-feature on/off flags. All features default OFF — user must opt into
@@ -2,7 +2,7 @@
2
2
  * Message viewer component -- displays full message in sandboxed iframe.
3
3
  * Subscribes to message-state: clears when selected becomes null.
4
4
  */
5
- import { getMessage, updateFlags, allowRemoteContent, getAttachment, addContact, listContacts, upsertContact, unsubscribeOneClick } from "../lib/api-client.js";
5
+ import { getMessage, updateFlags, allowRemoteContent, flagSenderOrDomain, getAttachment, addContact, listContacts, upsertContact, unsubscribeOneClick } from "../lib/api-client.js";
6
6
  import { showContextMenu } from "./context-menu.js";
7
7
  import * as state from "../lib/message-state.js";
8
8
  /** Currently displayed message (for reply/forward) */
@@ -547,10 +547,14 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
547
547
  const deliveredTo = msg.deliveredTo || "";
548
548
  const toAddr = msg.to?.[0]?.address || "";
549
549
  const returnPath = msg.returnPath || "";
550
+ const isFlagged = !!msg.isFlagged;
550
551
  const banner = document.createElement("div");
551
- banner.className = "mv-remote-banner";
552
+ banner.className = "mv-remote-banner" + (isFlagged ? " mv-remote-banner-flagged" : "");
552
553
  banner.innerHTML =
553
- `<div class="mv-rb-summary">` +
554
+ (isFlagged
555
+ ? `<div class="mv-rb-flagged">⚠ FLAGGED: this sender or domain is on your flagged list</div>`
556
+ : "") +
557
+ `<div class="mv-rb-summary">` +
554
558
  `<span class="mv-rb-toggle">&#x25B8;</span>` +
555
559
  `<span>Remote content blocked</span>` +
556
560
  `<span class="mv-rb-buttons">` +
@@ -567,6 +571,10 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
567
571
  (returnPath && returnPath !== senderAddr ? `<div><span class="mv-rb-label">Return-Path:</span> ${escapeText(returnPath)}</div>` : "") +
568
572
  `</div>` +
569
573
  (deliveredTo || toAddr ? `<div class="mv-rb-actions"><button id="btn-allow-to">Always allow to: ${escapeText(deliveredTo || toAddr)}</button></div>` : "") +
574
+ `<div class="mv-rb-actions">` +
575
+ `<button id="btn-flag-sender" class="mv-rb-flag-btn" title="${escapeText(senderAddr)}">${isFlagged ? "Unflag" : "Flag"} sender</button>` +
576
+ (senderDomain ? `<button id="btn-flag-domain" class="mv-rb-flag-btn" title="*@${escapeText(senderDomain)}">Flag domain *@${escapeText(senderDomain)}</button>` : "") +
577
+ `</div>` +
570
578
  `<div class="mv-rb-actions"><button id="btn-edit-allowlist" title="View / edit the full allowlist">Edit allowlist…</button></div>` +
571
579
  `</div>`;
572
580
  bodyEl.appendChild(banner);
@@ -609,6 +617,35 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
609
617
  await allowRemoteContent("recipient", addr);
610
618
  loadRemote();
611
619
  });
620
+ // Flag (or unflag) sender / domain — toggles the allowlist's
621
+ // flaggedSenders / flaggedDomains lists. Subsequent messages
622
+ // from this sender or domain show a red FLAGGED warning at the
623
+ // top of this banner. Doesn't load remote content; this is a
624
+ // signal-to-the-user feature, orthogonal to allow/block.
625
+ const onFlagToggle = async (type, value) => {
626
+ if (!value)
627
+ return;
628
+ try {
629
+ const result = await flagSenderOrDomain(type, value);
630
+ const status = document.getElementById("status-sync");
631
+ if (status)
632
+ status.textContent = result.flagged
633
+ ? `Flagged ${type}: ${value}`
634
+ : `Unflagged ${type}: ${value}`;
635
+ // Re-render this message so the banner picks up the new
636
+ // flagged state without the user having to reselect.
637
+ if (currentMessage) {
638
+ showMessage(currentAccountId, currentMessage.uid, currentMessage.folderId, specialUse).catch(() => { });
639
+ }
640
+ }
641
+ catch (e) {
642
+ const status = document.getElementById("status-sync");
643
+ if (status)
644
+ status.textContent = `Flag failed: ${e?.message || e}`;
645
+ }
646
+ };
647
+ banner.querySelector("#btn-flag-sender")?.addEventListener("click", () => onFlagToggle("sender", senderAddr));
648
+ banner.querySelector("#btn-flag-domain")?.addEventListener("click", () => onFlagToggle("domain", senderDomain));
612
649
  // "Edit allowlist…" — fires a document-level event that app.ts
613
650
  // listens for and opens the JSONC editor pre-selected to
614
651
  // allowlist.jsonc. Keeps message-viewer free of the editor import.
@@ -42,6 +42,7 @@
42
42
  <button class="tb-btn" id="btn-send">Send</button>
43
43
  <button class="tb-btn" id="btn-attach">Attach</button>
44
44
  <input type="file" id="compose-file" multiple hidden>
45
+ <button class="tb-btn" id="btn-edit-in-word" title="Open the body in Microsoft Word — saves in Word reload back into the editor">Edit in Word</button>
45
46
  <button class="tb-btn" id="btn-discard">Discard</button>
46
47
  <span id="compose-status" class="compose-status"></span>
47
48
  </div>
@@ -4,7 +4,7 @@
4
4
  * Receives init data via window.opener.postMessage or URL params.
5
5
  */
6
6
  import { createEditor } from "./editor.js";
7
- import { getSettings, getAccounts, searchContacts, saveDraft as apiSaveDraft, deleteDraft, logClientEvent, addPreferredContact, addToDenylist } from "../lib/api-client.js";
7
+ import { getSettings, getAccounts, searchContacts, saveDraft as apiSaveDraft, deleteDraft, logClientEvent, addPreferredContact, addToDenylist, openInWord, closeWordEdit, onEvent } from "../lib/api-client.js";
8
8
  import { showContextMenu } from "../components/context-menu.js";
9
9
  // Very first line the iframe runs — if this doesn't reach Node, the iframe
10
10
  // itself isn't loading or the bridge is completely broken.
@@ -1076,6 +1076,54 @@ document.getElementById("btn-attach")?.addEventListener("click", () => {
1076
1076
  attachJustClicked = Date.now();
1077
1077
  fileInput?.click();
1078
1078
  });
1079
+ // ── Edit in Word (external editor handoff) ──
1080
+ //
1081
+ // Click writes the current body to a temp file, opens it in Word (or the
1082
+ // platform fallback), and watches the file. When Word saves, the service
1083
+ // emits `wordEditUpdated` and we replace the editor's HTML with the new
1084
+ // content. The editId is per-compose-window — closeWordEdit cleans up the
1085
+ // temp file when the window closes or the message is sent.
1086
+ let wordEditId = null;
1087
+ document.getElementById("btn-edit-in-word")?.addEventListener("click", async () => {
1088
+ if (!wordEditId)
1089
+ wordEditId = `compose-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1090
+ showDraftStatus("Opening in Word…", false);
1091
+ try {
1092
+ const result = await openInWord(wordEditId, editor.getHtml());
1093
+ if (!result.ok || result.opener === "none") {
1094
+ showDraftStatus("Couldn't launch an editor. Install Word, LibreOffice, or set a default for .html.", true);
1095
+ return;
1096
+ }
1097
+ const label = result.opener === "word" ? "Word" :
1098
+ result.opener === "libreoffice" ? "LibreOffice" :
1099
+ "your default editor";
1100
+ showDraftStatus(`Editing in ${label} — saves there will reload here.`, false);
1101
+ }
1102
+ catch (e) {
1103
+ showDraftStatus(`Edit-in-Word failed: ${e?.message || e}`, true);
1104
+ }
1105
+ });
1106
+ // Listen for external-editor saves. Only react to events for this compose's
1107
+ // editId — multiple compose windows can be open and should not stomp each
1108
+ // other's bodies.
1109
+ onEvent((ev) => {
1110
+ if (ev?.type !== "wordEditUpdated")
1111
+ return;
1112
+ if (!wordEditId || ev.editId !== wordEditId)
1113
+ return;
1114
+ try {
1115
+ editor.setHtml(ev.html || "");
1116
+ showDraftStatus("Reloaded edits from external editor.", false);
1117
+ scheduleDraftSave();
1118
+ }
1119
+ catch (e) {
1120
+ showDraftStatus(`Reload failed: ${e?.message || e}`, true);
1121
+ }
1122
+ });
1123
+ window.addEventListener("beforeunload", () => {
1124
+ if (wordEditId)
1125
+ closeWordEdit(wordEditId).catch(() => { });
1126
+ });
1079
1127
  async function ingestFiles(files) {
1080
1128
  for (const file of Array.from(files)) {
1081
1129
  const buf = await file.arrayBuffer();
package/client/index.html CHANGED
@@ -47,6 +47,11 @@
47
47
  <label class="tb-menu-item"><input type="radio" name="opt-editor" value="quill" id="opt-editor-quill" checked> Quill</label>
48
48
  <label class="tb-menu-item"><input type="radio" name="opt-editor" value="tiptap" id="opt-editor-tiptap"> tiptap</label>
49
49
  <hr class="tb-menu-sep">
50
+ <span class="tb-menu-label" title="Which app the compose 'Edit in Word' button hands the body off to. Auto = Word first, then LibreOffice, then OS default.">External editor</span>
51
+ <label class="tb-menu-item"><input type="radio" name="opt-extedit" value="auto" id="opt-extedit-auto" checked> Auto (Word → LibreOffice → default)</label>
52
+ <label class="tb-menu-item"><input type="radio" name="opt-extedit" value="word" id="opt-extedit-word"> Microsoft Word</label>
53
+ <label class="tb-menu-item"><input type="radio" name="opt-extedit" value="libreoffice" id="opt-extedit-libre"> LibreOffice Writer</label>
54
+ <hr class="tb-menu-sep">
50
55
  <label class="tb-menu-item" title="Ghost-text completions while composing — Ollama / Claude / OpenAI back-end, off by default"><input type="checkbox" id="opt-autocomplete"> AI autocomplete</label>
51
56
  <label class="tb-menu-item" title="Right-click in message body → Translate"><input type="checkbox" id="opt-ai-translate"> AI translate (off by default)</label>
52
57
  <label class="tb-menu-item" title="Right-click in compose editor → Proofread (when wired)"><input type="checkbox" id="opt-ai-proofread"> AI proofread (off by default)</label>
@@ -218,6 +218,9 @@ export function openLocalPath(which) {
218
218
  export function allowRemoteContent(type, value) {
219
219
  return ipc().allowRemoteContent(type, value);
220
220
  }
221
+ export function flagSenderOrDomain(type, value) {
222
+ return ipc().flagSenderOrDomain?.(type, value) ?? Promise.resolve({ flagged: false });
223
+ }
221
224
  export function deleteMessage(accountId, uid) {
222
225
  return ipc().deleteMessage?.(accountId, uid);
223
226
  }
@@ -348,6 +351,12 @@ export function readConfigHelp(name) {
348
351
  export function unsubscribeOneClick(url) {
349
352
  return ipc().unsubscribeOneClick?.(url);
350
353
  }
354
+ export function openInWord(editId, html) {
355
+ return ipc().openInWord?.(editId, html) ?? Promise.resolve({ ok: false, path: "", opener: "none" });
356
+ }
357
+ export function closeWordEdit(editId) {
358
+ return ipc().closeWordEdit?.(editId) ?? Promise.resolve();
359
+ }
351
360
  /** Run an AI text transform (translate / proofread / summarize). Returns
352
361
  * empty `text` with a `reason` when the feature is disabled or the provider
353
362
  * errors — caller should surface `reason` in a status bar, not throw. */
@@ -140,6 +140,12 @@
140
140
  unsubscribeOneClick: function(url) {
141
141
  return callNode("unsubscribeOneClick", { url: url });
142
142
  },
143
+ openInWord: function(editId, html) {
144
+ return callNode("openInWord", { editId: editId, html: html });
145
+ },
146
+ closeWordEdit: function(editId) {
147
+ return callNode("closeWordEdit", { editId: editId });
148
+ },
143
149
  aiTransform: function(req) {
144
150
  return callNode("aiTransform", req);
145
151
  },
@@ -265,6 +271,9 @@
265
271
  allowRemoteContent: function(type, value) {
266
272
  return callNode("allowRemoteContent", { type: type, value: value });
267
273
  },
274
+ flagSenderOrDomain: function(type, value) {
275
+ return callNode("flagSenderOrDomain", { type: type, value: value });
276
+ },
268
277
  getSettings: function() { return callNode("getSettings"); },
269
278
  saveSettingsData: function(data) { return callNode("saveSettingsData", data); },
270
279
  getVersion: function() { return callNode("getVersion"); },
@@ -1525,6 +1525,17 @@ body.calendar-sidebar-on .calendar-sidebar { display: flex; }
1525
1525
  font-weight: 600;
1526
1526
  }
1527
1527
 
1528
+ /* Flagged sender or domain — red strip above the normal banner. */
1529
+ .mv-rb-flagged {
1530
+ background: oklch(0.42 0.20 25);
1531
+ color: white;
1532
+ padding: var(--gap-xs) var(--gap-md);
1533
+ font-weight: 700;
1534
+ letter-spacing: 0.02em;
1535
+ }
1536
+ .mv-remote-banner-flagged { box-shadow: inset 0 0 0 2px oklch(0.55 0.22 25); }
1537
+ .mv-rb-flag-btn { background: oklch(0.42 0.20 25); }
1538
+
1528
1539
  .mv-rb-summary {
1529
1540
  display: flex;
1530
1541
  align-items: center;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.437",
3
+ "version": "1.0.438",
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.362",
39
+ "@bobfrankston/msger": "^0.1.363",
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.362",
103
+ "@bobfrankston/msger": "^0.1.363",
104
104
  "@bobfrankston/mailx-host": "^0.1.8",
105
105
  "@capacitor/android": "^8.3.0",
106
106
  "@capacitor/cli": "^8.3.0",
@@ -69,8 +69,6 @@ export declare class ImapManager extends EventEmitter {
69
69
  private inboxSyncing;
70
70
  /** Use native IMAP client instead of imapflow. Set to true to enable. */
71
71
  useNativeClient: boolean;
72
- /** Accounts hitting connection limits — back off until this time */
73
- private connectionBackoff;
74
72
  /** Per-account health counters. Incremented when the server misbehaves
75
73
  * in ways that suggest a problem the user should know about (inactivity
76
74
  * timeouts, connection-cap hits, rate-limit waits). Surfaced via a
@@ -100,50 +98,82 @@ export declare class ImapManager extends EventEmitter {
100
98
  searchOnServer(accountId: string, mailboxPath: string, criteria: any): Promise<number[]>;
101
99
  /** Server-side search that also materializes any UIDs we don't yet have
102
100
  * locally. Returns the full result after upsert, so the caller can
103
- * render hits that fall outside the history window. */
101
+ * render hits that fall outside the history window. The fetch loop
102
+ * can be long for big hit-sets, so this runs on the slow lane and
103
+ * yields between chunks (each chunk is a separate withConnection)
104
+ * so an interactive body fetch can interleave. */
104
105
  searchAndFetchOnServer(accountId: string, folderId: number, mailboxPath: string, criteria: any): Promise<number[]>;
105
106
  /** Create a fresh IMAP client for an account (public access for API endpoints) */
106
- createPublicClient(accountId: string): any;
107
- /** Persistent operational connections — one per account, reused for all operations */
107
+ createPublicClient(accountId: string): Promise<any>;
108
+ /** Persistent operational connections — one per account, reused for all operations.
109
+ * Body fetch, sync, prefetch, outbox-append, flag/move all serialize through
110
+ * this single client per account via withConnection(). The priority lane
111
+ * in the queue lets interactive clicks jump ahead of background prefetch. */
108
112
  private opsClients;
109
- /** Operation queuesensures sequential access per account */
113
+ /** Two-lane operation queue per account interactive ops (body fetch on
114
+ * click, flag toggle) drain before background ops (sync, prefetch). FIFO
115
+ * within each lane. The single ops connection means there's never a race
116
+ * on which folder is SELECTed; commands run strictly sequentially. */
110
117
  private opsQueues;
111
- /** Persistent body-fetch connectionsseparate from ops so on-demand
112
- * body reads never queue behind a slow sync operation (bobma's IMAP
113
- * SEARCH can sit idle for 300s during backfill). */
114
- private bodyClients;
115
- /** Per-account backoff after the IMAP server rejected a connection with
116
- * the per-user+IP cap (Dovecot mail_max_userip_connections). Subsequent
117
- * body fetches short-circuit until the timestamp passes. */
118
- private bodyBackoff;
118
+ /** Per-host semaphorecaps simultaneous IMAP socket opens to one server.
119
+ * Defensive guardrail: with the single-ops-per-account model an individual
120
+ * user's mailx never hits more than (#accounts × 2) sockets per host, well
121
+ * under any reasonable server cap. Exists for the multi-account-on-one-host
122
+ * case (e.g. bobma + bobma2 both on imap.iecc.com). */
123
+ private hostSemaphores;
124
+ private static readonly HOST_PERMITS;
119
125
  /** Get (or create) the persistent operational connection for an account.
120
126
  * logout() is wrapped as a no-op so legacy callers don't close it. */
121
127
  private getOpsClient;
122
128
  /** Run an operation on the account's connection — queued, sequential, no concurrency */
123
- withConnection<T>(accountId: string, fn: (client: any) => Promise<T>): Promise<T>;
129
+ /** Run an operation against the account's single ops connection. Tasks
130
+ * queue strictly sequentially per account — only one IMAP command in
131
+ * flight at a time. This eliminates the SELECT-races and "stale client
132
+ * recovery" paths the old multi-client design needed.
133
+ *
134
+ * Default lane is `fast` — covers virtually everything (body fetch,
135
+ * flag toggle, move, incremental sync). Pass `slow: true` only for
136
+ * operations the caller knows will take a long time and shouldn't
137
+ * block the user (multi-folder prefetch batches, large backfills).
138
+ * When both lanes have tasks, fast drains first.
139
+ *
140
+ * Within a lane, FIFO. The running task always finishes — IMAP can't
141
+ * preempt a command mid-flight. */
142
+ withConnection<T>(accountId: string, fn: (client: any) => Promise<T>, opts?: {
143
+ slow?: boolean;
144
+ }): Promise<T>;
145
+ /** Run the next queued task. Fast lane drains before slow.
146
+ * Idempotent — safe to call after each task completes; the running
147
+ * flag prevents reentrant draining. */
148
+ private drainOpsQueue;
149
+ /** Acquire one slot of the per-host connection semaphore. Returns a release
150
+ * function — call exactly once when the socket is closed. Used by
151
+ * newClient to cap simultaneous IMAP connections to a single server
152
+ * across all mailx accounts. */
153
+ private acquireHostSlot;
124
154
  /** Open IMAP clients per account, used to trace who's opening sockets
125
155
  * when we hit the Dovecot per-user+IP connection cap. */
126
156
  private openClients;
127
157
  /** Create a new IMAP client (internal — callers use getOpsClient or withConnection).
128
- * `purpose` is a short tag printed alongside the `[conn+]` log so we can tell
129
- * which code path (sync/idle/body/outbox/move/…) opened each connection. */
158
+ * Acquires one slot of the per-host semaphore before constructing the
159
+ * client; the slot is released when logout() or destroy() runs.
160
+ * `purpose` is a short tag printed alongside the `[conn+]` log so we can
161
+ * tell which code path (ops/idle/etc.) opened each connection. */
130
162
  private newClient;
131
- /** Get (or lazily create) the persistent body-fetch client. Separate from
132
- * the ops client so body reads never wait on a slow sync command. */
133
- private getBodyClient;
134
- /** Drop the body-fetch connection (e.g. after a socket error). */
135
- private dropBodyClient;
136
- /** Force-close every pooled client for an account — ops, body, any
137
- * lingering ones in openClients. Used when the server reports its
138
- * connection cap is hit so our slot count drops to zero on the
139
- * server side before backoff expires. */
163
+ /** Force-close every IMAP socket for an account ops + any lingering
164
+ * ones in openClients (e.g. an IDLE watcher in flight). Used during
165
+ * account removal and disconnectOps so the server's connection slots
166
+ * free immediately rather than waiting for socket idle timeouts. */
140
167
  closeAllClients(accountId: string): Promise<void>;
141
168
  /** Disconnect the persistent operational connection for an account */
142
169
  disconnectOps(accountId: string): Promise<void>;
143
- /** Legacy API callers that still create/destroy connections.
144
- * These return the persistent ops client. logout() is a no-op
145
- * (the connection stays alive for reuse). */
170
+ /** Legacy entry: returns the shared persistent ops client. Most callers
171
+ * should be using `withConnection()` instead that gives proper
172
+ * queueing and lets fast operations jump ahead of slow ones. */
146
173
  createClientWithLimit(accountId: string): Promise<any>;
174
+ /** Disposable fresh client — only used by the IDLE watcher, which holds
175
+ * its own socket so the fast/slow ops queue isn't blocked by IDLE
176
+ * parking the connection in a wait-for-server state. */
147
177
  private createClient;
148
178
  private trackLogout;
149
179
  /** Number of registered IMAP accounts */
@@ -175,7 +205,12 @@ export declare class ImapManager extends EventEmitter {
175
205
  private storeApiMessages;
176
206
  /** Kill and recreate the persistent ops connection */
177
207
  private reconnectOps;
178
- /** Handle sync errors — classify and emit appropriate UI events */
208
+ /** Handle sync errors — classify and emit appropriate UI events.
209
+ * The connection-cap branch was removed: with the unified ops queue +
210
+ * per-host semaphore, mailx alone can't exceed the server cap. If the
211
+ * cap *is* hit, that means another client (Thunderbird, phone, sibling
212
+ * process) is holding slots — punishing mailx with a multi-minute
213
+ * blackout doesn't help the user, the next sync tick will retry. */
179
214
  private handleSyncError;
180
215
  /** Fetch ONLY new messages above highestUid for one account's INBOX —
181
216
  * the IDLE callback's hot path. Skips gap detection, backfill, and the
@@ -211,17 +246,21 @@ export declare class ImapManager extends EventEmitter {
211
246
  startWatching(): Promise<void>;
212
247
  /** Stop all IDLE watchers */
213
248
  stopWatching(): Promise<void>;
214
- /** Per-account fetch queue — serializes body fetches so only one IMAP command runs at a time.
215
- * The persistent fetchClient can only handle one command at a time (IMAP protocol limitation). */
216
- private fetchQueues;
217
- /** Serialize body fetch operations per account — prevents concurrent IMAP commands on same connection */
218
249
  /** Unlink the on-disk body file for a message by reading its `body_path`
219
250
  * from the DB. Safe to call either before or after `db.deleteMessage`
220
251
  * — read body_path first, store it, then unlink whenever. */
221
252
  private unlinkBodyFile;
222
- private enqueueFetch;
223
253
  /** Fetch a single message body on demand, caching in the store.
224
- * Uses its own fresh connection — never blocked by background prefetch. */
254
+ *
255
+ * Cache lookup is folder-agnostic: when a UID exists in multiple folders
256
+ * (Gmail labels, copy-instead-of-move) the prefetcher may have populated
257
+ * body_path on only one row. Looking up by (account, uid) without the
258
+ * folder filter finds the cached `.eml` regardless of which folder
259
+ * context the UI passed.
260
+ *
261
+ * Server fetch goes through the unified ops queue on the fast lane —
262
+ * the user clicked, they're waiting, this jumps ahead of any background
263
+ * prefetch sitting in the slow lane. */
225
264
  fetchMessageBody(accountId: string, folderId: number, uid: number): Promise<Buffer | null>;
226
265
  /** Fetch message body via Gmail/Outlook API.
227
266
  * Throws `MessageNotFoundError` when the server says the message is gone
@@ -360,7 +399,10 @@ export declare class ImapManager extends EventEmitter {
360
399
  * Uses @bobfrankston/smtp-direct with the same TransportFactory as IMAP —
361
400
  * same TCP byte-stream interface, no nodemailer dependency. */
362
401
  private sendRawViaSMTP;
363
- /** Process Outbox — send pending messages with flag-based interlock */
402
+ /** Process Outbox — send pending messages with flag-based interlock.
403
+ * Each per-UID step is its own withConnection({slow}) call so the queue
404
+ * yields between messages: a click-to-view body in the middle of a
405
+ * 10-message outbox drain doesn't wait for all 10 to finish. */
364
406
  processOutbox(accountId: string): Promise<void>;
365
407
  /** Start background Outbox worker — runs immediately then every 10 seconds */
366
408
  private outboxBackoff;