@bobfrankston/rmfmail 1.2.65 → 1.2.67

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.
@@ -5055,54 +5055,68 @@ export class ImapManager extends EventEmitter {
5055
5055
  const result = await this.withConnection(accountId, async (client) => {
5056
5056
  const flags = await client.getFlags(outboxFolder.path, uid);
5057
5057
 
5058
- // Sweep stale claims. $Sending-<host>-<sec> with old timestamp,
5059
- // or legacy $Sending-<host> without timestamp (treated as
5060
- // stale; if the real owner is alive it'll re-claim next tick).
5061
- const claimFlags = flags.filter((f: string) => f.startsWith("$Sending"));
5062
- for (const cf of claimFlags) {
5063
- const m = cf.match(/^\$Sending-(.+?)(?:-(\d+))?$/);
5064
- if (!m) continue;
5065
- const tsSec = m[2] ? parseInt(m[2]) : 0;
5066
- const ageSec = nowSec - tsSec;
5067
- if (ageSec * 1000 > STALE_CLAIM_MS) {
5068
- try {
5069
- await client.removeFlags(outboxFolder.path, uid, [cf]);
5070
- console.log(` [outbox] Swept stale claim ${cf} on UID ${uid} (age ${Math.round(ageSec / 60)}m)`);
5071
- } catch { /* ignore */ }
5058
+ // The $Sending/$Failed/$PermanentFailure interlock uses CUSTOM
5059
+ // IMAP keywords, which only work when the server advertises \*
5060
+ // in PERMANENTFLAGS. AOL (and some others) don't, and reject a
5061
+ // STORE of any custom keyword with "[CLIENTBUG] UID STORE
5062
+ // Command arguments invalid" which wedged the whole outbox in
5063
+ // a 5-minute retry loop, so nothing ever sent (Bob 2026-06-26,
5064
+ // from the log). Gate the keyword dance on real support; without
5065
+ // it, send best-effort with no IMAP-side claim (correct for a
5066
+ // single device there's no keyword to coordinate on anyway).
5067
+ const supportsKeywords = !!(client as any).mailboxInfo?.permanentFlags?.includes("\\*");
5068
+
5069
+ if (supportsKeywords) {
5070
+ // Sweep stale claims. $Sending-<host>-<sec> with old timestamp,
5071
+ // or legacy $Sending-<host> without timestamp (treated as
5072
+ // stale; if the real owner is alive it'll re-claim next tick).
5073
+ const claimFlags = flags.filter((f: string) => f.startsWith("$Sending"));
5074
+ for (const cf of claimFlags) {
5075
+ const m = cf.match(/^\$Sending-(.+?)(?:-(\d+))?$/);
5076
+ if (!m) continue;
5077
+ const tsSec = m[2] ? parseInt(m[2]) : 0;
5078
+ const ageSec = nowSec - tsSec;
5079
+ if (ageSec * 1000 > STALE_CLAIM_MS) {
5080
+ try {
5081
+ await client.removeFlags(outboxFolder.path, uid, [cf]);
5082
+ console.log(` [outbox] Swept stale claim ${cf} on UID ${uid} (age ${Math.round(ageSec / 60)}m)`);
5083
+ } catch { /* ignore */ }
5084
+ }
5072
5085
  }
5073
- }
5074
5086
 
5075
- const flagsNow = (claimFlags.length > 0)
5076
- ? await client.getFlags(outboxFolder.path, uid)
5077
- : flags;
5078
- if (flagsNow.some((f: string) => f.startsWith("$Sending"))) return { skip: true };
5079
- if (flagsNow.includes("$PermanentFailure")) return { skip: true };
5080
- if (flagsNow.includes("$Failed")) {
5081
- await client.removeFlags(outboxFolder.path, uid, ["$Failed"]);
5082
- }
5087
+ const flagsNow = (claimFlags.length > 0)
5088
+ ? await client.getFlags(outboxFolder.path, uid)
5089
+ : flags;
5090
+ if (flagsNow.some((f: string) => f.startsWith("$Sending"))) return { skip: true };
5091
+ if (flagsNow.includes("$PermanentFailure")) return { skip: true };
5092
+ if (flagsNow.includes("$Failed")) {
5093
+ await client.removeFlags(outboxFolder.path, uid, ["$Failed"]);
5094
+ }
5083
5095
 
5084
- await client.addFlags(outboxFolder.path, uid, [sendingFlag]);
5096
+ await client.addFlags(outboxFolder.path, uid, [sendingFlag]);
5085
5097
 
5086
- // TOCTOU re-check: if two devices addFlags concurrently both
5087
- // see ≥2 sending flags. Fail-safe: both back off, next tick
5088
- // one wins.
5089
- const flagsAfter = await client.getFlags(outboxFolder.path, uid);
5090
- const sendingFlags = flagsAfter.filter((f: string) => f.startsWith("$Sending"));
5091
- if (sendingFlags.length > 1 || (sendingFlags.length === 1 && sendingFlags[0] !== sendingFlag)) {
5092
- await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
5093
- return { skip: true };
5098
+ // TOCTOU re-check: if two devices addFlags concurrently both
5099
+ // see ≥2 sending flags. Fail-safe: both back off, next tick
5100
+ // one wins.
5101
+ const flagsAfter = await client.getFlags(outboxFolder.path, uid);
5102
+ const sendingFlags = flagsAfter.filter((f: string) => f.startsWith("$Sending"));
5103
+ if (sendingFlags.length > 1 || (sendingFlags.length === 1 && sendingFlags[0] !== sendingFlag)) {
5104
+ await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
5105
+ return { skip: true };
5106
+ }
5094
5107
  }
5095
5108
 
5096
5109
  const msg = await client.fetchMessageByUid(outboxFolder.path, uid, { source: true });
5097
5110
  if (!msg?.source) {
5098
- await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
5111
+ if (supportsKeywords) await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
5099
5112
  return { skip: true };
5100
5113
  }
5101
- return { source: msg.source };
5114
+ return { source: msg.source, supportsKeywords };
5102
5115
  }, { lane: "outbox" });
5103
5116
 
5104
5117
  if ((result as any).skip) continue;
5105
5118
  const source = (result as any).source as string;
5119
+ const supportsKeywords = !!(result as any).supportsKeywords;
5106
5120
 
5107
5121
  // SMTP send is its own connection — not an IMAP op, doesn't go
5108
5122
  // through withConnection.
@@ -5147,12 +5161,14 @@ export class ImapManager extends EventEmitter {
5147
5161
  } catch (e: any) {
5148
5162
  const errMsg = e.message || String(e);
5149
5163
  console.error(` [outbox] Send failed UID ${uid}: ${errMsg}`);
5150
- try {
5151
- await this.withConnection(accountId, async (client) => {
5152
- await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
5153
- await client.addFlags(outboxFolder.path, uid, ["$Failed"]);
5154
- }, { lane: "outbox" });
5155
- } catch { /* best-effort */ }
5164
+ if (supportsKeywords) {
5165
+ try {
5166
+ await this.withConnection(accountId, async (client) => {
5167
+ await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
5168
+ await client.addFlags(outboxFolder.path, uid, ["$Failed"]);
5169
+ }, { lane: "outbox" });
5170
+ } catch { /* best-effort */ }
5171
+ }
5156
5172
  // Suppress the banner for transient network errors. ETIMEDOUT
5157
5173
  // on a Dovecot send is the server being slow / a TCP idle
5158
5174
  // drop, NOT a user-actionable failure — the next outbox tick
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.108",
3
+ "version": "0.1.109",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bobfrankston/mailx-imap",
9
- "version": "0.1.108",
9
+ "version": "0.1.109",
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.108",
3
+ "version": "0.1.109",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",