@bobfrankston/mailx-imap 0.1.69 → 0.1.71

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.
Files changed (3) hide show
  1. package/index.d.ts +1 -0
  2. package/index.js +51 -3
  3. package/package.json +3 -3
package/index.d.ts CHANGED
@@ -59,6 +59,7 @@ export interface OutboxStatus {
59
59
  export declare class ImapManager extends EventEmitter {
60
60
  private configs;
61
61
  private watchers;
62
+ private watcherClients;
62
63
  private fetchClients;
63
64
  /** The Store is the architectural nexus — owner of MailxDB +
64
65
  * FileMessageStore + the event bus. This package (mailx-imap) is a
package/index.js CHANGED
@@ -158,6 +158,14 @@ async function withTimeout(promise, ms, client, label) {
158
158
  export class ImapManager extends EventEmitter {
159
159
  configs = new Map();
160
160
  watchers = new Map();
161
+ // Parallel map of the IDLE watch client per account, so runFullSync /
162
+ // startWatching can health-check the live socket and refresh ONLY dead
163
+ // watchers instead of tearing every watcher down each cycle. The blanket
164
+ // teardown was the IDLE-churn driver: it dropped healthy IDLE every 5 min,
165
+ // and re-establishing competed for the saturated 4-permit host semaphore
166
+ // (folder syncs holding all permits, timing out at 360s), leaving new mail
167
+ // on 5-min polling for minutes at a time (Bob 2026-05-28).
168
+ watcherClients = new Map();
161
169
  fetchClients = new Map();
162
170
  /** The Store is the architectural nexus — owner of MailxDB +
163
171
  * FileMessageStore + the event bus. This package (mailx-imap) is a
@@ -294,6 +302,7 @@ export class ImapManager extends EventEmitter {
294
302
  }
295
303
  catch { /* */ }
296
304
  this.watchers.delete(accountId);
305
+ this.watcherClients.delete(accountId);
297
306
  }
298
307
  // Delete only the IMAP token (not contacts — separate scope, separate consent)
299
308
  const accountDir = tokenDirName(account.imap.user);
@@ -1049,6 +1058,18 @@ export class ImapManager extends EventEmitter {
1049
1058
  *
1050
1059
  * Fires the same emits as a normal sync so the UI updates. */
1051
1060
  async insertLocalRowFromSource(accountId, folder, uid, source, flags) {
1061
+ // Guard against uid <= 0. Valid IMAP UIDs are always ≥ 1; a 0 here
1062
+ // means the APPENDUID capture failed (server returned no UIDPLUS, or
1063
+ // appendMessage resolved to 0 instead of null) and the caller didn't
1064
+ // catch it. Inserting uid=0 created a phantom INBOX row with no
1065
+ // message_id, no body, no subject — the "blank line in the summary"
1066
+ // (Bob 2026-05-28). Refuse it; the caller's syncFolder fallback will
1067
+ // pick the message up with its real UID on the next sync.
1068
+ if (!uid || uid <= 0) {
1069
+ console.error(` [insert] refusing uid=${uid} for ${accountId}/${folder.path} — invalid IMAP UID (APPENDUID likely failed); deferring to sync`);
1070
+ this.syncFolder(accountId, folder.id).catch(() => { });
1071
+ return;
1072
+ }
1052
1073
  // insertLocalRowFromSource runs right after sendMessage — that's a
1053
1074
  // user-initiated path but the parse cost is on the post-send
1054
1075
  // background work, not the click-through. Tag as background so a
@@ -2461,7 +2482,12 @@ export class ImapManager extends EventEmitter {
2461
2482
  async runFullSync() {
2462
2483
  console.log(` [periodic] Full sync at ${new Date().toLocaleTimeString()}`);
2463
2484
  await this.syncAll();
2464
- await this.stopWatching();
2485
+ // Do NOT tear down healthy IDLE here. startWatching is idempotent and
2486
+ // now health-checks each existing watcher: alive ones are left intact,
2487
+ // dead/missing ones are re-established. The old unconditional
2488
+ // stopWatching()+startWatching() dropped healthy IDLE every cycle and
2489
+ // the re-establish stalled behind the saturated host semaphore — the
2490
+ // "still slow polling / not subscribing" gaps (Bob 2026-05-28).
2465
2491
  await this.startWatching();
2466
2492
  }
2467
2493
  /** Stop periodic sync */
@@ -2482,8 +2508,28 @@ export class ImapManager extends EventEmitter {
2482
2508
  * right way to make that cheap, not piling per-folder IDLE sockets). */
2483
2509
  async startWatching() {
2484
2510
  for (const [accountId] of this.configs) {
2485
- if (this.watchers.has(accountId))
2486
- continue;
2511
+ if (this.watchers.has(accountId)) {
2512
+ // Already watching — but is the socket still alive? A silently
2513
+ // dropped IDLE leaves the watcher entry in place, so without
2514
+ // this health-check the deadman (which only checks for MISSING
2515
+ // entries) would never refresh it. If dead, tear down just THIS
2516
+ // one and fall through to re-establish; if alive, leave it.
2517
+ const wc = this.watcherClients.get(accountId);
2518
+ const sock = wc?.native?.transport?.socket;
2519
+ const dead = !wc || sock?.destroyed || sock?.readyState === "closed" || wc?._dead;
2520
+ if (!dead)
2521
+ continue;
2522
+ console.log(` [idle] ${accountId}: watcher socket dead — refreshing`);
2523
+ const stop = this.watchers.get(accountId);
2524
+ if (stop) {
2525
+ try {
2526
+ await stop();
2527
+ }
2528
+ catch { /* */ }
2529
+ }
2530
+ this.watchers.delete(accountId);
2531
+ this.watcherClients.delete(accountId);
2532
+ }
2487
2533
  try {
2488
2534
  // IDLE keeps its own dedicated socket — once the connection
2489
2535
  // is parked in IDLE, it's unusable for any other command, so
@@ -2545,6 +2591,7 @@ export class ImapManager extends EventEmitter {
2545
2591
  await stop();
2546
2592
  await watchClient.logout();
2547
2593
  });
2594
+ this.watcherClients.set(accountId, watchClient);
2548
2595
  console.log(` [idle] Watching INBOX for ${accountId}${useNotify ? " (+NOTIFY personal mailboxes)" : ""}`);
2549
2596
  }
2550
2597
  catch (e) {
@@ -2578,6 +2625,7 @@ export class ImapManager extends EventEmitter {
2578
2625
  catch { /* ignore */ }
2579
2626
  }
2580
2627
  this.watchers.clear();
2628
+ this.watcherClients.clear();
2581
2629
  }
2582
2630
  /** Unlink the on-disk body file for a message by reading its `body_path`
2583
2631
  * from the DB. Safe to call either before or after `db.deleteMessage`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.69",
3
+ "version": "0.1.71",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -11,7 +11,7 @@
11
11
  "dependencies": {
12
12
  "@bobfrankston/mailx-types": "^0.1.18",
13
13
  "@bobfrankston/mailx-settings": "^0.1.25",
14
- "@bobfrankston/mailx-store": "^0.1.40",
14
+ "@bobfrankston/mailx-store": "^0.1.41",
15
15
  "@bobfrankston/iflow-direct": "^0.1.51",
16
16
  "@bobfrankston/tcp-transport": "^0.1.6",
17
17
  "@bobfrankston/smtp-direct": "^0.1.8",
@@ -39,7 +39,7 @@
39
39
  "dependencies": {
40
40
  "@bobfrankston/mailx-types": "^0.1.18",
41
41
  "@bobfrankston/mailx-settings": "^0.1.25",
42
- "@bobfrankston/mailx-store": "^0.1.40",
42
+ "@bobfrankston/mailx-store": "^0.1.41",
43
43
  "@bobfrankston/iflow-direct": "^0.1.51",
44
44
  "@bobfrankston/tcp-transport": "^0.1.6",
45
45
  "@bobfrankston/smtp-direct": "^0.1.8",