@bobfrankston/mailx-imap 0.1.85 → 0.1.87

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 (2) hide show
  1. package/index.js +131 -107
  2. package/package.json +15 -15
package/index.js CHANGED
@@ -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
@@ -4193,19 +4191,37 @@ export class ImapManager extends EventEmitter {
4193
4191
  if (host !== this.hostname)
4194
4192
  continue;
4195
4193
  const pid = parseInt(pidStr);
4196
- if (pid === myPid)
4197
- continue; // it's us
4194
+ let ageMs = Infinity;
4195
+ try {
4196
+ ageMs = Date.now() - fs.statSync(path.join(dir, f)).mtimeMs;
4197
+ }
4198
+ catch { /* */ }
4199
+ if (pid === myPid) {
4200
+ // Our own claim. Normally we're actively sending it — leave
4201
+ // it. But the send is now bounded (60s APPEND timeout), so
4202
+ // an OWN claim older than STALE_CLAIM_MS means the send
4203
+ // hung past its timeout, or a prior tick orphaned it (the
4204
+ // release rename failed). Without reclaiming it the file
4205
+ // sits in `.sending-` forever — recovery used to skip every
4206
+ // own-PID claim unconditionally, so a transient connection
4207
+ // wedge pinned the message even after the link recovered
4208
+ // (Bob 2026-06-11: two messages stuck .sending-<ourpid>
4209
+ // while SELECT Outbox was already succeeding again).
4210
+ if (ageMs < STALE_CLAIM_MS)
4211
+ continue;
4212
+ try {
4213
+ fs.renameSync(path.join(dir, f), path.join(dir, original));
4214
+ console.log(` [outbox] Recovered our own stale claim ${f} → ${original} (hung ${Math.round(ageMs / 60_000)}m)`);
4215
+ }
4216
+ catch { /* ignore */ }
4217
+ continue;
4218
+ }
4198
4219
  let alive = false;
4199
4220
  try {
4200
4221
  process.kill(pid, 0);
4201
4222
  alive = true;
4202
4223
  }
4203
4224
  catch { /* dead */ }
4204
- let ageMs = Infinity;
4205
- try {
4206
- ageMs = Date.now() - fs.statSync(path.join(dir, f)).mtimeMs;
4207
- }
4208
- catch { /* */ }
4209
4225
  // Live PID + recent mtime → genuine sibling owner, leave it.
4210
4226
  // Live PID + ancient mtime → recycled PID, sweep. Dead PID → sweep.
4211
4227
  if (alive && ageMs < STALE_CLAIM_MS)
@@ -4338,7 +4354,15 @@ export class ImapManager extends EventEmitter {
4338
4354
  }
4339
4355
  try {
4340
4356
  const raw = fs.readFileSync(claimedPath, "utf-8");
4341
- await client.appendMessage(outboxPath, raw, ["\\Seen"]);
4357
+ // Bound the APPEND. On a wedged connection (Dovecot
4358
+ // ETIMEDOUT storm) the bare await can hang the full
4359
+ // 300s inactivity timeout, pinning the file in
4360
+ // `.sending-` state the whole time and reading to the
4361
+ // user as "stuck, not sending" (Bob 2026-06-11). A
4362
+ // 60s cap force-closes the socket and throws, so the
4363
+ // catch below releases the claim and the next tick
4364
+ // retries instead of hanging for 5 minutes.
4365
+ await withTimeout(client.appendMessage(outboxPath, raw, ["\\Seen"]), 60_000, client, `outbox APPEND ${file}`);
4342
4366
  fs.renameSync(claimedPath, path.join(sentDir, file));
4343
4367
  console.log(` [outbox] Moved ${file} to IMAP Outbox → sent/`);
4344
4368
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.85",
3
+ "version": "0.1.87",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -9,14 +9,14 @@
9
9
  },
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
- "@bobfrankston/mailx-types": "^0.1.18",
12
+ "@bobfrankston/mailx-types": "^0.1.19",
13
13
  "@bobfrankston/mailx-settings": "^0.1.26",
14
- "@bobfrankston/mailx-store": "^0.1.45",
15
- "@bobfrankston/iflow-direct": "^0.1.52",
16
- "@bobfrankston/tcp-transport": "^0.1.6",
17
- "@bobfrankston/smtp-direct": "^0.1.8",
18
- "@bobfrankston/mailx-sync": "^0.1.19",
19
- "@bobfrankston/oauthsupport": "^1.0.31"
14
+ "@bobfrankston/mailx-store": "^0.1.46",
15
+ "@bobfrankston/iflow-direct": "^0.1.53",
16
+ "@bobfrankston/tcp-transport": "^0.1.7",
17
+ "@bobfrankston/smtp-direct": "^0.1.9",
18
+ "@bobfrankston/mailx-sync": "^0.1.20",
19
+ "@bobfrankston/oauthsupport": "^1.0.32"
20
20
  },
21
21
  "repository": {
22
22
  "type": "git",
@@ -37,14 +37,14 @@
37
37
  },
38
38
  ".transformedSnapshot": {
39
39
  "dependencies": {
40
- "@bobfrankston/mailx-types": "^0.1.18",
40
+ "@bobfrankston/mailx-types": "^0.1.19",
41
41
  "@bobfrankston/mailx-settings": "^0.1.26",
42
- "@bobfrankston/mailx-store": "^0.1.45",
43
- "@bobfrankston/iflow-direct": "^0.1.52",
44
- "@bobfrankston/tcp-transport": "^0.1.6",
45
- "@bobfrankston/smtp-direct": "^0.1.8",
46
- "@bobfrankston/mailx-sync": "^0.1.19",
47
- "@bobfrankston/oauthsupport": "^1.0.31"
42
+ "@bobfrankston/mailx-store": "^0.1.46",
43
+ "@bobfrankston/iflow-direct": "^0.1.53",
44
+ "@bobfrankston/tcp-transport": "^0.1.7",
45
+ "@bobfrankston/smtp-direct": "^0.1.9",
46
+ "@bobfrankston/mailx-sync": "^0.1.20",
47
+ "@bobfrankston/oauthsupport": "^1.0.32"
48
48
  }
49
49
  }
50
50
  }