@bobfrankston/rmfmail 1.1.203 → 1.1.205

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.
@@ -4038,35 +4038,35 @@ export class ImapManager extends EventEmitter {
4038
4038
  * sending/queued/ on every send — that write is gone now, so scanning the
4039
4039
  * directory is safe again. Any legitimate files that land there (crash
4040
4040
  * recovery, manual drop) will get sent. */
4041
- private async processLocalQueue(accountId: string): Promise<void> {
4042
- const outboxDir = path.join(getConfigDir(), "outbox", accountId);
4043
- const queuedDir = path.join(getConfigDir(), "sending", accountId, "queued");
4044
-
4045
- // Recovery sweep: any *.sending-<host>-<pid> on THIS host whose PID is
4046
- // dead (process crashed mid-send) gets unclaimed so the next tick can
4047
- // retry. Foreign hosts are left alone — we have no way to know if their
4048
- // process is alive. Cross-host stale recovery is the IMAP-folder path's
4049
- // job (sweeper looks at server-side claim flags, not local files).
4050
- // Stale-claim recovery. A claim is "stale" if any of:
4051
- // (a) the PID is dead original owner crashed mid-send
4052
- // (b) the PID is alive BUT it's not us, and the file mtime is
4053
- // older than STALE_CLAIM_MS — the OS recycled the PID for
4054
- // some other process. `process.kill(pid, 0)` returning success
4055
- // only proves *some* process owns that PID, not that it's
4056
- // our long-dead mailx daemon. Without the age guard, a
4057
- // claim survives forever as soon as any other Node process
4058
- // (statusline, msger, npm) gets the recycled PID. Bob saw
4059
- // this exact case: `.sending-rmf39-63196` sat in the queue
4060
- // for 7+ hours because PID 63196 was now an unrelated Node.
4061
- // (c) it's our PID never sweep our own claim.
4062
- // 5 minutes: SMTP transactions complete in seconds; 5m of
4063
- // claim with no progress means the worker is wedged or PID
4064
- // got recycled. Earlier 1h was a band-aid that left Bob's
4065
- // outbox stuck for hours when an SMTP wedge crashed the
4066
- // worker mid-flight.
4041
+ /** Recover stale `.sending-<host>-<pid>` claims for an account back to
4042
+ * plain `.ltr` so the next tick can retry. A claim is stale if:
4043
+ * (a) the PID is dead original owner crashed/was replaced mid-send;
4044
+ * (b) the PID is alive but it's not us and the file mtime is older than
4045
+ * STALE_CLAIM_MS `process.kill(pid,0)` only proves *some* process
4046
+ * owns that PID, not our long-dead daemon (OS recycles PIDs); the
4047
+ * age guard stops an unrelated Node (statusline/msger/npm) inheriting
4048
+ * a recycled PID from pinning the claim forever (Bob's 7-hour stuck
4049
+ * `.sending-rmf39-63196`);
4050
+ * (c) it's our PID never sweep our own live claim.
4051
+ * Foreign hosts are left alone (we can't probe their PIDs); cross-host
4052
+ * recovery is the IMAP-folder sweeper's job.
4053
+ *
4054
+ * CRITICAL: this is a local filesystem op with NO network I/O, so it MUST
4055
+ * run independently of send-backoff. It used to live inside
4056
+ * processLocalQueue, which the outbox worker SKIPS when an account is in
4057
+ * backoff so a persistently-erroring account (e.g. Dovecot "Not
4058
+ * connected" storms) never recovered its claims, leaving a message stuck
4059
+ * "sending…" forever while healthy accounts drained. Bob 2026-05-31:
4060
+ * "stuck while others are getting sent." Now called every tick for every
4061
+ * account before the backoff gate. */
4062
+ private recoverStaleClaims(accountId: string): void {
4067
4063
  const STALE_CLAIM_MS = 5 * 60_000;
4068
4064
  const myPid = process.pid;
4069
- for (const dir of [outboxDir, queuedDir]) {
4065
+ const dirs = [
4066
+ path.join(getConfigDir(), "outbox", accountId),
4067
+ path.join(getConfigDir(), "sending", accountId, "queued"),
4068
+ ];
4069
+ for (const dir of dirs) {
4070
4070
  if (!fs.existsSync(dir)) continue;
4071
4071
  for (const f of fs.readdirSync(dir)) {
4072
4072
  const m = f.match(/^(.+)\.sending-([^-]+)-(\d+)$/);
@@ -4079,9 +4079,8 @@ export class ImapManager extends EventEmitter {
4079
4079
  try { process.kill(pid, 0); alive = true; } catch { /* dead */ }
4080
4080
  let ageMs = Infinity;
4081
4081
  try { ageMs = Date.now() - fs.statSync(path.join(dir, f)).mtimeMs; } catch { /* */ }
4082
- // Live PID + recent mtime → assume genuine sibling owner.
4083
- // Live PID + ancient mtime → PID got recycled, sweep it.
4084
- // Dead PID → sweep regardless of age.
4082
+ // Live PID + recent mtime → genuine sibling owner, leave it.
4083
+ // Live PID + ancient mtime → recycled PID, sweep. Dead PID → sweep.
4085
4084
  if (alive && ageMs < STALE_CLAIM_MS) continue;
4086
4085
  try {
4087
4086
  fs.renameSync(path.join(dir, f), path.join(dir, original));
@@ -4090,6 +4089,16 @@ export class ImapManager extends EventEmitter {
4090
4089
  } catch { /* ignore */ }
4091
4090
  }
4092
4091
  }
4092
+ }
4093
+
4094
+ private async processLocalQueue(accountId: string): Promise<void> {
4095
+ const outboxDir = path.join(getConfigDir(), "outbox", accountId);
4096
+ const queuedDir = path.join(getConfigDir(), "sending", accountId, "queued");
4097
+
4098
+ // Recovery also runs unconditionally in the worker tick (before the
4099
+ // backoff gate); keep it here too for the direct-call path from
4100
+ // queueOutgoing. Idempotent — a no-op when nothing is stale.
4101
+ this.recoverStaleClaims(accountId);
4093
4102
 
4094
4103
  const filesToSend: { dir: string; file: string }[] = [];
4095
4104
  for (const dir of [outboxDir, queuedDir]) {
@@ -4541,7 +4550,14 @@ export class ImapManager extends EventEmitter {
4541
4550
  // per-account sweep picks them up below.
4542
4551
  this.routeGeneralOutbox();
4543
4552
  for (const [accountId] of this.configs) {
4544
- // Skip accounts in backoff
4553
+ // Recover stale claims FIRST, unconditionally — it's a local
4554
+ // FS op with no network, so it must not be gated by send
4555
+ // backoff. Otherwise an account stuck in backoff (Dovecot "Not
4556
+ // connected" storms) never unclaims an orphaned .sending- file,
4557
+ // and a message sits "sending…" forever while other accounts
4558
+ // drain (Bob 2026-05-31).
4559
+ this.recoverStaleClaims(accountId);
4560
+ // Skip the SEND for accounts in backoff (network ops only).
4545
4561
  const retryAfter = this.outboxBackoff.get(accountId) || 0;
4546
4562
  if (now < retryAfter) continue;
4547
4563
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.78",
3
+ "version": "0.1.79",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bobfrankston/mailx-imap",
9
- "version": "0.1.78",
9
+ "version": "0.1.79",
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.78",
3
+ "version": "0.1.79",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",