@bobfrankston/rmfmail 1.1.239 → 1.1.241

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.
@@ -1017,14 +1017,24 @@ export class ImapManager extends EventEmitter {
1017
1017
  // worth of latency instead of the full loop's worth.
1018
1018
  const BATCH_SIZE = 50;
1019
1019
  let stored = 0;
1020
- let inTxn = false;
1021
- const startTxn = () => { this.db.beginTransaction(); inTxn = true; };
1022
- const commitTxn = () => { if (inTxn) { this.db.commitTransaction(); inTxn = false; } };
1023
- const rollbackTxn = () => { if (inTxn) { this.db.rollbackTransaction(); inTxn = false; } };
1024
- try {
1025
- startTxn();
1026
- let batchCount = 0;
1027
- for (const msg of msgs) {
1020
+ // Two-phase batching. The async I/O (write .eml to disk, parse the body
1021
+ // for preview/FTS) runs OUTSIDE any transaction; only the synchronous
1022
+ // upserts run inside one. The previous version held a raw BEGIN open
1023
+ // across `await putMessage` / `await extractPreview`, so a concurrent
1024
+ // storeMessages or the contacts walker could hit BEGIN on the same
1025
+ // connection mid-await → "cannot start a transaction within a
1026
+ // transaction". That threw out the whole batch: messages stored with
1027
+ // empty From/Subject AND the local high-water never advanced, so the
1028
+ // same UIDs re-fetched forever (Bob 2026-06-11: npm notice with blank
1029
+ // From/Subject, its UID re-fetched 51×, sync wedged). Keeping every
1030
+ // `await` out of the transaction makes the collision structurally
1031
+ // impossible, and runInTxn() is nesting-safe besides.
1032
+ type PreparedRow = { upsert: Parameters<MailxDB["upsertMessage"]>[0]; ftsBody: string };
1033
+ for (let i = 0; i < msgs.length; i += BATCH_SIZE) {
1034
+ const chunk = msgs.slice(i, i + BATCH_SIZE);
1035
+ // Phase 1 — async prep, NO transaction held.
1036
+ const prepared: PreparedRow[] = [];
1037
+ for (const msg of chunk) {
1028
1038
  // Debug: log subjects with non-ASCII to trace encoding issues
1029
1039
  if (msg.subject && /[^\x00-\x7F]/.test(msg.subject)) {
1030
1040
  const hex = Buffer.from(msg.subject, "utf-8").subarray(0, 40).toString("hex");
@@ -1070,41 +1080,43 @@ export class ImapManager extends EventEmitter {
1070
1080
  if (msg.flagged) flags.push("\\Flagged");
1071
1081
  if (msg.answered) flags.push("\\Answered");
1072
1082
  if (msg.draft) flags.push("\\Draft");
1073
- const ftsRowId = this.db.upsertMessage({
1074
- accountId, folderId, uid: msg.uid,
1075
- messageId: msg.messageId || "",
1076
- inReplyTo: (msg as any).inReplyTo || "",
1077
- references: [],
1078
- date: toFiniteDateMs(msg.date),
1079
- subject: msg.subject || "",
1080
- from: toEmailAddress(msg.from?.[0] || {}),
1081
- to: toEmailAddresses(msg.to || []),
1082
- cc: toEmailAddresses(msg.cc || []),
1083
- flags, size: msg.size || 0, hasAttachments, preview, bodyPath
1083
+ prepared.push({
1084
+ upsert: {
1085
+ accountId, folderId, uid: msg.uid,
1086
+ messageId: msg.messageId || "",
1087
+ inReplyTo: (msg as any).inReplyTo || "",
1088
+ references: [],
1089
+ date: toFiniteDateMs(msg.date),
1090
+ subject: msg.subject || "",
1091
+ from: toEmailAddress(msg.from?.[0] || {}),
1092
+ to: toEmailAddresses(msg.to || []),
1093
+ cc: toEmailAddresses(msg.cc || []),
1094
+ flags, size: msg.size || 0, hasAttachments, preview, bodyPath,
1095
+ },
1096
+ ftsBody,
1084
1097
  });
1085
- // Index the full body for search. extractPreview already
1086
- // parsed `source`, so the body text is free here — and
1087
- // indexing it at sync time means a word buried in the body
1088
- // is searchable without the user first opening the message.
1089
- if (ftsRowId && ftsBody) this.db.updateFtsBody(ftsRowId, ftsBody);
1090
- stored++;
1091
- batchCount++;
1092
- if (batchCount >= BATCH_SIZE) {
1093
- commitTxn();
1094
- // Hand the event loop to any waiting IPC (user clicks,
1095
- // outbox drains, alarm polls). setImmediate runs AFTER
1096
- // pending I/O callbacks, so a stack of getMessage IPCs
1097
- // that arrived during the previous chunk gets serviced
1098
- // before we start the next batch.
1099
- await new Promise<void>(r => setImmediate(r));
1100
- batchCount = 0;
1101
- startTxn();
1102
- }
1103
1098
  }
1104
- commitTxn();
1105
- } catch (e: any) {
1106
- rollbackTxn();
1107
- console.error(` storeMessages error: ${e.message}`);
1099
+ // Phase 2 — synchronous transaction, no awaits inside. runInTxn
1100
+ // owns its own BEGIN/COMMIT (or joins an already-open txn) and
1101
+ // ROLLBACKs on any throw, so a bad row can't leak an open txn.
1102
+ try {
1103
+ this.db.runInTxn(() => {
1104
+ for (const r of prepared) {
1105
+ const ftsRowId = this.db.upsertMessage(r.upsert);
1106
+ // Index the full body for search. extractPreview already
1107
+ // parsed `source`, so the body text is free here — and
1108
+ // indexing it at sync time means a word buried in the
1109
+ // body is searchable without first opening the message.
1110
+ if (ftsRowId && r.ftsBody) this.db.updateFtsBody(ftsRowId, r.ftsBody);
1111
+ }
1112
+ });
1113
+ stored += prepared.length;
1114
+ } catch (e: any) {
1115
+ console.error(` storeMessages error: ${e.message}`);
1116
+ }
1117
+ // Hand the event loop to any waiting IPC (user clicks, outbox
1118
+ // drains, alarm polls) between batches.
1119
+ await new Promise<void>(r => setImmediate(r));
1108
1120
  }
1109
1121
  return stored;
1110
1122
  }
@@ -1594,50 +1606,42 @@ export class ImapManager extends EventEmitter {
1594
1606
 
1595
1607
  for (let batchStart = 0; batchStart < messages.length; batchStart += batchSize) {
1596
1608
  const batchEnd = Math.min(batchStart + batchSize, messages.length);
1597
- this.db.beginTransaction();
1598
- try {
1599
- for (let i = batchStart; i < batchEnd; i++) {
1600
- const msg = messages[i];
1601
-
1602
- // CRITICAL: was `if (msg.uid <= highestUid) { update flags; continue }`.
1603
- // Same bug as the streamy storeMessages path on line ~861:
1604
- // it dropped every gap-fill message because gap-fills
1605
- // recover UIDs in the already-scanned range (all <=
1606
- // highestUid by definition). Trust upsertMessage's
1607
- // UNIQUE constraint to dedupe if mailx truly has the
1608
- // row, the upsert becomes an UPDATE that refreshes
1609
- // flags too, all in one path.
1610
-
1611
- // Tombstone-skip: in-flight local delete/move waiting
1612
- // for server confirmation. Cleared on action success or
1613
- // permanent failure (see clearTombstoneForUid).
1614
- if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
1615
- console.log(` [tombstone] ${accountId}: skipping ${msg.messageId} in syncFolder (locally deleted)`);
1616
- continue;
1617
- }
1618
-
1619
- // Store body
1620
- const source = msg.source || "";
1621
- let bodyPath = "";
1622
- if (source) {
1623
- bodyPath = await this.bodyStore.putMessage(
1624
- accountId, folderId, msg.uid,
1625
- Buffer.from(source, "utf-8")
1626
- );
1627
- }
1628
-
1629
- // Parse for preview and attachment info
1630
- const parsed = await extractPreview(source);
1631
-
1632
- // Build flags array
1633
- const flags: string[] = [];
1634
- if (msg.seen) flags.push("\\Seen");
1635
- if (msg.flagged) flags.push("\\Flagged");
1636
- if (msg.answered) flags.push("\\Answered");
1637
- if (msg.draft) flags.push("\\Draft");
1638
-
1639
- // Store metadata
1640
- const ftsRowId = this.db.upsertMessage({
1609
+ // Phase 1 — async prep (write .eml, parse preview), NO transaction
1610
+ // held. The previous version kept a raw BEGIN open across
1611
+ // `await putMessage` / `await extractPreview`, so a concurrent
1612
+ // store on the same connection could hit BEGIN mid-await →
1613
+ // "cannot start a transaction within a transaction", wedging the
1614
+ // sync. Same fix + cause as storeMessages above (Bob 2026-06-11).
1615
+ //
1616
+ // NOTE: the old `if (msg.uid <= highestUid) continue` skip is
1617
+ // intentionally gone — gap-fills recover UIDs in the scanned range,
1618
+ // and upsertMessage's UNIQUE(account,folder,uid) constraint dedupes
1619
+ // (truly-present rows become a cheap UPDATE).
1620
+ const prepared: Array<{ upsert: Parameters<MailxDB["upsertMessage"]>[0]; ftsBody: string }> = [];
1621
+ for (let i = batchStart; i < batchEnd; i++) {
1622
+ const msg = messages[i];
1623
+ // Tombstone-skip: in-flight local delete/move awaiting server
1624
+ // confirmation (cleared on success or permanent failure).
1625
+ if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
1626
+ console.log(` [tombstone] ${accountId}: skipping ${msg.messageId} in syncFolder (locally deleted)`);
1627
+ continue;
1628
+ }
1629
+ const source = msg.source || "";
1630
+ let bodyPath = "";
1631
+ if (source) {
1632
+ bodyPath = await this.bodyStore.putMessage(
1633
+ accountId, folderId, msg.uid,
1634
+ Buffer.from(source, "utf-8")
1635
+ );
1636
+ }
1637
+ const parsed = await extractPreview(source);
1638
+ const flags: string[] = [];
1639
+ if (msg.seen) flags.push("\\Seen");
1640
+ if (msg.flagged) flags.push("\\Flagged");
1641
+ if (msg.answered) flags.push("\\Answered");
1642
+ if (msg.draft) flags.push("\\Draft");
1643
+ prepared.push({
1644
+ upsert: {
1641
1645
  accountId,
1642
1646
  folderId,
1643
1647
  uid: msg.uid,
@@ -1653,19 +1657,25 @@ export class ImapManager extends EventEmitter {
1653
1657
  size: msg.size || 0,
1654
1658
  hasAttachments: parsed.hasAttachments,
1655
1659
  preview: parsed.preview,
1656
- bodyPath
1657
- });
1658
- // Full body into the FTS index — the parse above already
1659
- // produced the text, so search covers body content even
1660
- // for messages the user hasn't opened.
1661
- if (ftsRowId && parsed.bodyText) this.db.updateFtsBody(ftsRowId, parsed.bodyText);
1662
-
1663
- newCount++;
1664
- }
1665
- this.db.commitTransaction();
1660
+ bodyPath,
1661
+ },
1662
+ ftsBody: parsed.bodyText || "",
1663
+ });
1664
+ }
1665
+ // Phase 2 synchronous transaction, no awaits inside. runInTxn
1666
+ // owns BEGIN/COMMIT (or joins an open txn) and ROLLBACKs on throw.
1667
+ try {
1668
+ this.db.runInTxn(() => {
1669
+ for (const r of prepared) {
1670
+ const ftsRowId = this.db.upsertMessage(r.upsert);
1671
+ // Full body into FTS — parse above already produced the
1672
+ // text, so search covers unopened messages too.
1673
+ if (ftsRowId && r.ftsBody) this.db.updateFtsBody(ftsRowId, r.ftsBody);
1674
+ }
1675
+ });
1676
+ newCount += prepared.length;
1666
1677
  } catch (e: any) {
1667
1678
  console.error(` transaction error: ${e.message}`);
1668
- this.db.rollbackTransaction();
1669
1679
  throw e;
1670
1680
  }
1671
1681
 
@@ -2700,6 +2710,22 @@ export class ImapManager extends EventEmitter {
2700
2710
  if (expungeTimer) clearTimeout(expungeTimer);
2701
2711
  expungeTimer = setTimeout(() => {
2702
2712
  expungeTimer = null;
2713
+ // Skip while WE have sync actions draining. Every
2714
+ // delete/move we push EXPUNGEs on the server and bounces
2715
+ // straight back here as an onExpunge — and a reconcile
2716
+ // of a large INBOX is a full server-vs-local set-diff.
2717
+ // During a bulk delete that meant one ~135k-row reconcile
2718
+ // per message (the 2s debounce can't coalesce them — our
2719
+ // moves arrive ~2.5s apart), hammering the DB so the list
2720
+ // sat on "Loading…" with a stale preview (Bob 2026-06-12).
2721
+ // The local store already reflects our own moves, so these
2722
+ // are redundant. The last move empties the queue, so its
2723
+ // trailing expunge runs exactly ONE reconcile to catch any
2724
+ // genuinely-external change.
2725
+ if (this.db.getPendingSyncActions(accountId).length > 0) {
2726
+ console.log(` [idle] ${accountId}: INBOX expunge during own drain — deferring reconcile`);
2727
+ return;
2728
+ }
2703
2729
  const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
2704
2730
  if (!inbox) return;
2705
2731
  console.log(` [idle] ${accountId}: INBOX expunge pushed → reconcile`);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.86",
3
+ "version": "0.1.88",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bobfrankston/mailx-imap",
9
- "version": "0.1.86",
9
+ "version": "0.1.88",
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
12
  "@bobfrankston/iflow-direct": "^0.1.27",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.86",
3
+ "version": "0.1.88",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",