@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.
Files changed (2) hide show
  1. package/index.js +59 -43
  2. 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
- // Sweep stale claims. $Sending-<host>-<sec> with old timestamp,
5178
- // or legacy $Sending-<host> without timestamp (treated as
5179
- // stale; if the real owner is alive it'll re-claim next tick).
5180
- const claimFlags = flags.filter((f) => f.startsWith("$Sending"));
5181
- for (const cf of claimFlags) {
5182
- const m = cf.match(/^\$Sending-(.+?)(?:-(\d+))?$/);
5183
- if (!m)
5184
- continue;
5185
- const tsSec = m[2] ? parseInt(m[2]) : 0;
5186
- const ageSec = nowSec - tsSec;
5187
- if (ageSec * 1000 > STALE_CLAIM_MS) {
5188
- try {
5189
- await client.removeFlags(outboxFolder.path, uid, [cf]);
5190
- console.log(` [outbox] Swept stale claim ${cf} on UID ${uid} (age ${Math.round(ageSec / 60)}m)`);
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
- const flagsNow = (claimFlags.length > 0)
5196
- ? await client.getFlags(outboxFolder.path, uid)
5197
- : flags;
5198
- if (flagsNow.some((f) => f.startsWith("$Sending")))
5199
- return { skip: true };
5200
- if (flagsNow.includes("$PermanentFailure"))
5201
- return { skip: true };
5202
- if (flagsNow.includes("$Failed")) {
5203
- await client.removeFlags(outboxFolder.path, uid, ["$Failed"]);
5204
- }
5205
- await client.addFlags(outboxFolder.path, uid, [sendingFlag]);
5206
- // TOCTOU re-check: if two devices addFlags concurrently both
5207
- // see ≥2 sending flags. Fail-safe: both back off, next tick
5208
- // one wins.
5209
- const flagsAfter = await client.getFlags(outboxFolder.path, uid);
5210
- const sendingFlags = flagsAfter.filter((f) => f.startsWith("$Sending"));
5211
- if (sendingFlags.length > 1 || (sendingFlags.length === 1 && sendingFlags[0] !== sendingFlag)) {
5212
- await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
5213
- return { skip: true };
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
- await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
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
- try {
5271
- await this.withConnection(accountId, async (client) => {
5272
- await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
5273
- await client.addFlags(outboxFolder.path, uid, ["$Failed"]);
5274
- }, { lane: "outbox" });
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.108",
3
+ "version": "0.1.109",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",