@bobfrankston/mailx-imap 0.1.108 → 0.1.109
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.js +59 -43
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -5174,54 +5174,68 @@ export class ImapManager extends EventEmitter {
|
|
|
5174
5174
|
// long outbox drain never blocks them.
|
|
5175
5175
|
const result = await this.withConnection(accountId, async (client) => {
|
|
5176
5176
|
const flags = await client.getFlags(outboxFolder.path, uid);
|
|
5177
|
-
//
|
|
5178
|
-
//
|
|
5179
|
-
//
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
|
|
5183
|
-
|
|
5184
|
-
|
|
5185
|
-
|
|
5186
|
-
|
|
5187
|
-
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
|
|
5177
|
+
// The $Sending/$Failed/$PermanentFailure interlock uses CUSTOM
|
|
5178
|
+
// IMAP keywords, which only work when the server advertises \*
|
|
5179
|
+
// in PERMANENTFLAGS. AOL (and some others) don't, and reject a
|
|
5180
|
+
// STORE of any custom keyword with "[CLIENTBUG] UID STORE
|
|
5181
|
+
// Command arguments invalid" — which wedged the whole outbox in
|
|
5182
|
+
// a 5-minute retry loop, so nothing ever sent (Bob 2026-06-26,
|
|
5183
|
+
// from the log). Gate the keyword dance on real support; without
|
|
5184
|
+
// it, send best-effort with no IMAP-side claim (correct for a
|
|
5185
|
+
// single device — there's no keyword to coordinate on anyway).
|
|
5186
|
+
const supportsKeywords = !!client.mailboxInfo?.permanentFlags?.includes("\\*");
|
|
5187
|
+
if (supportsKeywords) {
|
|
5188
|
+
// Sweep stale claims. $Sending-<host>-<sec> with old timestamp,
|
|
5189
|
+
// or legacy $Sending-<host> without timestamp (treated as
|
|
5190
|
+
// stale; if the real owner is alive it'll re-claim next tick).
|
|
5191
|
+
const claimFlags = flags.filter((f) => f.startsWith("$Sending"));
|
|
5192
|
+
for (const cf of claimFlags) {
|
|
5193
|
+
const m = cf.match(/^\$Sending-(.+?)(?:-(\d+))?$/);
|
|
5194
|
+
if (!m)
|
|
5195
|
+
continue;
|
|
5196
|
+
const tsSec = m[2] ? parseInt(m[2]) : 0;
|
|
5197
|
+
const ageSec = nowSec - tsSec;
|
|
5198
|
+
if (ageSec * 1000 > STALE_CLAIM_MS) {
|
|
5199
|
+
try {
|
|
5200
|
+
await client.removeFlags(outboxFolder.path, uid, [cf]);
|
|
5201
|
+
console.log(` [outbox] Swept stale claim ${cf} on UID ${uid} (age ${Math.round(ageSec / 60)}m)`);
|
|
5202
|
+
}
|
|
5203
|
+
catch { /* ignore */ }
|
|
5191
5204
|
}
|
|
5192
|
-
catch { /* ignore */ }
|
|
5193
5205
|
}
|
|
5194
|
-
|
|
5195
|
-
|
|
5196
|
-
|
|
5197
|
-
|
|
5198
|
-
|
|
5199
|
-
|
|
5200
|
-
|
|
5201
|
-
|
|
5202
|
-
|
|
5203
|
-
|
|
5204
|
-
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
|
|
5208
|
-
|
|
5209
|
-
|
|
5210
|
-
|
|
5211
|
-
|
|
5212
|
-
|
|
5213
|
-
|
|
5206
|
+
const flagsNow = (claimFlags.length > 0)
|
|
5207
|
+
? await client.getFlags(outboxFolder.path, uid)
|
|
5208
|
+
: flags;
|
|
5209
|
+
if (flagsNow.some((f) => f.startsWith("$Sending")))
|
|
5210
|
+
return { skip: true };
|
|
5211
|
+
if (flagsNow.includes("$PermanentFailure"))
|
|
5212
|
+
return { skip: true };
|
|
5213
|
+
if (flagsNow.includes("$Failed")) {
|
|
5214
|
+
await client.removeFlags(outboxFolder.path, uid, ["$Failed"]);
|
|
5215
|
+
}
|
|
5216
|
+
await client.addFlags(outboxFolder.path, uid, [sendingFlag]);
|
|
5217
|
+
// TOCTOU re-check: if two devices addFlags concurrently both
|
|
5218
|
+
// see ≥2 sending flags. Fail-safe: both back off, next tick
|
|
5219
|
+
// one wins.
|
|
5220
|
+
const flagsAfter = await client.getFlags(outboxFolder.path, uid);
|
|
5221
|
+
const sendingFlags = flagsAfter.filter((f) => f.startsWith("$Sending"));
|
|
5222
|
+
if (sendingFlags.length > 1 || (sendingFlags.length === 1 && sendingFlags[0] !== sendingFlag)) {
|
|
5223
|
+
await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
|
|
5224
|
+
return { skip: true };
|
|
5225
|
+
}
|
|
5214
5226
|
}
|
|
5215
5227
|
const msg = await client.fetchMessageByUid(outboxFolder.path, uid, { source: true });
|
|
5216
5228
|
if (!msg?.source) {
|
|
5217
|
-
|
|
5229
|
+
if (supportsKeywords)
|
|
5230
|
+
await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
|
|
5218
5231
|
return { skip: true };
|
|
5219
5232
|
}
|
|
5220
|
-
return { source: msg.source };
|
|
5233
|
+
return { source: msg.source, supportsKeywords };
|
|
5221
5234
|
}, { lane: "outbox" });
|
|
5222
5235
|
if (result.skip)
|
|
5223
5236
|
continue;
|
|
5224
5237
|
const source = result.source;
|
|
5238
|
+
const supportsKeywords = !!result.supportsKeywords;
|
|
5225
5239
|
// SMTP send is its own connection — not an IMAP op, doesn't go
|
|
5226
5240
|
// through withConnection.
|
|
5227
5241
|
try {
|
|
@@ -5267,13 +5281,15 @@ export class ImapManager extends EventEmitter {
|
|
|
5267
5281
|
catch (e) {
|
|
5268
5282
|
const errMsg = e.message || String(e);
|
|
5269
5283
|
console.error(` [outbox] Send failed UID ${uid}: ${errMsg}`);
|
|
5270
|
-
|
|
5271
|
-
|
|
5272
|
-
await
|
|
5273
|
-
|
|
5274
|
-
|
|
5284
|
+
if (supportsKeywords) {
|
|
5285
|
+
try {
|
|
5286
|
+
await this.withConnection(accountId, async (client) => {
|
|
5287
|
+
await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
|
|
5288
|
+
await client.addFlags(outboxFolder.path, uid, ["$Failed"]);
|
|
5289
|
+
}, { lane: "outbox" });
|
|
5290
|
+
}
|
|
5291
|
+
catch { /* best-effort */ }
|
|
5275
5292
|
}
|
|
5276
|
-
catch { /* best-effort */ }
|
|
5277
5293
|
// Suppress the banner for transient network errors. ETIMEDOUT
|
|
5278
5294
|
// on a Dovecot send is the server being slow / a TCP idle
|
|
5279
5295
|
// drop, NOT a user-actionable failure — the next outbox tick
|