@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.
- package/index.d.ts +22 -0
- package/index.js +56 -31
- 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
|
-
|
|
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
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
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
|
-
|
|
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 →
|
|
4144
|
-
// Live PID + ancient mtime → PID
|
|
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
|
-
//
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|