@bobfrankston/mailx-imap 0.1.110 → 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 +86 -4
- 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 || []),
|
|
@@ -2025,6 +2027,7 @@ export class ImapManager extends EventEmitter {
|
|
|
2025
2027
|
inReplyTo: msg.inReplyTo || "",
|
|
2026
2028
|
references: [],
|
|
2027
2029
|
date: toFiniteDateMs(msg.date),
|
|
2030
|
+
sentDate: msg.sentDate instanceof Date ? toFiniteDateMs(msg.sentDate) : undefined,
|
|
2028
2031
|
subject: msg.subject || "",
|
|
2029
2032
|
from: toEmailAddress(msg.from?.[0] || {}),
|
|
2030
2033
|
to: toEmailAddresses(msg.to || []),
|
|
@@ -2690,12 +2693,22 @@ export class ImapManager extends EventEmitter {
|
|
|
2690
2693
|
if (Number.isFinite(t))
|
|
2691
2694
|
dateMs = t;
|
|
2692
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
|
+
}
|
|
2693
2705
|
this.db.upsertMessage({
|
|
2694
2706
|
accountId, folderId, uid: msg.uid,
|
|
2695
2707
|
messageId: msg.messageId || "",
|
|
2696
2708
|
inReplyTo: msg.inReplyTo || "",
|
|
2697
2709
|
references: msg.references || [],
|
|
2698
2710
|
date: dateMs,
|
|
2711
|
+
sentDate: sentMs,
|
|
2699
2712
|
subject: msg.subject || "",
|
|
2700
2713
|
from: toEmailAddress(msg.from?.[0] || {}),
|
|
2701
2714
|
to: toEmailAddresses(msg.to || []),
|
|
@@ -5041,6 +5054,16 @@ export class ImapManager extends EventEmitter {
|
|
|
5041
5054
|
await withTimeout(client.appendMessage(outboxPath, raw, ["\\Seen"]), 60_000, client, `outbox APPEND ${file}`);
|
|
5042
5055
|
fs.renameSync(claimedPath, path.join(sentDir, file));
|
|
5043
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
|
+
});
|
|
5044
5067
|
}
|
|
5045
5068
|
catch (e) {
|
|
5046
5069
|
// APPEND failed (connection dropped mid-send, server
|
|
@@ -5127,8 +5150,17 @@ export class ImapManager extends EventEmitter {
|
|
|
5127
5150
|
throw new Error("No recipients");
|
|
5128
5151
|
// Dedup: skip if this Message-ID has already been sent. Prevents the
|
|
5129
5152
|
// outbox from re-sending the same file across crash/restart cycles.
|
|
5130
|
-
|
|
5131
|
-
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)) {
|
|
5132
5164
|
console.log(` [smtp] ${accountId}: SKIP ${messageId} — already in sent_log`);
|
|
5133
5165
|
return;
|
|
5134
5166
|
}
|
|
@@ -5163,7 +5195,25 @@ export class ImapManager extends EventEmitter {
|
|
|
5163
5195
|
* Each per-UID step is its own withConnection({slow}) call so the queue
|
|
5164
5196
|
* yields between messages: a click-to-view body in the middle of a
|
|
5165
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();
|
|
5166
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) {
|
|
5167
5217
|
const outboxFolder = this.findFolder(accountId, "outbox");
|
|
5168
5218
|
if (!outboxFolder)
|
|
5169
5219
|
return;
|
|
@@ -5553,6 +5603,15 @@ export class ImapManager extends EventEmitter {
|
|
|
5553
5603
|
this.sentSweepInterval = null;
|
|
5554
5604
|
}
|
|
5555
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();
|
|
5556
5615
|
async sweepSentOnce(accountId) {
|
|
5557
5616
|
const sent = this.findFolder(accountId, "sent");
|
|
5558
5617
|
if (!sent)
|
|
@@ -5578,7 +5637,11 @@ export class ImapManager extends EventEmitter {
|
|
|
5578
5637
|
// a finally, the opposite of "dedicated".
|
|
5579
5638
|
await this.withConnection(accountId, async (client) => {
|
|
5580
5639
|
for (const row of rows) {
|
|
5581
|
-
|
|
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();
|
|
5582
5645
|
if (!msgId)
|
|
5583
5646
|
continue;
|
|
5584
5647
|
let serverUids = [];
|
|
@@ -5590,13 +5653,32 @@ export class ImapManager extends EventEmitter {
|
|
|
5590
5653
|
continue;
|
|
5591
5654
|
}
|
|
5592
5655
|
if (serverUids.length === 0) {
|
|
5593
|
-
// 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).
|
|
5594
5659
|
if (!row.body_path)
|
|
5595
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 */ }
|
|
5596
5677
|
try {
|
|
5597
5678
|
const buf = await this.bodyStore.readByPath(row.body_path);
|
|
5598
5679
|
if (!buf)
|
|
5599
5680
|
continue;
|
|
5681
|
+
this.sentSweepAppended.add(msgId);
|
|
5600
5682
|
await client.appendMessage(sent.path, buf, ["\\Seen"]);
|
|
5601
5683
|
appended++;
|
|
5602
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
|
}
|