@bobfrankston/mailx-imap 0.1.110 → 0.1.112
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 +92 -6
- package/package.json +11 -11
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,
|
|
@@ -1306,7 +1307,7 @@ export class ImapManager extends EventEmitter {
|
|
|
1306
1307
|
return dbFolders;
|
|
1307
1308
|
}
|
|
1308
1309
|
/** Store a batch of messages to DB immediately — used by onChunk for incremental sync */
|
|
1309
|
-
async storeMessages(accountId, folderId, folder, msgs, highestUid) {
|
|
1310
|
+
async storeMessages(accountId, folderId, folder, msgs, highestUid, liveServerUids) {
|
|
1310
1311
|
// Chunked transactions: better-sqlite3 is synchronous and an open
|
|
1311
1312
|
// transaction blocks every other query on the same DB. A 1881-row
|
|
1312
1313
|
// INBOX sync inside ONE transaction locked the connection for ~1.5 s
|
|
@@ -1376,12 +1377,14 @@ 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 || []),
|
|
1382
1384
|
cc: toEmailAddresses(msg.cc || []),
|
|
1383
1385
|
flags, size: msg.size || 0, hasAttachments, preview, bodyPath,
|
|
1384
1386
|
exclusive: true, // IMAP path → one folder per message
|
|
1387
|
+
liveServerUids,
|
|
1385
1388
|
},
|
|
1386
1389
|
ftsBody,
|
|
1387
1390
|
});
|
|
@@ -1579,7 +1582,10 @@ export class ImapManager extends EventEmitter {
|
|
|
1579
1582
|
const chunk = toFetch.slice(i, i + BACKFILL_CHUNK_SIZE);
|
|
1580
1583
|
try {
|
|
1581
1584
|
const got = await client.fetchMessages(folderPath, chunk.join(","), { source: false });
|
|
1582
|
-
|
|
1585
|
+
// Pass the server's live UID list so move-detect can tell a
|
|
1586
|
+
// duplicate delivery (both uids live in this folder) from a
|
|
1587
|
+
// real move — see upsertMessage.liveServerUids.
|
|
1588
|
+
recoveredCount += await this.storeMessages(accountId, folderId, folder, got, 0, new Set(serverUids));
|
|
1583
1589
|
}
|
|
1584
1590
|
catch (e) {
|
|
1585
1591
|
console.error(` ${folderPath}: backfill chunk ${i}-${i + chunk.length} failed (${e?.message || e}) — keeping ${recoveredCount} so far, resuming next cycle`);
|
|
@@ -2025,6 +2031,7 @@ export class ImapManager extends EventEmitter {
|
|
|
2025
2031
|
inReplyTo: msg.inReplyTo || "",
|
|
2026
2032
|
references: [],
|
|
2027
2033
|
date: toFiniteDateMs(msg.date),
|
|
2034
|
+
sentDate: msg.sentDate instanceof Date ? toFiniteDateMs(msg.sentDate) : undefined,
|
|
2028
2035
|
subject: msg.subject || "",
|
|
2029
2036
|
from: toEmailAddress(msg.from?.[0] || {}),
|
|
2030
2037
|
to: toEmailAddresses(msg.to || []),
|
|
@@ -2690,12 +2697,22 @@ export class ImapManager extends EventEmitter {
|
|
|
2690
2697
|
if (Number.isFinite(t))
|
|
2691
2698
|
dateMs = t;
|
|
2692
2699
|
}
|
|
2700
|
+
// Sent time from the Date: header (iflow exposes it as sentDate,
|
|
2701
|
+
// separate from msg.date = INTERNALDATE). Undefined → store
|
|
2702
|
+
// defaults sent_date to dateMs.
|
|
2703
|
+
let sentMs;
|
|
2704
|
+
if (msg.sentDate instanceof Date) {
|
|
2705
|
+
const st = msg.sentDate.getTime();
|
|
2706
|
+
if (Number.isFinite(st))
|
|
2707
|
+
sentMs = st;
|
|
2708
|
+
}
|
|
2693
2709
|
this.db.upsertMessage({
|
|
2694
2710
|
accountId, folderId, uid: msg.uid,
|
|
2695
2711
|
messageId: msg.messageId || "",
|
|
2696
2712
|
inReplyTo: msg.inReplyTo || "",
|
|
2697
2713
|
references: msg.references || [],
|
|
2698
2714
|
date: dateMs,
|
|
2715
|
+
sentDate: sentMs,
|
|
2699
2716
|
subject: msg.subject || "",
|
|
2700
2717
|
from: toEmailAddress(msg.from?.[0] || {}),
|
|
2701
2718
|
to: toEmailAddresses(msg.to || []),
|
|
@@ -5041,6 +5058,16 @@ export class ImapManager extends EventEmitter {
|
|
|
5041
5058
|
await withTimeout(client.appendMessage(outboxPath, raw, ["\\Seen"]), 60_000, client, `outbox APPEND ${file}`);
|
|
5042
5059
|
fs.renameSync(claimedPath, path.join(sentDir, file));
|
|
5043
5060
|
console.log(` [outbox] Moved ${file} to IMAP Outbox → sent/`);
|
|
5061
|
+
// Chain the SMTP delivery immediately instead of waiting
|
|
5062
|
+
// for the next worker tick. The APPEND→tick→SMTP→Sent
|
|
5063
|
+
// chain added up to ~30s before the message appeared in
|
|
5064
|
+
// Sent, which read as "my message is gone" (Bob
|
|
5065
|
+
// 2026-07-01, repeatedly). Fire-and-forget; the tick
|
|
5066
|
+
// remains the retry backstop.
|
|
5067
|
+
setImmediate(() => {
|
|
5068
|
+
this.processOutbox(accountId)
|
|
5069
|
+
.catch((e) => console.error(` [outbox] chained processOutbox: ${e?.message || e}`));
|
|
5070
|
+
});
|
|
5044
5071
|
}
|
|
5045
5072
|
catch (e) {
|
|
5046
5073
|
// APPEND failed (connection dropped mid-send, server
|
|
@@ -5127,8 +5154,17 @@ export class ImapManager extends EventEmitter {
|
|
|
5127
5154
|
throw new Error("No recipients");
|
|
5128
5155
|
// Dedup: skip if this Message-ID has already been sent. Prevents the
|
|
5129
5156
|
// outbox from re-sending the same file across crash/restart cycles.
|
|
5130
|
-
|
|
5131
|
-
if (messageId &&
|
|
5157
|
+
// A message WITHOUT a Message-ID gets one synthesized here — the old
|
|
5158
|
+
// `if (messageId && …)` guard silently skipped BOTH the dedup check
|
|
5159
|
+
// AND recordSent for ID-less mail, so such a message could re-send
|
|
5160
|
+
// forever with no brake (2026-07-02 resend-loop audit).
|
|
5161
|
+
let messageId = messageIdMatch ? messageIdMatch[1] : "";
|
|
5162
|
+
if (!messageId) {
|
|
5163
|
+
messageId = `<mailx-${Date.now()}-${Math.random().toString(36).slice(2, 10)}@${account.email.split("@")[1] || "mailx.local"}>`;
|
|
5164
|
+
raw = raw.replace(/\r?\n\r?\n/, `\r\nMessage-ID: ${messageId}\r\n\r\n`);
|
|
5165
|
+
console.log(` [smtp] ${accountId}: synthesized Message-ID ${messageId} for ID-less outgoing message`);
|
|
5166
|
+
}
|
|
5167
|
+
if (this.db.hasSentMessage(messageId)) {
|
|
5132
5168
|
console.log(` [smtp] ${accountId}: SKIP ${messageId} — already in sent_log`);
|
|
5133
5169
|
return;
|
|
5134
5170
|
}
|
|
@@ -5163,7 +5199,25 @@ export class ImapManager extends EventEmitter {
|
|
|
5163
5199
|
* Each per-UID step is its own withConnection({slow}) call so the queue
|
|
5164
5200
|
* yields between messages: a click-to-view body in the middle of a
|
|
5165
5201
|
* 10-message outbox drain doesn't wait for all 10 to finish. */
|
|
5202
|
+
/** Per-account re-entrancy guard for processOutbox. The worker tick and
|
|
5203
|
+
* the chained post-APPEND call (processLocalQueue) can overlap; on
|
|
5204
|
+
* servers without custom-keyword support ($Sending interlock) two
|
|
5205
|
+
* concurrent drains could both fetch and SMTP the same Outbox UID —
|
|
5206
|
+
* a double-send. One drain per account at a time; latecomers no-op
|
|
5207
|
+
* (the 10s tick retries anything they would have handled). */
|
|
5208
|
+
outboxDrainInFlight = new Set();
|
|
5166
5209
|
async processOutbox(accountId) {
|
|
5210
|
+
if (this.outboxDrainInFlight.has(accountId))
|
|
5211
|
+
return;
|
|
5212
|
+
this.outboxDrainInFlight.add(accountId);
|
|
5213
|
+
try {
|
|
5214
|
+
await this._processOutbox(accountId);
|
|
5215
|
+
}
|
|
5216
|
+
finally {
|
|
5217
|
+
this.outboxDrainInFlight.delete(accountId);
|
|
5218
|
+
}
|
|
5219
|
+
}
|
|
5220
|
+
async _processOutbox(accountId) {
|
|
5167
5221
|
const outboxFolder = this.findFolder(accountId, "outbox");
|
|
5168
5222
|
if (!outboxFolder)
|
|
5169
5223
|
return;
|
|
@@ -5553,6 +5607,15 @@ export class ImapManager extends EventEmitter {
|
|
|
5553
5607
|
this.sentSweepInterval = null;
|
|
5554
5608
|
}
|
|
5555
5609
|
}
|
|
5610
|
+
/** Message-IDs sweepSentOnce has already re-APPENDed this daemon run.
|
|
5611
|
+
* HARD GUARD against perpetual motion: a re-appended copy syncs back in,
|
|
5612
|
+
* refreshes the row's cached_at, and keeps it inside the sweep window —
|
|
5613
|
+
* so if the header search ever reports "missing" again (it failed
|
|
5614
|
+
* silently for ~13 days on a flaky lane connection, 2026-07-02, 3,704
|
|
5615
|
+
* copies of one message), the sweep would append forever. One re-append
|
|
5616
|
+
* per Message-ID per daemon lifetime; a genuinely-lost message still
|
|
5617
|
+
* gets its one repair, and a false "missing" can no longer loop. */
|
|
5618
|
+
sentSweepAppended = new Set();
|
|
5556
5619
|
async sweepSentOnce(accountId) {
|
|
5557
5620
|
const sent = this.findFolder(accountId, "sent");
|
|
5558
5621
|
if (!sent)
|
|
@@ -5578,7 +5641,11 @@ export class ImapManager extends EventEmitter {
|
|
|
5578
5641
|
// a finally, the opposite of "dedicated".
|
|
5579
5642
|
await this.withConnection(accountId, async (client) => {
|
|
5580
5643
|
for (const row of rows) {
|
|
5581
|
-
|
|
5644
|
+
// Trim defensively: rows written before the iflow-direct
|
|
5645
|
+
// envelope-parser trim carry the header's fold whitespace
|
|
5646
|
+
// ("\t<...>"), which made HEADER searches return 0 and drove
|
|
5647
|
+
// the re-APPEND loop (2026-07-02, 3,707 duplicates).
|
|
5648
|
+
const msgId = (row.message_id || "").trim();
|
|
5582
5649
|
if (!msgId)
|
|
5583
5650
|
continue;
|
|
5584
5651
|
let serverUids = [];
|
|
@@ -5590,13 +5657,32 @@ export class ImapManager extends EventEmitter {
|
|
|
5590
5657
|
continue;
|
|
5591
5658
|
}
|
|
5592
5659
|
if (serverUids.length === 0) {
|
|
5593
|
-
// Not on server. Re-APPEND from .eml if we have one
|
|
5660
|
+
// Not on server. Re-APPEND from .eml if we have one —
|
|
5661
|
+
// but at most ONCE per Message-ID per daemon run (see
|
|
5662
|
+
// sentSweepAppended above; the 3,704-duplicate loop).
|
|
5594
5663
|
if (!row.body_path)
|
|
5595
5664
|
continue;
|
|
5665
|
+
if (this.sentSweepAppended.has(msgId)) {
|
|
5666
|
+
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`);
|
|
5667
|
+
continue;
|
|
5668
|
+
}
|
|
5669
|
+
// Cross-check the local DB before trusting the server
|
|
5670
|
+
// search: multiple local Sent rows with this Message-ID
|
|
5671
|
+
// mean prior copies synced IN from the server — it exists
|
|
5672
|
+
// there regardless of what the search just said.
|
|
5673
|
+
try {
|
|
5674
|
+
const localCopies = this.db.countMessagesByMessageId?.(accountId, sent.id, msgId) ?? 0;
|
|
5675
|
+
if (localCopies > 1) {
|
|
5676
|
+
console.error(` [sent-sweep] ${accountId}: SKIP re-append of ${msgId} — ${localCopies} local Sent rows already exist (server search returned 0 falsely)`);
|
|
5677
|
+
continue;
|
|
5678
|
+
}
|
|
5679
|
+
}
|
|
5680
|
+
catch { /* count helper missing — proceed to the once-guard */ }
|
|
5596
5681
|
try {
|
|
5597
5682
|
const buf = await this.bodyStore.readByPath(row.body_path);
|
|
5598
5683
|
if (!buf)
|
|
5599
5684
|
continue;
|
|
5685
|
+
this.sentSweepAppended.add(msgId);
|
|
5600
5686
|
await client.appendMessage(sent.path, buf, ["\\Seen"]);
|
|
5601
5687
|
appended++;
|
|
5602
5688
|
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.112",
|
|
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.
|
|
13
|
-
"@bobfrankston/mailx-settings": "^0.1.
|
|
14
|
-
"@bobfrankston/mailx-store": "^0.1.
|
|
15
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
12
|
+
"@bobfrankston/mailx-types": "^0.1.22",
|
|
13
|
+
"@bobfrankston/mailx-settings": "^0.1.33",
|
|
14
|
+
"@bobfrankston/mailx-store": "^0.1.57",
|
|
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.27",
|
|
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.
|
|
41
|
-
"@bobfrankston/mailx-settings": "^0.1.
|
|
42
|
-
"@bobfrankston/mailx-store": "^0.1.
|
|
43
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
40
|
+
"@bobfrankston/mailx-types": "^0.1.22",
|
|
41
|
+
"@bobfrankston/mailx-settings": "^0.1.33",
|
|
42
|
+
"@bobfrankston/mailx-store": "^0.1.57",
|
|
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.27",
|
|
47
47
|
"@bobfrankston/oauthsupport": "^1.0.33"
|
|
48
48
|
}
|
|
49
49
|
}
|