@bobfrankston/mailx-imap 0.1.78 → 0.1.80

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 +22 -0
  2. package/index.js +56 -31
  3. package/package.json +3 -3
package/index.d.ts CHANGED
@@ -479,6 +479,28 @@ export declare class ImapManager extends EventEmitter {
479
479
  * sending/queued/ on every send — that write is gone now, so scanning the
480
480
  * directory is safe again. Any legitimate files that land there (crash
481
481
  * recovery, manual drop) will get sent. */
482
+ /** Recover stale `.sending-<host>-<pid>` claims for an account back to
483
+ * plain `.ltr` so the next tick can retry. A claim is stale if:
484
+ * (a) the PID is dead — original owner crashed/was replaced mid-send;
485
+ * (b) the PID is alive but it's not us and the file mtime is older than
486
+ * STALE_CLAIM_MS — `process.kill(pid,0)` only proves *some* process
487
+ * owns that PID, not our long-dead daemon (OS recycles PIDs); the
488
+ * age guard stops an unrelated Node (statusline/msger/npm) inheriting
489
+ * a recycled PID from pinning the claim forever (Bob's 7-hour stuck
490
+ * `.sending-rmf39-63196`);
491
+ * (c) it's our PID — never sweep our own live claim.
492
+ * Foreign hosts are left alone (we can't probe their PIDs); cross-host
493
+ * recovery is the IMAP-folder sweeper's job.
494
+ *
495
+ * CRITICAL: this is a local filesystem op with NO network I/O, so it MUST
496
+ * run independently of send-backoff. It used to live inside
497
+ * processLocalQueue, which the outbox worker SKIPS when an account is in
498
+ * backoff — so a persistently-erroring account (e.g. Dovecot "Not
499
+ * connected" storms) never recovered its claims, leaving a message stuck
500
+ * "sending…" forever while healthy accounts drained. Bob 2026-05-31:
501
+ * "stuck while others are getting sent." Now called every tick for every
502
+ * account before the backoff gate. */
503
+ private recoverStaleClaims;
482
504
  private processLocalQueue;
483
505
  /** Send a raw RFC 2822 message via SMTP for a given account.
484
506
  * Uses @bobfrankston/smtp-direct with the same TransportFactory as IMAP —
package/index.js CHANGED
@@ -2968,7 +2968,17 @@ export class ImapManager extends EventEmitter {
2968
2968
  // BATCH_SIZE rows that all get filtered to zero, and the loop
2969
2969
  // terminates without ever trying live messages further down the
2970
2970
  // size-asc list.
2971
- const raw = this.db.getMessagesWithoutBody(accountId, BATCH_SIZE * 4);
2971
+ // Exclude error-cooled folders AT THE QUERY so a single bloated /
2972
+ // perpetually-failing folder (a stuck server-side Outbox, a 300s
2973
+ // archive) can't fill the entire size-asc batch and starve every
2974
+ // healthy folder. Recomputed each iteration: a folder that trips
2975
+ // its cooldown mid-loop drops out on the next pull (Bob 2026-06-01
2976
+ // "so much unfetched — nothing draining"). Empty when no folder is
2977
+ // cooling, so the common case is unchanged.
2978
+ const coolingFolderIds = this.db.getFolders(accountId)
2979
+ .filter(f => this.shouldSkipFolder(accountId, f.path))
2980
+ .map(f => f.id);
2981
+ const raw = this.db.getMessagesWithoutBody(accountId, BATCH_SIZE * 4, coolingFolderIds);
2972
2982
  const missing = raw.filter(m => !this.isPrefetchEmpty(accountId, m.folderId, m.uid))
2973
2983
  .slice(0, BATCH_SIZE);
2974
2984
  if (missing.length === 0)
@@ -4089,34 +4099,35 @@ export class ImapManager extends EventEmitter {
4089
4099
  * sending/queued/ on every send — that write is gone now, so scanning the
4090
4100
  * directory is safe again. Any legitimate files that land there (crash
4091
4101
  * recovery, manual drop) will get sent. */
4092
- async processLocalQueue(accountId) {
4093
- const outboxDir = path.join(getConfigDir(), "outbox", accountId);
4094
- const queuedDir = path.join(getConfigDir(), "sending", accountId, "queued");
4095
- // Recovery sweep: any *.sending-<host>-<pid> on THIS host whose PID is
4096
- // dead (process crashed mid-send) gets unclaimed so the next tick can
4097
- // retry. Foreign hosts are left alone we have no way to know if their
4098
- // process is alive. Cross-host stale recovery is the IMAP-folder path's
4099
- // job (sweeper looks at server-side claim flags, not local files).
4100
- // Stale-claim recovery. A claim is "stale" if any of:
4101
- // (a) the PID is dead original owner crashed mid-send
4102
- // (b) the PID is alive BUT it's not us, and the file mtime is
4103
- // older than STALE_CLAIM_MS — the OS recycled the PID for
4104
- // some other process. `process.kill(pid, 0)` returning success
4105
- // only proves *some* process owns that PID, not that it's
4106
- // our long-dead mailx daemon. Without the age guard, a
4107
- // claim survives forever as soon as any other Node process
4108
- // (statusline, msger, npm) gets the recycled PID. Bob saw
4109
- // this exact case: `.sending-rmf39-63196` sat in the queue
4110
- // for 7+ hours because PID 63196 was now an unrelated Node.
4111
- // (c) it's our PID never sweep our own claim.
4112
- // 5 minutes: SMTP transactions complete in seconds; 5m of
4113
- // claim with no progress means the worker is wedged or PID
4114
- // got recycled. Earlier 1h was a band-aid that left Bob's
4115
- // outbox stuck for hours when an SMTP wedge crashed the
4116
- // worker mid-flight.
4102
+ /** Recover stale `.sending-<host>-<pid>` claims for an account back to
4103
+ * plain `.ltr` so the next tick can retry. A claim is stale if:
4104
+ * (a) the PID is dead original owner crashed/was replaced mid-send;
4105
+ * (b) the PID is alive but it's not us and the file mtime is older than
4106
+ * STALE_CLAIM_MS `process.kill(pid,0)` only proves *some* process
4107
+ * owns that PID, not our long-dead daemon (OS recycles PIDs); the
4108
+ * age guard stops an unrelated Node (statusline/msger/npm) inheriting
4109
+ * a recycled PID from pinning the claim forever (Bob's 7-hour stuck
4110
+ * `.sending-rmf39-63196`);
4111
+ * (c) it's our PID never sweep our own live claim.
4112
+ * Foreign hosts are left alone (we can't probe their PIDs); cross-host
4113
+ * recovery is the IMAP-folder sweeper's job.
4114
+ *
4115
+ * CRITICAL: this is a local filesystem op with NO network I/O, so it MUST
4116
+ * run independently of send-backoff. It used to live inside
4117
+ * processLocalQueue, which the outbox worker SKIPS when an account is in
4118
+ * backoff so a persistently-erroring account (e.g. Dovecot "Not
4119
+ * connected" storms) never recovered its claims, leaving a message stuck
4120
+ * "sending…" forever while healthy accounts drained. Bob 2026-05-31:
4121
+ * "stuck while others are getting sent." Now called every tick for every
4122
+ * account before the backoff gate. */
4123
+ recoverStaleClaims(accountId) {
4117
4124
  const STALE_CLAIM_MS = 5 * 60_000;
4118
4125
  const myPid = process.pid;
4119
- for (const dir of [outboxDir, queuedDir]) {
4126
+ const dirs = [
4127
+ path.join(getConfigDir(), "outbox", accountId),
4128
+ path.join(getConfigDir(), "sending", accountId, "queued"),
4129
+ ];
4130
+ for (const dir of dirs) {
4120
4131
  if (!fs.existsSync(dir))
4121
4132
  continue;
4122
4133
  for (const f of fs.readdirSync(dir)) {
@@ -4140,9 +4151,8 @@ export class ImapManager extends EventEmitter {
4140
4151
  ageMs = Date.now() - fs.statSync(path.join(dir, f)).mtimeMs;
4141
4152
  }
4142
4153
  catch { /* */ }
4143
- // Live PID + recent mtime → assume genuine sibling owner.
4144
- // Live PID + ancient mtime → PID got recycled, sweep it.
4145
- // Dead PID → sweep regardless of age.
4154
+ // Live PID + recent mtime → genuine sibling owner, leave it.
4155
+ // Live PID + ancient mtime → recycled PID, sweep. Dead PID → sweep.
4146
4156
  if (alive && ageMs < STALE_CLAIM_MS)
4147
4157
  continue;
4148
4158
  try {
@@ -4153,6 +4163,14 @@ export class ImapManager extends EventEmitter {
4153
4163
  catch { /* ignore */ }
4154
4164
  }
4155
4165
  }
4166
+ }
4167
+ async processLocalQueue(accountId) {
4168
+ const outboxDir = path.join(getConfigDir(), "outbox", accountId);
4169
+ const queuedDir = path.join(getConfigDir(), "sending", accountId, "queued");
4170
+ // Recovery also runs unconditionally in the worker tick (before the
4171
+ // backoff gate); keep it here too for the direct-call path from
4172
+ // queueOutgoing. Idempotent — a no-op when nothing is stale.
4173
+ this.recoverStaleClaims(accountId);
4156
4174
  const filesToSend = [];
4157
4175
  for (const dir of [outboxDir, queuedDir]) {
4158
4176
  if (!fs.existsSync(dir))
@@ -4634,7 +4652,14 @@ export class ImapManager extends EventEmitter {
4634
4652
  // per-account sweep picks them up below.
4635
4653
  this.routeGeneralOutbox();
4636
4654
  for (const [accountId] of this.configs) {
4637
- // Skip accounts in backoff
4655
+ // Recover stale claims FIRST, unconditionally — it's a local
4656
+ // FS op with no network, so it must not be gated by send
4657
+ // backoff. Otherwise an account stuck in backoff (Dovecot "Not
4658
+ // connected" storms) never unclaims an orphaned .sending- file,
4659
+ // and a message sits "sending…" forever while other accounts
4660
+ // drain (Bob 2026-05-31).
4661
+ this.recoverStaleClaims(accountId);
4662
+ // Skip the SEND for accounts in backoff (network ops only).
4638
4663
  const retryAfter = this.outboxBackoff.get(accountId) || 0;
4639
4664
  if (now < retryAfter)
4640
4665
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.78",
3
+ "version": "0.1.80",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -10,7 +10,7 @@
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
12
  "@bobfrankston/mailx-types": "^0.1.18",
13
- "@bobfrankston/mailx-settings": "^0.1.25",
13
+ "@bobfrankston/mailx-settings": "^0.1.26",
14
14
  "@bobfrankston/mailx-store": "^0.1.42",
15
15
  "@bobfrankston/iflow-direct": "^0.1.51",
16
16
  "@bobfrankston/tcp-transport": "^0.1.6",
@@ -38,7 +38,7 @@
38
38
  ".transformedSnapshot": {
39
39
  "dependencies": {
40
40
  "@bobfrankston/mailx-types": "^0.1.18",
41
- "@bobfrankston/mailx-settings": "^0.1.25",
41
+ "@bobfrankston/mailx-settings": "^0.1.26",
42
42
  "@bobfrankston/mailx-store": "^0.1.42",
43
43
  "@bobfrankston/iflow-direct": "^0.1.51",
44
44
  "@bobfrankston/tcp-transport": "^0.1.6",