@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.
- package/index.js +75 -44
- 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.
|
|
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
|
-
//
|
|
5178
|
-
//
|
|
5179
|
-
//
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
|
|
5183
|
-
|
|
5184
|
-
|
|
5185
|
-
|
|
5186
|
-
|
|
5187
|
-
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
|
|
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
|
-
|
|
5196
|
-
|
|
5197
|
-
|
|
5198
|
-
|
|
5199
|
-
|
|
5200
|
-
|
|
5201
|
-
|
|
5202
|
-
|
|
5203
|
-
|
|
5204
|
-
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
|
|
5208
|
-
|
|
5209
|
-
|
|
5210
|
-
|
|
5211
|
-
|
|
5212
|
-
|
|
5213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5271
|
-
|
|
5272
|
-
await
|
|
5273
|
-
|
|
5274
|
-
|
|
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
|