@bobfrankston/mailx-imap 0.1.167 → 0.1.170

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 +75 -11
  3. package/package.json +9 -9
package/index.d.ts CHANGED
@@ -237,6 +237,7 @@ export declare class ImapManager extends EventEmitter {
237
237
  slow?: boolean;
238
238
  lane?: string;
239
239
  timeoutMs?: number;
240
+ priority?: boolean;
240
241
  }): Promise<T>;
241
242
  /** Drain the next queued task on EVERY lane for the account. Lanes run
242
243
  * concurrently — each on its own connection — and the per-lane running
package/index.js CHANGED
@@ -7,7 +7,7 @@ import { createAutoImapConfig, CompatImapClient } from "@bobfrankston/iflow-dire
7
7
  import { authenticateOAuth, clearReauthBackoff } from "@bobfrankston/oauthsupport";
8
8
  import { parseSerial, fixCharsetDeclString, storeBus } from "@bobfrankston/mailx-store";
9
9
  import { loadSettings, getConfigDir, getHistoryDays, getPrefetch, tokenDirName } from "@bobfrankston/mailx-settings";
10
- import { isJunkPreviewText } from "@bobfrankston/mailx-types";
10
+ import { isJunkPreviewText, logIfChanged } from "@bobfrankston/mailx-types";
11
11
  import { EventEmitter } from "node:events";
12
12
  import * as fs from "node:fs";
13
13
  import * as path from "node:path";
@@ -874,7 +874,19 @@ export class ImapManager extends EventEmitter {
874
874
  reject(e);
875
875
  }
876
876
  };
877
- queue.tasks.push(task);
877
+ // A task the user is waiting on goes to the FRONT of its lane.
878
+ // Lanes are strictly sequential, so without this a click-time body
879
+ // fetch sits behind whatever background work was queued first and
880
+ // can only start after all of it — Bob 2026-08-29: "if I look at a
881
+ // letter while sync things get confused … viewing the body should
882
+ // get priority instead of waiting for sync". It cannot preempt the
883
+ // command already in flight (an IMAP command cannot be cancelled
884
+ // without dropping the connection), but it no longer waits for the
885
+ // whole backlog.
886
+ if (opts.priority)
887
+ queue.tasks.unshift(task);
888
+ else
889
+ queue.tasks.push(task);
878
890
  this.drainOpsQueue(accountId);
879
891
  });
880
892
  }
@@ -2405,10 +2417,13 @@ export class ImapManager extends EventEmitter {
2405
2417
  && statusMessageCount !== null
2406
2418
  && serverUidsArr.length === statusMessageCount;
2407
2419
  if (serverUidsArr.length === 0 && localUidsAll.length > 0 && !authoritative) {
2408
- console.log(` [sync] ${accountId}/${folder.path}: reconcile skipped server UID list empty but local has ${localUidsAll.length} (treating as transient)`);
2420
+ // Same line every cycle for a folder stuck in this state
2421
+ // logIfChanged prints the first one, each change, and an
2422
+ // hourly restatement. (Claude Code 2026-08-29)
2423
+ logIfChanged(`${accountId}/${folder.path}:reconcile`, ` [sync] ${accountId}/${folder.path}: reconcile skipped — server UID list empty but local has ${localUidsAll.length} (treating as transient)`);
2409
2424
  }
2410
2425
  else if (!authoritative && localUids.length > 0 && toDelete.length / localUids.length > 0.5) {
2411
- console.log(` [sync] ${accountId}/${folder.path}: reconcile REFUSED — would delete ${toDelete.length}/${localUids.length} (${Math.round(toDelete.length / localUids.length * 100)}%) — probably a sync bug, skipping`);
2426
+ logIfChanged(`${accountId}/${folder.path}:reconcile`, ` [sync] ${accountId}/${folder.path}: reconcile REFUSED — would delete ${toDelete.length}/${localUids.length} (${Math.round(toDelete.length / localUids.length * 100)}%) — probably a sync bug, skipping`);
2412
2427
  }
2413
2428
  else if (toDelete.length > 0) {
2414
2429
  // DEFERRED DELETE — DO NOT delete immediately. Server-side
@@ -2888,10 +2903,10 @@ export class ImapManager extends EventEmitter {
2888
2903
  const serverUids = new Set(serverUidsArr);
2889
2904
  const localUids = this.db.getUidsForFolder(accountId, folder.id);
2890
2905
  if (serverUidsArr._truncated) {
2891
- console.log(` [api] ${accountId}/${folder.path}: reconcile skipped — server list truncated (${serverUidsArr.length} ids)`);
2906
+ logIfChanged(`${accountId}/${folder.path}:api-reconcile`, ` [api] ${accountId}/${folder.path}: reconcile skipped — server list truncated (${serverUidsArr.length} ids)`);
2892
2907
  }
2893
2908
  else if (serverUidsArr.length === 0 && localUids.length > 0) {
2894
- console.log(` [api] ${accountId}/${folder.path}: reconcile skipped — server list empty but local has ${localUids.length}`);
2909
+ logIfChanged(`${accountId}/${folder.path}:api-reconcile`, ` [api] ${accountId}/${folder.path}: reconcile skipped — server list empty but local has ${localUids.length}`);
2895
2910
  }
2896
2911
  else {
2897
2912
  const toDelete = localUids.filter(uid => !serverUids.has(uid));
@@ -2925,7 +2940,7 @@ export class ImapManager extends EventEmitter {
2925
2940
  }
2926
2941
  }
2927
2942
  if (!confirmed) {
2928
- console.log(` [api] ${accountId}/${folder.path}: reconcile refused — would delete ${toDelete.length}/${localUids.length} (${Math.round(toDelete.length / localUids.length * 100)}%) — probably a sync bug, skipping`);
2943
+ logIfChanged(`${accountId}/${folder.path}:api-reconcile`, ` [api] ${accountId}/${folder.path}: reconcile refused — would delete ${toDelete.length}/${localUids.length} (${Math.round(toDelete.length / localUids.length * 100)}%) — probably a sync bug, skipping`);
2929
2944
  }
2930
2945
  else {
2931
2946
  for (const uid of toDelete) {
@@ -3511,10 +3526,41 @@ export class ImapManager extends EventEmitter {
3511
3526
  this.syncFolder(accountId, inbox.id).catch(e => console.error(` [idle] expunge reconcile failed: ${e.message}`));
3512
3527
  }, 2000);
3513
3528
  };
3529
+ // A flag changed on a message we already have — someone starred
3530
+ // or read it on another client. The server pushes it during
3531
+ // IDLE as an unsolicited FETCH; before v1.2.296 that was
3532
+ // dropped, so a star set on the phone reached the desktop only
3533
+ // on the next periodic sync of INBOX. Measured on Bob's own
3534
+ // log that was a median of 5 minutes and up to 20 (Bob
3535
+ // 2026-08-29: "how long should it take for a * on the phone to
3536
+ // get to my desktop?").
3537
+ //
3538
+ // The push says WHICH flags but by sequence number, which this
3539
+ // connection cannot map to a UID reliably — so it triggers the
3540
+ // folder's normal QRESYNC pass, which asks the server for
3541
+ // exactly what changed since our modseq and took 53 ms in the
3542
+ // same log. Debounced: marking ten messages read on the phone
3543
+ // arrives as ten pushes.
3544
+ let flagTimer = null;
3545
+ const onFlagChange = () => {
3546
+ if (flagTimer)
3547
+ clearTimeout(flagTimer);
3548
+ flagTimer = setTimeout(() => {
3549
+ flagTimer = null;
3550
+ // Our own STOREs bounce straight back as flag pushes;
3551
+ // the local store already has those.
3552
+ if (this.db.getPendingSyncActions(accountId).length > 0)
3553
+ return;
3554
+ const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
3555
+ if (!inbox)
3556
+ return;
3557
+ this.syncFolder(accountId, inbox.id).catch(e => console.error(` [idle] flag-change sync failed: ${e.message}`));
3558
+ }, 1500);
3559
+ };
3514
3560
  const stop = await watchClient.watchMailbox("INBOX", (newCount) => {
3515
3561
  console.log(` [idle] ${accountId}: ${newCount} new message(s)`);
3516
3562
  this.syncInboxNewOnly(accountId).catch(e => console.error(` [idle] sync error: ${e.message}`));
3517
- }, { notifySpec, onMailboxStatus, onExpunge });
3563
+ }, { notifySpec, onMailboxStatus, onExpunge, onFlagChange });
3518
3564
  this.watchers.set(accountId, async () => {
3519
3565
  await stop();
3520
3566
  await watchClient.logout();
@@ -3728,6 +3774,13 @@ export class ImapManager extends EventEmitter {
3728
3774
  // IMAP: fast lane on the ops queue. One try; if the socket is stale,
3729
3775
  // withConnection's discard-on-error logic drops the client so the
3730
3776
  // next attempt (caller-driven retry) gets a fresh one.
3777
+ //
3778
+ // An interactive fetch (`force`) jumps the lane queue and gets a
3779
+ // tighter cap: the reader is staring at a blank pane, and 90 s of
3780
+ // silence followed by a banner is the worst of both. 30 s is long
3781
+ // enough for a big body on a slow server and short enough that a
3782
+ // wedged lane becomes a RETRYABLE timeout while the reader is still
3783
+ // looking at the message.
3731
3784
  try {
3732
3785
  const raw = await this.withConnection(accountId, async (client) => {
3733
3786
  const msg = await client.fetchMessageByUid(folder.path, uid, { source: true });
@@ -3736,7 +3789,7 @@ export class ImapManager extends EventEmitter {
3736
3789
  if (!msg.source)
3737
3790
  return null;
3738
3791
  return Buffer.from(msg.source, "utf-8");
3739
- });
3792
+ }, force ? { priority: true, timeoutMs: 30_000 } : {});
3740
3793
  if (!raw)
3741
3794
  return null;
3742
3795
  const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
@@ -3751,8 +3804,19 @@ export class ImapManager extends EventEmitter {
3751
3804
  if (e?.isNotFound)
3752
3805
  throw e;
3753
3806
  console.error(` Body fetch error (${accountId}/${uid}): ${e?.message || e}`);
3754
- // Record so the next view / prefetch backs off instead of
3755
- // re-attempting a hanging fetch and re-congesting the queue.
3807
+ // A lane timeout / dropped socket says nothing about whether this
3808
+ // message HAS a body. Swallowing it into `null` told the caller
3809
+ // "the server has no body for this" — the reconciler classifies
3810
+ // that as non-transient, skips its retry budget, and banners.
3811
+ // That is the "eventually reports an error rather than retrying"
3812
+ // Bob hit (2026-08-29). Rethrow instead, so the reconciler's
3813
+ // transient test sees the word "timeout" and retries with backoff.
3814
+ const transient = /timeout|discarding client|connection|not connected|ECONN|ETIMEDOUT|ENOTFOUND|socket/i
3815
+ .test(String(e?.message || ""));
3816
+ if (transient)
3817
+ throw e;
3818
+ // A genuine "no body here" — arm the backoff so prefetch and the
3819
+ // next view don't re-attempt a fetch the server won't satisfy.
3756
3820
  try {
3757
3821
  this.markPrefetchEmpty(accountId, folderId, uid);
3758
3822
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.167",
3
+ "version": "0.1.170",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -9,10 +9,10 @@
9
9
  },
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
- "@bobfrankston/mailx-types": "^0.1.73",
13
- "@bobfrankston/mailx-settings": "^0.1.70",
14
- "@bobfrankston/mailx-store": "^0.1.107",
15
- "@bobfrankston/iflow-direct": "^0.1.65",
12
+ "@bobfrankston/mailx-types": "^0.1.76",
13
+ "@bobfrankston/mailx-settings": "^0.1.73",
14
+ "@bobfrankston/mailx-store": "^0.1.109",
15
+ "@bobfrankston/iflow-direct": "^0.1.68",
16
16
  "@bobfrankston/tcp-transport": "^0.1.8",
17
17
  "@bobfrankston/smtp-direct": "^0.1.9",
18
18
  "@bobfrankston/mailx-sync": "^0.1.29",
@@ -37,10 +37,10 @@
37
37
  },
38
38
  ".transformedSnapshot": {
39
39
  "dependencies": {
40
- "@bobfrankston/mailx-types": "^0.1.73",
41
- "@bobfrankston/mailx-settings": "^0.1.70",
42
- "@bobfrankston/mailx-store": "^0.1.107",
43
- "@bobfrankston/iflow-direct": "^0.1.65",
40
+ "@bobfrankston/mailx-types": "^0.1.76",
41
+ "@bobfrankston/mailx-settings": "^0.1.73",
42
+ "@bobfrankston/mailx-store": "^0.1.109",
43
+ "@bobfrankston/iflow-direct": "^0.1.68",
44
44
  "@bobfrankston/tcp-transport": "^0.1.8",
45
45
  "@bobfrankston/smtp-direct": "^0.1.9",
46
46
  "@bobfrankston/mailx-sync": "^0.1.29",