@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.
Files changed (3) hide show
  1. package/index.d.ts +17 -0
  2. package/index.js +92 -6
  3. 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
- recoveredCount += await this.storeMessages(accountId, folderId, folder, got, 0);
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
- const messageId = messageIdMatch ? messageIdMatch[1] : "";
5131
- if (messageId && this.db.hasSentMessage(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
- const msgId = row.message_id;
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.110",
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.20",
13
- "@bobfrankston/mailx-settings": "^0.1.32",
14
- "@bobfrankston/mailx-store": "^0.1.56",
15
- "@bobfrankston/iflow-direct": "^0.1.55",
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.23",
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.20",
41
- "@bobfrankston/mailx-settings": "^0.1.32",
42
- "@bobfrankston/mailx-store": "^0.1.56",
43
- "@bobfrankston/iflow-direct": "^0.1.55",
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.23",
46
+ "@bobfrankston/mailx-sync": "^0.1.27",
47
47
  "@bobfrankston/oauthsupport": "^1.0.33"
48
48
  }
49
49
  }