@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.
Files changed (3) hide show
  1. package/index.d.ts +17 -0
  2. package/index.js +102 -5
  3. 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.log(` [sync] Updated flags UID ${action.uid} (+[${desired.join(",")}] -[${toRemove.join(",")}])`);
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
- const messageId = messageIdMatch ? messageIdMatch[1] : "";
5116
- if (messageId && this.db.hasSentMessage(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
- const msgId = row.message_id;
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.109",
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.20",
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.55",
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.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.20",
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.55",
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.25",
47
47
  "@bobfrankston/oauthsupport": "^1.0.33"
48
48
  }
49
49
  }