@bobfrankston/mailx-imap 0.1.109 → 0.1.111
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.d.ts +17 -0
- package/index.js +102 -5
- package/package.json +7 -7
package/index.d.ts
CHANGED
|
@@ -604,7 +604,15 @@ export declare class ImapManager extends EventEmitter {
|
|
|
604
604
|
* Each per-UID step is its own withConnection({slow}) call so the queue
|
|
605
605
|
* yields between messages: a click-to-view body in the middle of a
|
|
606
606
|
* 10-message outbox drain doesn't wait for all 10 to finish. */
|
|
607
|
+
/** Per-account re-entrancy guard for processOutbox. The worker tick and
|
|
608
|
+
* the chained post-APPEND call (processLocalQueue) can overlap; on
|
|
609
|
+
* servers without custom-keyword support ($Sending interlock) two
|
|
610
|
+
* concurrent drains could both fetch and SMTP the same Outbox UID —
|
|
611
|
+
* a double-send. One drain per account at a time; latecomers no-op
|
|
612
|
+
* (the 10s tick retries anything they would have handled). */
|
|
613
|
+
private outboxDrainInFlight;
|
|
607
614
|
processOutbox(accountId: string): Promise<void>;
|
|
615
|
+
private _processOutbox;
|
|
608
616
|
/** Start background Outbox worker — runs immediately then every 10 seconds */
|
|
609
617
|
private outboxBackoff;
|
|
610
618
|
private outboxBackoffDelay;
|
|
@@ -660,6 +668,15 @@ export declare class ImapManager extends EventEmitter {
|
|
|
660
668
|
private readonly SENT_SWEEP_INTERVAL_MS;
|
|
661
669
|
startSentSweep(): void;
|
|
662
670
|
stopSentSweep(): void;
|
|
671
|
+
/** Message-IDs sweepSentOnce has already re-APPENDed this daemon run.
|
|
672
|
+
* HARD GUARD against perpetual motion: a re-appended copy syncs back in,
|
|
673
|
+
* refreshes the row's cached_at, and keeps it inside the sweep window —
|
|
674
|
+
* so if the header search ever reports "missing" again (it failed
|
|
675
|
+
* silently for ~13 days on a flaky lane connection, 2026-07-02, 3,704
|
|
676
|
+
* copies of one message), the sweep would append forever. One re-append
|
|
677
|
+
* per Message-ID per daemon lifetime; a genuinely-lost message still
|
|
678
|
+
* gets its one repair, and a false "missing" can no longer loop. */
|
|
679
|
+
private sentSweepAppended;
|
|
663
680
|
private sweepSentOnce;
|
|
664
681
|
private configWatchers;
|
|
665
682
|
private cloudPollTimers;
|
package/index.js
CHANGED
|
@@ -1222,6 +1222,7 @@ export class ImapManager extends EventEmitter {
|
|
|
1222
1222
|
uid: msg.uid,
|
|
1223
1223
|
messageId: msg.messageId,
|
|
1224
1224
|
date: msg.date || new Date(),
|
|
1225
|
+
sentDate: msg.sentDate,
|
|
1225
1226
|
subject: msg.subject,
|
|
1226
1227
|
from: msg.from,
|
|
1227
1228
|
to: msg.to,
|
|
@@ -1376,6 +1377,7 @@ export class ImapManager extends EventEmitter {
|
|
|
1376
1377
|
inReplyTo: msg.inReplyTo || "",
|
|
1377
1378
|
references: [],
|
|
1378
1379
|
date: toFiniteDateMs(msg.date),
|
|
1380
|
+
sentDate: msg.sentDate instanceof Date ? toFiniteDateMs(msg.sentDate) : undefined,
|
|
1379
1381
|
subject: msg.subject || "",
|
|
1380
1382
|
from: toEmailAddress(msg.from?.[0] || {}),
|
|
1381
1383
|
to: toEmailAddresses(msg.to || []),
|
|
@@ -1764,7 +1766,18 @@ export class ImapManager extends EventEmitter {
|
|
|
1764
1766
|
// Apply changed-state FETCH — flag updates since prior modSeq.
|
|
1765
1767
|
for (const m of qr.changedMessages) {
|
|
1766
1768
|
try {
|
|
1769
|
+
// LOCAL-FIRST: never let a server reconcile clobber a flag
|
|
1770
|
+
// the user just changed locally but whose push hasn't drained
|
|
1771
|
+
// yet — that's the "I unflag it and it quickly comes back"
|
|
1772
|
+
// bug (Bob 2026-06-27). Skip any message with a pending flag
|
|
1773
|
+
// action; the drain will push it and a later reconcile syncs.
|
|
1774
|
+
const pendingFlag = this.db.findPendingSyncAction(accountId, "flags", m.uid, folderId);
|
|
1775
|
+
if (pendingFlag) {
|
|
1776
|
+
console.error(` [flag-reconcile] SKIP uid=${m.uid} folder=${folderId} — pending local flag action wins`);
|
|
1777
|
+
continue;
|
|
1778
|
+
}
|
|
1767
1779
|
const flagsArr = Array.from(m.flags || []).map(f => String(f));
|
|
1780
|
+
console.error(` [flag-reconcile] OVERWRITE uid=${m.uid} folder=${folderId} server=[${flagsArr.join(",")}]`);
|
|
1768
1781
|
this.db.updateMessageFlags(accountId, folderId, m.uid, flagsArr);
|
|
1769
1782
|
}
|
|
1770
1783
|
catch { /* row may have just been VANISHED */ }
|
|
@@ -2014,6 +2027,7 @@ export class ImapManager extends EventEmitter {
|
|
|
2014
2027
|
inReplyTo: msg.inReplyTo || "",
|
|
2015
2028
|
references: [],
|
|
2016
2029
|
date: toFiniteDateMs(msg.date),
|
|
2030
|
+
sentDate: msg.sentDate instanceof Date ? toFiniteDateMs(msg.sentDate) : undefined,
|
|
2017
2031
|
subject: msg.subject || "",
|
|
2018
2032
|
from: toEmailAddress(msg.from?.[0] || {}),
|
|
2019
2033
|
to: toEmailAddresses(msg.to || []),
|
|
@@ -2679,12 +2693,22 @@ export class ImapManager extends EventEmitter {
|
|
|
2679
2693
|
if (Number.isFinite(t))
|
|
2680
2694
|
dateMs = t;
|
|
2681
2695
|
}
|
|
2696
|
+
// Sent time from the Date: header (iflow exposes it as sentDate,
|
|
2697
|
+
// separate from msg.date = INTERNALDATE). Undefined → store
|
|
2698
|
+
// defaults sent_date to dateMs.
|
|
2699
|
+
let sentMs;
|
|
2700
|
+
if (msg.sentDate instanceof Date) {
|
|
2701
|
+
const st = msg.sentDate.getTime();
|
|
2702
|
+
if (Number.isFinite(st))
|
|
2703
|
+
sentMs = st;
|
|
2704
|
+
}
|
|
2682
2705
|
this.db.upsertMessage({
|
|
2683
2706
|
accountId, folderId, uid: msg.uid,
|
|
2684
2707
|
messageId: msg.messageId || "",
|
|
2685
2708
|
inReplyTo: msg.inReplyTo || "",
|
|
2686
2709
|
references: msg.references || [],
|
|
2687
2710
|
date: dateMs,
|
|
2711
|
+
sentDate: sentMs,
|
|
2688
2712
|
subject: msg.subject || "",
|
|
2689
2713
|
from: toEmailAddress(msg.from?.[0] || {}),
|
|
2690
2714
|
to: toEmailAddresses(msg.to || []),
|
|
@@ -4168,13 +4192,17 @@ export class ImapManager extends EventEmitter {
|
|
|
4168
4192
|
const MANAGED = ["\\Seen", "\\Flagged"];
|
|
4169
4193
|
const impliedRemove = MANAGED.filter(f => !desired.includes(f));
|
|
4170
4194
|
const toRemove = [...new Set([...explicitRemove, ...impliedRemove])];
|
|
4195
|
+
// console.error so it lands in the captured daemon log
|
|
4196
|
+
// (worker stdout is NOT piped; stderr is). Temporary
|
|
4197
|
+
// diagnostic for the "flag won't persist" hunt.
|
|
4198
|
+
console.error(` [flag-drain] ${accountId} uid=${action.uid} folder="${folder.path}" +[${desired.join(",")}] -[${toRemove.join(",")}]`);
|
|
4171
4199
|
if (desired.length > 0) {
|
|
4172
4200
|
await client.addFlags(folder.path, action.uid, desired);
|
|
4173
4201
|
}
|
|
4174
4202
|
if (toRemove.length > 0) {
|
|
4175
4203
|
await client.removeFlags(folder.path, action.uid, toRemove);
|
|
4176
4204
|
}
|
|
4177
|
-
console.
|
|
4205
|
+
console.error(` [flag-drain] ${accountId} uid=${action.uid} OK (server STORE done)`);
|
|
4178
4206
|
break;
|
|
4179
4207
|
}
|
|
4180
4208
|
case "append": {
|
|
@@ -5026,6 +5054,16 @@ export class ImapManager extends EventEmitter {
|
|
|
5026
5054
|
await withTimeout(client.appendMessage(outboxPath, raw, ["\\Seen"]), 60_000, client, `outbox APPEND ${file}`);
|
|
5027
5055
|
fs.renameSync(claimedPath, path.join(sentDir, file));
|
|
5028
5056
|
console.log(` [outbox] Moved ${file} to IMAP Outbox → sent/`);
|
|
5057
|
+
// Chain the SMTP delivery immediately instead of waiting
|
|
5058
|
+
// for the next worker tick. The APPEND→tick→SMTP→Sent
|
|
5059
|
+
// chain added up to ~30s before the message appeared in
|
|
5060
|
+
// Sent, which read as "my message is gone" (Bob
|
|
5061
|
+
// 2026-07-01, repeatedly). Fire-and-forget; the tick
|
|
5062
|
+
// remains the retry backstop.
|
|
5063
|
+
setImmediate(() => {
|
|
5064
|
+
this.processOutbox(accountId)
|
|
5065
|
+
.catch((e) => console.error(` [outbox] chained processOutbox: ${e?.message || e}`));
|
|
5066
|
+
});
|
|
5029
5067
|
}
|
|
5030
5068
|
catch (e) {
|
|
5031
5069
|
// APPEND failed (connection dropped mid-send, server
|
|
@@ -5112,8 +5150,17 @@ export class ImapManager extends EventEmitter {
|
|
|
5112
5150
|
throw new Error("No recipients");
|
|
5113
5151
|
// Dedup: skip if this Message-ID has already been sent. Prevents the
|
|
5114
5152
|
// outbox from re-sending the same file across crash/restart cycles.
|
|
5115
|
-
|
|
5116
|
-
if (messageId &&
|
|
5153
|
+
// A message WITHOUT a Message-ID gets one synthesized here — the old
|
|
5154
|
+
// `if (messageId && …)` guard silently skipped BOTH the dedup check
|
|
5155
|
+
// AND recordSent for ID-less mail, so such a message could re-send
|
|
5156
|
+
// forever with no brake (2026-07-02 resend-loop audit).
|
|
5157
|
+
let messageId = messageIdMatch ? messageIdMatch[1] : "";
|
|
5158
|
+
if (!messageId) {
|
|
5159
|
+
messageId = `<mailx-${Date.now()}-${Math.random().toString(36).slice(2, 10)}@${account.email.split("@")[1] || "mailx.local"}>`;
|
|
5160
|
+
raw = raw.replace(/\r?\n\r?\n/, `\r\nMessage-ID: ${messageId}\r\n\r\n`);
|
|
5161
|
+
console.log(` [smtp] ${accountId}: synthesized Message-ID ${messageId} for ID-less outgoing message`);
|
|
5162
|
+
}
|
|
5163
|
+
if (this.db.hasSentMessage(messageId)) {
|
|
5117
5164
|
console.log(` [smtp] ${accountId}: SKIP ${messageId} — already in sent_log`);
|
|
5118
5165
|
return;
|
|
5119
5166
|
}
|
|
@@ -5148,7 +5195,25 @@ export class ImapManager extends EventEmitter {
|
|
|
5148
5195
|
* Each per-UID step is its own withConnection({slow}) call so the queue
|
|
5149
5196
|
* yields between messages: a click-to-view body in the middle of a
|
|
5150
5197
|
* 10-message outbox drain doesn't wait for all 10 to finish. */
|
|
5198
|
+
/** Per-account re-entrancy guard for processOutbox. The worker tick and
|
|
5199
|
+
* the chained post-APPEND call (processLocalQueue) can overlap; on
|
|
5200
|
+
* servers without custom-keyword support ($Sending interlock) two
|
|
5201
|
+
* concurrent drains could both fetch and SMTP the same Outbox UID —
|
|
5202
|
+
* a double-send. One drain per account at a time; latecomers no-op
|
|
5203
|
+
* (the 10s tick retries anything they would have handled). */
|
|
5204
|
+
outboxDrainInFlight = new Set();
|
|
5151
5205
|
async processOutbox(accountId) {
|
|
5206
|
+
if (this.outboxDrainInFlight.has(accountId))
|
|
5207
|
+
return;
|
|
5208
|
+
this.outboxDrainInFlight.add(accountId);
|
|
5209
|
+
try {
|
|
5210
|
+
await this._processOutbox(accountId);
|
|
5211
|
+
}
|
|
5212
|
+
finally {
|
|
5213
|
+
this.outboxDrainInFlight.delete(accountId);
|
|
5214
|
+
}
|
|
5215
|
+
}
|
|
5216
|
+
async _processOutbox(accountId) {
|
|
5152
5217
|
const outboxFolder = this.findFolder(accountId, "outbox");
|
|
5153
5218
|
if (!outboxFolder)
|
|
5154
5219
|
return;
|
|
@@ -5538,6 +5603,15 @@ export class ImapManager extends EventEmitter {
|
|
|
5538
5603
|
this.sentSweepInterval = null;
|
|
5539
5604
|
}
|
|
5540
5605
|
}
|
|
5606
|
+
/** Message-IDs sweepSentOnce has already re-APPENDed this daemon run.
|
|
5607
|
+
* HARD GUARD against perpetual motion: a re-appended copy syncs back in,
|
|
5608
|
+
* refreshes the row's cached_at, and keeps it inside the sweep window —
|
|
5609
|
+
* so if the header search ever reports "missing" again (it failed
|
|
5610
|
+
* silently for ~13 days on a flaky lane connection, 2026-07-02, 3,704
|
|
5611
|
+
* copies of one message), the sweep would append forever. One re-append
|
|
5612
|
+
* per Message-ID per daemon lifetime; a genuinely-lost message still
|
|
5613
|
+
* gets its one repair, and a false "missing" can no longer loop. */
|
|
5614
|
+
sentSweepAppended = new Set();
|
|
5541
5615
|
async sweepSentOnce(accountId) {
|
|
5542
5616
|
const sent = this.findFolder(accountId, "sent");
|
|
5543
5617
|
if (!sent)
|
|
@@ -5563,7 +5637,11 @@ export class ImapManager extends EventEmitter {
|
|
|
5563
5637
|
// a finally, the opposite of "dedicated".
|
|
5564
5638
|
await this.withConnection(accountId, async (client) => {
|
|
5565
5639
|
for (const row of rows) {
|
|
5566
|
-
|
|
5640
|
+
// Trim defensively: rows written before the iflow-direct
|
|
5641
|
+
// envelope-parser trim carry the header's fold whitespace
|
|
5642
|
+
// ("\t<...>"), which made HEADER searches return 0 and drove
|
|
5643
|
+
// the re-APPEND loop (2026-07-02, 3,707 duplicates).
|
|
5644
|
+
const msgId = (row.message_id || "").trim();
|
|
5567
5645
|
if (!msgId)
|
|
5568
5646
|
continue;
|
|
5569
5647
|
let serverUids = [];
|
|
@@ -5575,13 +5653,32 @@ export class ImapManager extends EventEmitter {
|
|
|
5575
5653
|
continue;
|
|
5576
5654
|
}
|
|
5577
5655
|
if (serverUids.length === 0) {
|
|
5578
|
-
// Not on server. Re-APPEND from .eml if we have one
|
|
5656
|
+
// Not on server. Re-APPEND from .eml if we have one —
|
|
5657
|
+
// but at most ONCE per Message-ID per daemon run (see
|
|
5658
|
+
// sentSweepAppended above; the 3,704-duplicate loop).
|
|
5579
5659
|
if (!row.body_path)
|
|
5580
5660
|
continue;
|
|
5661
|
+
if (this.sentSweepAppended.has(msgId)) {
|
|
5662
|
+
console.error(` [sent-sweep] ${accountId}: SKIP re-append of ${msgId} — already re-appended once this run; header search reporting it missing AGAIN means the search is unreliable, not the message lost`);
|
|
5663
|
+
continue;
|
|
5664
|
+
}
|
|
5665
|
+
// Cross-check the local DB before trusting the server
|
|
5666
|
+
// search: multiple local Sent rows with this Message-ID
|
|
5667
|
+
// mean prior copies synced IN from the server — it exists
|
|
5668
|
+
// there regardless of what the search just said.
|
|
5669
|
+
try {
|
|
5670
|
+
const localCopies = this.db.countMessagesByMessageId?.(accountId, sent.id, msgId) ?? 0;
|
|
5671
|
+
if (localCopies > 1) {
|
|
5672
|
+
console.error(` [sent-sweep] ${accountId}: SKIP re-append of ${msgId} — ${localCopies} local Sent rows already exist (server search returned 0 falsely)`);
|
|
5673
|
+
continue;
|
|
5674
|
+
}
|
|
5675
|
+
}
|
|
5676
|
+
catch { /* count helper missing — proceed to the once-guard */ }
|
|
5581
5677
|
try {
|
|
5582
5678
|
const buf = await this.bodyStore.readByPath(row.body_path);
|
|
5583
5679
|
if (!buf)
|
|
5584
5680
|
continue;
|
|
5681
|
+
this.sentSweepAppended.add(msgId);
|
|
5585
5682
|
await client.appendMessage(sent.path, buf, ["\\Seen"]);
|
|
5586
5683
|
appended++;
|
|
5587
5684
|
console.log(` [sent-sweep] ${accountId}: re-APPENDed ${msgId} (was missing on server)`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-imap",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.111",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -9,13 +9,13 @@
|
|
|
9
9
|
},
|
|
10
10
|
"license": "ISC",
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@bobfrankston/mailx-types": "^0.1.
|
|
12
|
+
"@bobfrankston/mailx-types": "^0.1.21",
|
|
13
13
|
"@bobfrankston/mailx-settings": "^0.1.32",
|
|
14
14
|
"@bobfrankston/mailx-store": "^0.1.56",
|
|
15
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
15
|
+
"@bobfrankston/iflow-direct": "^0.1.56",
|
|
16
16
|
"@bobfrankston/tcp-transport": "^0.1.7",
|
|
17
17
|
"@bobfrankston/smtp-direct": "^0.1.9",
|
|
18
|
-
"@bobfrankston/mailx-sync": "^0.1.
|
|
18
|
+
"@bobfrankston/mailx-sync": "^0.1.25",
|
|
19
19
|
"@bobfrankston/oauthsupport": "^1.0.33"
|
|
20
20
|
},
|
|
21
21
|
"repository": {
|
|
@@ -37,13 +37,13 @@
|
|
|
37
37
|
},
|
|
38
38
|
".transformedSnapshot": {
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@bobfrankston/mailx-types": "^0.1.
|
|
40
|
+
"@bobfrankston/mailx-types": "^0.1.21",
|
|
41
41
|
"@bobfrankston/mailx-settings": "^0.1.32",
|
|
42
42
|
"@bobfrankston/mailx-store": "^0.1.56",
|
|
43
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
43
|
+
"@bobfrankston/iflow-direct": "^0.1.56",
|
|
44
44
|
"@bobfrankston/tcp-transport": "^0.1.7",
|
|
45
45
|
"@bobfrankston/smtp-direct": "^0.1.9",
|
|
46
|
-
"@bobfrankston/mailx-sync": "^0.1.
|
|
46
|
+
"@bobfrankston/mailx-sync": "^0.1.25",
|
|
47
47
|
"@bobfrankston/oauthsupport": "^1.0.33"
|
|
48
48
|
}
|
|
49
49
|
}
|