@bobfrankston/rmfmail 1.1.238 → 1.1.240

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.
@@ -986,20 +986,11 @@ export class ImapManager extends EventEmitter {
986
986
  // worth of latency instead of the full loop's worth.
987
987
  const BATCH_SIZE = 50;
988
988
  let stored = 0;
989
- let inTxn = false;
990
- const startTxn = () => { this.db.beginTransaction(); inTxn = true; };
991
- const commitTxn = () => { if (inTxn) {
992
- this.db.commitTransaction();
993
- inTxn = false;
994
- } };
995
- const rollbackTxn = () => { if (inTxn) {
996
- this.db.rollbackTransaction();
997
- inTxn = false;
998
- } };
999
- try {
1000
- startTxn();
1001
- let batchCount = 0;
1002
- for (const msg of msgs) {
989
+ for (let i = 0; i < msgs.length; i += BATCH_SIZE) {
990
+ const chunk = msgs.slice(i, i + BATCH_SIZE);
991
+ // Phase 1 async prep, NO transaction held.
992
+ const prepared = [];
993
+ for (const msg of chunk) {
1003
994
  // Debug: log subjects with non-ASCII to trace encoding issues
1004
995
  if (msg.subject && /[^\x00-\x7F]/.test(msg.subject)) {
1005
996
  const hex = Buffer.from(msg.subject, "utf-8").subarray(0, 40).toString("hex");
@@ -1046,43 +1037,45 @@ export class ImapManager extends EventEmitter {
1046
1037
  flags.push("\\Answered");
1047
1038
  if (msg.draft)
1048
1039
  flags.push("\\Draft");
1049
- const ftsRowId = this.db.upsertMessage({
1050
- accountId, folderId, uid: msg.uid,
1051
- messageId: msg.messageId || "",
1052
- inReplyTo: msg.inReplyTo || "",
1053
- references: [],
1054
- date: toFiniteDateMs(msg.date),
1055
- subject: msg.subject || "",
1056
- from: toEmailAddress(msg.from?.[0] || {}),
1057
- to: toEmailAddresses(msg.to || []),
1058
- cc: toEmailAddresses(msg.cc || []),
1059
- flags, size: msg.size || 0, hasAttachments, preview, bodyPath
1040
+ prepared.push({
1041
+ upsert: {
1042
+ accountId, folderId, uid: msg.uid,
1043
+ messageId: msg.messageId || "",
1044
+ inReplyTo: msg.inReplyTo || "",
1045
+ references: [],
1046
+ date: toFiniteDateMs(msg.date),
1047
+ subject: msg.subject || "",
1048
+ from: toEmailAddress(msg.from?.[0] || {}),
1049
+ to: toEmailAddresses(msg.to || []),
1050
+ cc: toEmailAddresses(msg.cc || []),
1051
+ flags, size: msg.size || 0, hasAttachments, preview, bodyPath,
1052
+ },
1053
+ ftsBody,
1060
1054
  });
1061
- // Index the full body for search. extractPreview already
1062
- // parsed `source`, so the body text is free here — and
1063
- // indexing it at sync time means a word buried in the body
1064
- // is searchable without the user first opening the message.
1065
- if (ftsRowId && ftsBody)
1066
- this.db.updateFtsBody(ftsRowId, ftsBody);
1067
- stored++;
1068
- batchCount++;
1069
- if (batchCount >= BATCH_SIZE) {
1070
- commitTxn();
1071
- // Hand the event loop to any waiting IPC (user clicks,
1072
- // outbox drains, alarm polls). setImmediate runs AFTER
1073
- // pending I/O callbacks, so a stack of getMessage IPCs
1074
- // that arrived during the previous chunk gets serviced
1075
- // before we start the next batch.
1076
- await new Promise(r => setImmediate(r));
1077
- batchCount = 0;
1078
- startTxn();
1079
- }
1080
1055
  }
1081
- commitTxn();
1082
- }
1083
- catch (e) {
1084
- rollbackTxn();
1085
- console.error(` storeMessages error: ${e.message}`);
1056
+ // Phase 2 — synchronous transaction, no awaits inside. runInTxn
1057
+ // owns its own BEGIN/COMMIT (or joins an already-open txn) and
1058
+ // ROLLBACKs on any throw, so a bad row can't leak an open txn.
1059
+ try {
1060
+ this.db.runInTxn(() => {
1061
+ for (const r of prepared) {
1062
+ const ftsRowId = this.db.upsertMessage(r.upsert);
1063
+ // Index the full body for search. extractPreview already
1064
+ // parsed `source`, so the body text is free here — and
1065
+ // indexing it at sync time means a word buried in the
1066
+ // body is searchable without first opening the message.
1067
+ if (ftsRowId && r.ftsBody)
1068
+ this.db.updateFtsBody(ftsRowId, r.ftsBody);
1069
+ }
1070
+ });
1071
+ stored += prepared.length;
1072
+ }
1073
+ catch (e) {
1074
+ console.error(` storeMessages error: ${e.message}`);
1075
+ }
1076
+ // Hand the event loop to any waiting IPC (user clicks, outbox
1077
+ // drains, alarm polls) between batches.
1078
+ await new Promise(r => setImmediate(r));
1086
1079
  }
1087
1080
  return stored;
1088
1081
  }
@@ -1561,45 +1554,43 @@ export class ImapManager extends EventEmitter {
1561
1554
  const batchSize = 50;
1562
1555
  for (let batchStart = 0; batchStart < messages.length; batchStart += batchSize) {
1563
1556
  const batchEnd = Math.min(batchStart + batchSize, messages.length);
1564
- this.db.beginTransaction();
1565
- try {
1566
- for (let i = batchStart; i < batchEnd; i++) {
1567
- const msg = messages[i];
1568
- // CRITICAL: was `if (msg.uid <= highestUid) { update flags; continue }`.
1569
- // Same bug as the streamy storeMessages path on line ~861:
1570
- // it dropped every gap-fill message because gap-fills
1571
- // recover UIDs in the already-scanned range (all <=
1572
- // highestUid by definition). Trust upsertMessage's
1573
- // UNIQUE constraint to dedupe — if mailx truly has the
1574
- // row, the upsert becomes an UPDATE that refreshes
1575
- // flags too, all in one path.
1576
- // Tombstone-skip: in-flight local delete/move waiting
1577
- // for server confirmation. Cleared on action success or
1578
- // permanent failure (see clearTombstoneForUid).
1579
- if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
1580
- console.log(` [tombstone] ${accountId}: skipping ${msg.messageId} in syncFolder (locally deleted)`);
1581
- continue;
1582
- }
1583
- // Store body
1584
- const source = msg.source || "";
1585
- let bodyPath = "";
1586
- if (source) {
1587
- bodyPath = await this.bodyStore.putMessage(accountId, folderId, msg.uid, Buffer.from(source, "utf-8"));
1588
- }
1589
- // Parse for preview and attachment info
1590
- const parsed = await extractPreview(source);
1591
- // Build flags array
1592
- const flags = [];
1593
- if (msg.seen)
1594
- flags.push("\\Seen");
1595
- if (msg.flagged)
1596
- flags.push("\\Flagged");
1597
- if (msg.answered)
1598
- flags.push("\\Answered");
1599
- if (msg.draft)
1600
- flags.push("\\Draft");
1601
- // Store metadata
1602
- const ftsRowId = this.db.upsertMessage({
1557
+ // Phase 1 — async prep (write .eml, parse preview), NO transaction
1558
+ // held. The previous version kept a raw BEGIN open across
1559
+ // `await putMessage` / `await extractPreview`, so a concurrent
1560
+ // store on the same connection could hit BEGIN mid-await →
1561
+ // "cannot start a transaction within a transaction", wedging the
1562
+ // sync. Same fix + cause as storeMessages above (Bob 2026-06-11).
1563
+ //
1564
+ // NOTE: the old `if (msg.uid <= highestUid) continue` skip is
1565
+ // intentionally gone gap-fills recover UIDs in the scanned range,
1566
+ // and upsertMessage's UNIQUE(account,folder,uid) constraint dedupes
1567
+ // (truly-present rows become a cheap UPDATE).
1568
+ const prepared = [];
1569
+ for (let i = batchStart; i < batchEnd; i++) {
1570
+ const msg = messages[i];
1571
+ // Tombstone-skip: in-flight local delete/move awaiting server
1572
+ // confirmation (cleared on success or permanent failure).
1573
+ if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
1574
+ console.log(` [tombstone] ${accountId}: skipping ${msg.messageId} in syncFolder (locally deleted)`);
1575
+ continue;
1576
+ }
1577
+ const source = msg.source || "";
1578
+ let bodyPath = "";
1579
+ if (source) {
1580
+ bodyPath = await this.bodyStore.putMessage(accountId, folderId, msg.uid, Buffer.from(source, "utf-8"));
1581
+ }
1582
+ const parsed = await extractPreview(source);
1583
+ const flags = [];
1584
+ if (msg.seen)
1585
+ flags.push("\\Seen");
1586
+ if (msg.flagged)
1587
+ flags.push("\\Flagged");
1588
+ if (msg.answered)
1589
+ flags.push("\\Answered");
1590
+ if (msg.draft)
1591
+ flags.push("\\Draft");
1592
+ prepared.push({
1593
+ upsert: {
1603
1594
  accountId,
1604
1595
  folderId,
1605
1596
  uid: msg.uid,
@@ -1615,20 +1606,27 @@ export class ImapManager extends EventEmitter {
1615
1606
  size: msg.size || 0,
1616
1607
  hasAttachments: parsed.hasAttachments,
1617
1608
  preview: parsed.preview,
1618
- bodyPath
1619
- });
1620
- // Full body into the FTS index — the parse above already
1621
- // produced the text, so search covers body content even
1622
- // for messages the user hasn't opened.
1623
- if (ftsRowId && parsed.bodyText)
1624
- this.db.updateFtsBody(ftsRowId, parsed.bodyText);
1625
- newCount++;
1626
- }
1627
- this.db.commitTransaction();
1609
+ bodyPath,
1610
+ },
1611
+ ftsBody: parsed.bodyText || "",
1612
+ });
1613
+ }
1614
+ // Phase 2 — synchronous transaction, no awaits inside. runInTxn
1615
+ // owns BEGIN/COMMIT (or joins an open txn) and ROLLBACKs on throw.
1616
+ try {
1617
+ this.db.runInTxn(() => {
1618
+ for (const r of prepared) {
1619
+ const ftsRowId = this.db.upsertMessage(r.upsert);
1620
+ // Full body into FTS — parse above already produced the
1621
+ // text, so search covers unopened messages too.
1622
+ if (ftsRowId && r.ftsBody)
1623
+ this.db.updateFtsBody(ftsRowId, r.ftsBody);
1624
+ }
1625
+ });
1626
+ newCount += prepared.length;
1628
1627
  }
1629
1628
  catch (e) {
1630
1629
  console.error(` transaction error: ${e.message}`);
1631
- this.db.rollbackTransaction();
1632
1630
  throw e;
1633
1631
  }
1634
1632
  // Emit progress and notify client after each batch