@bobfrankston/mailx-imap 0.1.108 → 0.1.110

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 +75 -44
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -1764,7 +1764,18 @@ export class ImapManager extends EventEmitter {
1764
1764
  // Apply changed-state FETCH — flag updates since prior modSeq.
1765
1765
  for (const m of qr.changedMessages) {
1766
1766
  try {
1767
+ // LOCAL-FIRST: never let a server reconcile clobber a flag
1768
+ // the user just changed locally but whose push hasn't drained
1769
+ // yet — that's the "I unflag it and it quickly comes back"
1770
+ // bug (Bob 2026-06-27). Skip any message with a pending flag
1771
+ // action; the drain will push it and a later reconcile syncs.
1772
+ const pendingFlag = this.db.findPendingSyncAction(accountId, "flags", m.uid, folderId);
1773
+ if (pendingFlag) {
1774
+ console.error(` [flag-reconcile] SKIP uid=${m.uid} folder=${folderId} — pending local flag action wins`);
1775
+ continue;
1776
+ }
1767
1777
  const flagsArr = Array.from(m.flags || []).map(f => String(f));
1778
+ console.error(` [flag-reconcile] OVERWRITE uid=${m.uid} folder=${folderId} server=[${flagsArr.join(",")}]`);
1768
1779
  this.db.updateMessageFlags(accountId, folderId, m.uid, flagsArr);
1769
1780
  }
1770
1781
  catch { /* row may have just been VANISHED */ }
@@ -4168,13 +4179,17 @@ export class ImapManager extends EventEmitter {
4168
4179
  const MANAGED = ["\\Seen", "\\Flagged"];
4169
4180
  const impliedRemove = MANAGED.filter(f => !desired.includes(f));
4170
4181
  const toRemove = [...new Set([...explicitRemove, ...impliedRemove])];
4182
+ // console.error so it lands in the captured daemon log
4183
+ // (worker stdout is NOT piped; stderr is). Temporary
4184
+ // diagnostic for the "flag won't persist" hunt.
4185
+ console.error(` [flag-drain] ${accountId} uid=${action.uid} folder="${folder.path}" +[${desired.join(",")}] -[${toRemove.join(",")}]`);
4171
4186
  if (desired.length > 0) {
4172
4187
  await client.addFlags(folder.path, action.uid, desired);
4173
4188
  }
4174
4189
  if (toRemove.length > 0) {
4175
4190
  await client.removeFlags(folder.path, action.uid, toRemove);
4176
4191
  }
4177
- console.log(` [sync] Updated flags UID ${action.uid} (+[${desired.join(",")}] -[${toRemove.join(",")}])`);
4192
+ console.error(` [flag-drain] ${accountId} uid=${action.uid} OK (server STORE done)`);
4178
4193
  break;
4179
4194
  }
4180
4195
  case "append": {
@@ -5174,54 +5189,68 @@ export class ImapManager extends EventEmitter {
5174
5189
  // long outbox drain never blocks them.
5175
5190
  const result = await this.withConnection(accountId, async (client) => {
5176
5191
  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)`);
5192
+ // The $Sending/$Failed/$PermanentFailure interlock uses CUSTOM
5193
+ // IMAP keywords, which only work when the server advertises \*
5194
+ // in PERMANENTFLAGS. AOL (and some others) don't, and reject a
5195
+ // STORE of any custom keyword with "[CLIENTBUG] UID STORE
5196
+ // Command arguments invalid" which wedged the whole outbox in
5197
+ // a 5-minute retry loop, so nothing ever sent (Bob 2026-06-26,
5198
+ // from the log). Gate the keyword dance on real support; without
5199
+ // it, send best-effort with no IMAP-side claim (correct for a
5200
+ // single device there's no keyword to coordinate on anyway).
5201
+ const supportsKeywords = !!client.mailboxInfo?.permanentFlags?.includes("\\*");
5202
+ if (supportsKeywords) {
5203
+ // Sweep stale claims. $Sending-<host>-<sec> with old timestamp,
5204
+ // or legacy $Sending-<host> without timestamp (treated as
5205
+ // stale; if the real owner is alive it'll re-claim next tick).
5206
+ const claimFlags = flags.filter((f) => f.startsWith("$Sending"));
5207
+ for (const cf of claimFlags) {
5208
+ const m = cf.match(/^\$Sending-(.+?)(?:-(\d+))?$/);
5209
+ if (!m)
5210
+ continue;
5211
+ const tsSec = m[2] ? parseInt(m[2]) : 0;
5212
+ const ageSec = nowSec - tsSec;
5213
+ if (ageSec * 1000 > STALE_CLAIM_MS) {
5214
+ try {
5215
+ await client.removeFlags(outboxFolder.path, uid, [cf]);
5216
+ console.log(` [outbox] Swept stale claim ${cf} on UID ${uid} (age ${Math.round(ageSec / 60)}m)`);
5217
+ }
5218
+ catch { /* ignore */ }
5191
5219
  }
5192
- catch { /* ignore */ }
5193
5220
  }
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 };
5221
+ const flagsNow = (claimFlags.length > 0)
5222
+ ? await client.getFlags(outboxFolder.path, uid)
5223
+ : flags;
5224
+ if (flagsNow.some((f) => f.startsWith("$Sending")))
5225
+ return { skip: true };
5226
+ if (flagsNow.includes("$PermanentFailure"))
5227
+ return { skip: true };
5228
+ if (flagsNow.includes("$Failed")) {
5229
+ await client.removeFlags(outboxFolder.path, uid, ["$Failed"]);
5230
+ }
5231
+ await client.addFlags(outboxFolder.path, uid, [sendingFlag]);
5232
+ // TOCTOU re-check: if two devices addFlags concurrently both
5233
+ // see ≥2 sending flags. Fail-safe: both back off, next tick
5234
+ // one wins.
5235
+ const flagsAfter = await client.getFlags(outboxFolder.path, uid);
5236
+ const sendingFlags = flagsAfter.filter((f) => f.startsWith("$Sending"));
5237
+ if (sendingFlags.length > 1 || (sendingFlags.length === 1 && sendingFlags[0] !== sendingFlag)) {
5238
+ await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
5239
+ return { skip: true };
5240
+ }
5214
5241
  }
5215
5242
  const msg = await client.fetchMessageByUid(outboxFolder.path, uid, { source: true });
5216
5243
  if (!msg?.source) {
5217
- await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
5244
+ if (supportsKeywords)
5245
+ await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
5218
5246
  return { skip: true };
5219
5247
  }
5220
- return { source: msg.source };
5248
+ return { source: msg.source, supportsKeywords };
5221
5249
  }, { lane: "outbox" });
5222
5250
  if (result.skip)
5223
5251
  continue;
5224
5252
  const source = result.source;
5253
+ const supportsKeywords = !!result.supportsKeywords;
5225
5254
  // SMTP send is its own connection — not an IMAP op, doesn't go
5226
5255
  // through withConnection.
5227
5256
  try {
@@ -5267,13 +5296,15 @@ export class ImapManager extends EventEmitter {
5267
5296
  catch (e) {
5268
5297
  const errMsg = e.message || String(e);
5269
5298
  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" });
5299
+ if (supportsKeywords) {
5300
+ try {
5301
+ await this.withConnection(accountId, async (client) => {
5302
+ await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
5303
+ await client.addFlags(outboxFolder.path, uid, ["$Failed"]);
5304
+ }, { lane: "outbox" });
5305
+ }
5306
+ catch { /* best-effort */ }
5275
5307
  }
5276
- catch { /* best-effort */ }
5277
5308
  // Suppress the banner for transient network errors. ETIMEDOUT
5278
5309
  // on a Dovecot send is the server being slow / a TCP idle
5279
5310
  // 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.110",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",