@bobfrankston/mailx-imap 0.1.78 → 0.1.79

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 +45 -30
  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
@@ -4089,34 +4089,35 @@ export class ImapManager extends EventEmitter {
4089
4089
  * sending/queued/ on every send — that write is gone now, so scanning the
4090
4090
  * directory is safe again. Any legitimate files that land there (crash
4091
4091
  * 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.
4092
+ /** Recover stale `.sending-<host>-<pid>` claims for an account back to
4093
+ * plain `.ltr` so the next tick can retry. A claim is stale if:
4094
+ * (a) the PID is dead original owner crashed/was replaced mid-send;
4095
+ * (b) the PID is alive but it's not us and the file mtime is older than
4096
+ * STALE_CLAIM_MS `process.kill(pid,0)` only proves *some* process
4097
+ * owns that PID, not our long-dead daemon (OS recycles PIDs); the
4098
+ * age guard stops an unrelated Node (statusline/msger/npm) inheriting
4099
+ * a recycled PID from pinning the claim forever (Bob's 7-hour stuck
4100
+ * `.sending-rmf39-63196`);
4101
+ * (c) it's our PID never sweep our own live claim.
4102
+ * Foreign hosts are left alone (we can't probe their PIDs); cross-host
4103
+ * recovery is the IMAP-folder sweeper's job.
4104
+ *
4105
+ * CRITICAL: this is a local filesystem op with NO network I/O, so it MUST
4106
+ * run independently of send-backoff. It used to live inside
4107
+ * processLocalQueue, which the outbox worker SKIPS when an account is in
4108
+ * backoff so a persistently-erroring account (e.g. Dovecot "Not
4109
+ * connected" storms) never recovered its claims, leaving a message stuck
4110
+ * "sending…" forever while healthy accounts drained. Bob 2026-05-31:
4111
+ * "stuck while others are getting sent." Now called every tick for every
4112
+ * account before the backoff gate. */
4113
+ recoverStaleClaims(accountId) {
4117
4114
  const STALE_CLAIM_MS = 5 * 60_000;
4118
4115
  const myPid = process.pid;
4119
- for (const dir of [outboxDir, queuedDir]) {
4116
+ const dirs = [
4117
+ path.join(getConfigDir(), "outbox", accountId),
4118
+ path.join(getConfigDir(), "sending", accountId, "queued"),
4119
+ ];
4120
+ for (const dir of dirs) {
4120
4121
  if (!fs.existsSync(dir))
4121
4122
  continue;
4122
4123
  for (const f of fs.readdirSync(dir)) {
@@ -4140,9 +4141,8 @@ export class ImapManager extends EventEmitter {
4140
4141
  ageMs = Date.now() - fs.statSync(path.join(dir, f)).mtimeMs;
4141
4142
  }
4142
4143
  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.
4144
+ // Live PID + recent mtime → genuine sibling owner, leave it.
4145
+ // Live PID + ancient mtime → recycled PID, sweep. Dead PID → sweep.
4146
4146
  if (alive && ageMs < STALE_CLAIM_MS)
4147
4147
  continue;
4148
4148
  try {
@@ -4153,6 +4153,14 @@ export class ImapManager extends EventEmitter {
4153
4153
  catch { /* ignore */ }
4154
4154
  }
4155
4155
  }
4156
+ }
4157
+ async processLocalQueue(accountId) {
4158
+ const outboxDir = path.join(getConfigDir(), "outbox", accountId);
4159
+ const queuedDir = path.join(getConfigDir(), "sending", accountId, "queued");
4160
+ // Recovery also runs unconditionally in the worker tick (before the
4161
+ // backoff gate); keep it here too for the direct-call path from
4162
+ // queueOutgoing. Idempotent — a no-op when nothing is stale.
4163
+ this.recoverStaleClaims(accountId);
4156
4164
  const filesToSend = [];
4157
4165
  for (const dir of [outboxDir, queuedDir]) {
4158
4166
  if (!fs.existsSync(dir))
@@ -4634,7 +4642,14 @@ export class ImapManager extends EventEmitter {
4634
4642
  // per-account sweep picks them up below.
4635
4643
  this.routeGeneralOutbox();
4636
4644
  for (const [accountId] of this.configs) {
4637
- // Skip accounts in backoff
4645
+ // Recover stale claims FIRST, unconditionally — it's a local
4646
+ // FS op with no network, so it must not be gated by send
4647
+ // backoff. Otherwise an account stuck in backoff (Dovecot "Not
4648
+ // connected" storms) never unclaims an orphaned .sending- file,
4649
+ // and a message sits "sending…" forever while other accounts
4650
+ // drain (Bob 2026-05-31).
4651
+ this.recoverStaleClaims(accountId);
4652
+ // Skip the SEND for accounts in backoff (network ops only).
4638
4653
  const retryAfter = this.outboxBackoff.get(accountId) || 0;
4639
4654
  if (now < retryAfter)
4640
4655
  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.79",
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",