@bobfrankston/rmfmail 1.2.296 → 1.2.297

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.
@@ -843,7 +843,7 @@ export class ImapManager extends EventEmitter {
843
843
  async withConnection<T>(
844
844
  accountId: string,
845
845
  fn: (client: any) => Promise<T>,
846
- opts: { slow?: boolean; lane?: string; timeoutMs?: number } = {},
846
+ opts: { slow?: boolean; lane?: string; timeoutMs?: number; priority?: boolean } = {},
847
847
  ): Promise<T> {
848
848
  const lane: string = opts.lane ?? (opts.slow ? "slow" : "fast");
849
849
  let perAccount = this.laneQueues.get(accountId);
@@ -886,7 +886,17 @@ export class ImapManager extends EventEmitter {
886
886
  reject(e);
887
887
  }
888
888
  };
889
- queue!.tasks.push(task);
889
+ // A task the user is waiting on goes to the FRONT of its lane.
890
+ // Lanes are strictly sequential, so without this a click-time body
891
+ // fetch sits behind whatever background work was queued first and
892
+ // can only start after all of it — Bob 2026-08-29: "if I look at a
893
+ // letter while sync things get confused … viewing the body should
894
+ // get priority instead of waiting for sync". It cannot preempt the
895
+ // command already in flight (an IMAP command cannot be cancelled
896
+ // without dropping the connection), but it no longer waits for the
897
+ // whole backlog.
898
+ if (opts.priority) queue!.tasks.unshift(task);
899
+ else queue!.tasks.push(task);
890
900
  this.drainOpsQueue(accountId);
891
901
  });
892
902
  }
@@ -3743,13 +3753,20 @@ export class ImapManager extends EventEmitter {
3743
3753
  // IMAP: fast lane on the ops queue. One try; if the socket is stale,
3744
3754
  // withConnection's discard-on-error logic drops the client so the
3745
3755
  // next attempt (caller-driven retry) gets a fresh one.
3756
+ //
3757
+ // An interactive fetch (`force`) jumps the lane queue and gets a
3758
+ // tighter cap: the reader is staring at a blank pane, and 90 s of
3759
+ // silence followed by a banner is the worst of both. 30 s is long
3760
+ // enough for a big body on a slow server and short enough that a
3761
+ // wedged lane becomes a RETRYABLE timeout while the reader is still
3762
+ // looking at the message.
3746
3763
  try {
3747
3764
  const raw = await this.withConnection(accountId, async (client) => {
3748
3765
  const msg: any = await client.fetchMessageByUid(folder.path, uid, { source: true });
3749
3766
  if (!msg) throw makeNotFoundError(accountId, folderId, uid);
3750
3767
  if (!msg.source) return null;
3751
3768
  return Buffer.from(msg.source, "utf-8");
3752
- });
3769
+ }, force ? { priority: true, timeoutMs: 30_000 } : {});
3753
3770
  if (!raw) return null;
3754
3771
  const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
3755
3772
  this.db.updateBodyPath(accountId, folderId, uid, bodyPath);
@@ -3761,8 +3778,18 @@ export class ImapManager extends EventEmitter {
3761
3778
  } catch (e: any) {
3762
3779
  if (e?.isNotFound) throw e;
3763
3780
  console.error(` Body fetch error (${accountId}/${uid}): ${e?.message || e}`);
3764
- // Record so the next view / prefetch backs off instead of
3765
- // re-attempting a hanging fetch and re-congesting the queue.
3781
+ // A lane timeout / dropped socket says nothing about whether this
3782
+ // message HAS a body. Swallowing it into `null` told the caller
3783
+ // "the server has no body for this" — the reconciler classifies
3784
+ // that as non-transient, skips its retry budget, and banners.
3785
+ // That is the "eventually reports an error rather than retrying"
3786
+ // Bob hit (2026-08-29). Rethrow instead, so the reconciler's
3787
+ // transient test sees the word "timeout" and retries with backoff.
3788
+ const transient = /timeout|discarding client|connection|not connected|ECONN|ETIMEDOUT|ENOTFOUND|socket/i
3789
+ .test(String(e?.message || ""));
3790
+ if (transient) throw e;
3791
+ // A genuine "no body here" — arm the backoff so prefetch and the
3792
+ // next view don't re-attempt a fetch the server won't satisfy.
3766
3793
  try { this.markPrefetchEmpty(accountId, folderId, uid); } catch { /* */ }
3767
3794
  return null;
3768
3795
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.169",
3
+ "version": "0.1.170",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bobfrankston/mailx-imap",
9
- "version": "0.1.169",
9
+ "version": "0.1.170",
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
12
  "@bobfrankston/iflow-direct": "^0.1.27",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.169",
3
+ "version": "0.1.170",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",