@bobfrankston/mailx-imap 0.1.38 → 0.1.40

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 +82 -12
  2. package/package.json +3 -3
package/index.js CHANGED
@@ -861,9 +861,30 @@ export class ImapManager extends EventEmitter {
861
861
  }
862
862
  /** Store a batch of messages to DB immediately — used by onChunk for incremental sync */
863
863
  async storeMessages(accountId, folderId, folder, msgs, highestUid) {
864
+ // Chunked transactions: better-sqlite3 is synchronous and an open
865
+ // transaction blocks every other query on the same DB. A 1881-row
866
+ // INBOX sync inside ONE transaction locked the connection for ~1.5 s
867
+ // of straight upserts; any concurrent `getMessage` IPC had to wait
868
+ // out the whole loop before its own DB read could run. Bob 2026-05-13:
869
+ // "long long loading again." Solution: commit every BATCH_SIZE rows,
870
+ // yield the event loop with `setImmediate`, then begin a new
871
+ // transaction. User clicks land in those gaps with a single-row
872
+ // worth of latency instead of the full loop's worth.
873
+ const BATCH_SIZE = 50;
864
874
  let stored = 0;
865
- this.db.beginTransaction();
875
+ let inTxn = false;
876
+ const startTxn = () => { this.db.beginTransaction(); inTxn = true; };
877
+ const commitTxn = () => { if (inTxn) {
878
+ this.db.commitTransaction();
879
+ inTxn = false;
880
+ } };
881
+ const rollbackTxn = () => { if (inTxn) {
882
+ this.db.rollbackTransaction();
883
+ inTxn = false;
884
+ } };
866
885
  try {
886
+ startTxn();
887
+ let batchCount = 0;
867
888
  for (const msg of msgs) {
868
889
  // Debug: log subjects with non-ASCII to trace encoding issues
869
890
  if (msg.subject && /[^\x00-\x7F]/.test(msg.subject)) {
@@ -919,11 +940,23 @@ export class ImapManager extends EventEmitter {
919
940
  flags, size: msg.size || 0, hasAttachments, preview, bodyPath
920
941
  });
921
942
  stored++;
943
+ batchCount++;
944
+ if (batchCount >= BATCH_SIZE) {
945
+ commitTxn();
946
+ // Hand the event loop to any waiting IPC (user clicks,
947
+ // outbox drains, alarm polls). setImmediate runs AFTER
948
+ // pending I/O callbacks, so a stack of getMessage IPCs
949
+ // that arrived during the previous chunk gets serviced
950
+ // before we start the next batch.
951
+ await new Promise(r => setImmediate(r));
952
+ batchCount = 0;
953
+ startTxn();
954
+ }
922
955
  }
923
- this.db.commitTransaction();
956
+ commitTxn();
924
957
  }
925
958
  catch (e) {
926
- this.db.rollbackTransaction();
959
+ rollbackTxn();
927
960
  console.error(` storeMessages error: ${e.message}`);
928
961
  }
929
962
  return stored;
@@ -1041,6 +1074,11 @@ export class ImapManager extends EventEmitter {
1041
1074
  // matches our local count, NOTHING changed — skip SELECT entirely.
1042
1075
  // For first sync (highestUid = 0) skip the STATUS check and let the
1043
1076
  // existing first-sync path (sequence-FETCH the latest N) run.
1077
+ // Captured at function scope so the set-diff block below can decide
1078
+ // when to skip the date-bound and fetch all UIDs (small folder
1079
+ // where a full UID SEARCH is cheap and reveals server-side deletes
1080
+ // of older messages).
1081
+ let statusMessageCount = null;
1044
1082
  if (highestUid > 0) {
1045
1083
  try {
1046
1084
  console.log(` [sync-status] ${accountId}/${folder.path}: calling STATUS...`);
@@ -1048,6 +1086,8 @@ export class ImapManager extends EventEmitter {
1048
1086
  const status = await client.native?.getStatus?.(folder.path)
1049
1087
  ?? await client.getStatus?.(folder.path);
1050
1088
  console.log(` [sync-status] ${accountId}/${folder.path}: STATUS returned in ${Date.now() - __statusT0}ms`);
1089
+ if (status && typeof status.messages === "number")
1090
+ statusMessageCount = status.messages;
1051
1091
  if (status && typeof status.uidNext === "number") {
1052
1092
  const serverHighest = status.uidNext - 1;
1053
1093
  const noNewUids = serverHighest <= highestUid;
@@ -1132,19 +1172,38 @@ export class ImapManager extends EventEmitter {
1132
1172
  // archive every cycle. First sync gets all UIDs (no anchor
1133
1173
  // yet so we have to compare against the empty local set
1134
1174
  // anyway).
1175
+ // Small folders (Drafts, Sent, Outbox, Trash, …) get a FULL
1176
+ // UID fetch instead of date-bounded. Without this, server-side
1177
+ // deletions of OLDER messages (e.g., user emptied Trash from
1178
+ // Thunderbird) are never reflected — date-bound filters them
1179
+ // out of the comparison entirely. Bob 2026-05-12: "I deleted
1180
+ // my drafts and Thunderbird shows that but rmfmail is not
1181
+ // acknowledging the deletions." Threshold tuned conservatively
1182
+ // — UID SEARCH ALL on a 500-message folder is sub-second; the
1183
+ // pathology being avoided is the same call on 130k+ INBOX.
1184
+ const SMALL_FOLDER_THRESHOLD = 500;
1185
+ const isSmallFolder = statusMessageCount !== null && statusMessageCount <= SMALL_FOLDER_THRESHOLD;
1135
1186
  const SINCE_DAYS = effectiveDays > 0 ? effectiveDays : 1825;
1136
1187
  const sinceDate = new Date(Date.now() - SINCE_DAYS * 86400000);
1137
- console.log(` [sync-uids] ${accountId}/${folder.path}: getUidsSince ${sinceDate.toISOString().slice(0, 10)}...`);
1188
+ let allServerUids;
1138
1189
  const __uidsT0 = Date.now();
1139
- const allServerUids = typeof client.getUidsSince === "function"
1140
- ? await client.getUidsSince(folder.path, sinceDate)
1141
- : await client.getUids(folder.path);
1190
+ if (isSmallFolder) {
1191
+ console.log(` [sync-uids] ${accountId}/${folder.path}: small folder (server=${statusMessageCount}) — getUids ALL`);
1192
+ allServerUids = await client.getUids(folder.path);
1193
+ serverUidsAreDateBounded = false;
1194
+ }
1195
+ else {
1196
+ console.log(` [sync-uids] ${accountId}/${folder.path}: getUidsSince ${sinceDate.toISOString().slice(0, 10)}...`);
1197
+ allServerUids = typeof client.getUidsSince === "function"
1198
+ ? await client.getUidsSince(folder.path, sinceDate)
1199
+ : await client.getUids(folder.path);
1200
+ serverUidsAreDateBounded = true;
1201
+ }
1142
1202
  console.log(` [sync-uids] ${accountId}/${folder.path}: ${allServerUids.length} UIDs in ${Date.now() - __uidsT0}ms`);
1143
1203
  // Stash for the deletion-reconciliation block below — we
1144
- // already have the date-bounded server UID list, no point
1145
- // hitting the server a second time with UID SEARCH ALL.
1204
+ // already have the server UID list, no point hitting the
1205
+ // server a second time.
1146
1206
  serverUidsCached = allServerUids;
1147
- serverUidsAreDateBounded = true;
1148
1207
  const existingSet = new Set(existingUids);
1149
1208
  const newSet = new Set(messages.map(m => m.uid));
1150
1209
  const missingUids = allServerUids.filter((uid) => !existingSet.has(uid) && !newSet.has(uid));
@@ -1367,10 +1426,21 @@ export class ImapManager extends EventEmitter {
1367
1426
  localUids = localUidsAll.filter(u => u >= minServerUid);
1368
1427
  }
1369
1428
  const toDelete = localUids.filter(uid => !serverUids.has(uid));
1370
- if (serverUidsArr.length === 0 && localUidsAll.length > 0) {
1429
+ // Authoritative-list signal: when the server's UID list came
1430
+ // back as the FULL set (not date-bounded) AND the size matches
1431
+ // what STATUS reported, we know we have the server's truth.
1432
+ // The 50% safety guard exists to protect against partial /
1433
+ // transient server responses; it should NOT block reconcile
1434
+ // when the response is authoritative. Without this carve-out,
1435
+ // emptying a folder externally (Thunderbird, webmail, mobile)
1436
+ // leaves the old rows in mailx forever (Bob 2026-05-12).
1437
+ const authoritative = !serverUidsAreDateBounded
1438
+ && statusMessageCount !== null
1439
+ && serverUidsArr.length === statusMessageCount;
1440
+ if (serverUidsArr.length === 0 && localUidsAll.length > 0 && !authoritative) {
1371
1441
  console.log(` [sync] ${accountId}/${folder.path}: reconcile skipped — server UID list empty but local has ${localUidsAll.length} (treating as transient)`);
1372
1442
  }
1373
- else if (localUids.length > 0 && toDelete.length / localUids.length > 0.5) {
1443
+ else if (!authoritative && localUids.length > 0 && toDelete.length / localUids.length > 0.5) {
1374
1444
  console.log(` [sync] ${accountId}/${folder.path}: reconcile REFUSED — would delete ${toDelete.length}/${localUids.length} (${Math.round(toDelete.length / localUids.length * 100)}%) — probably a sync bug, skipping`);
1375
1445
  }
1376
1446
  else if (toDelete.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.38",
3
+ "version": "0.1.40",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -11,7 +11,7 @@
11
11
  "dependencies": {
12
12
  "@bobfrankston/mailx-types": "^0.1.11",
13
13
  "@bobfrankston/mailx-settings": "^0.1.16",
14
- "@bobfrankston/mailx-store": "^0.1.19",
14
+ "@bobfrankston/mailx-store": "^0.1.21",
15
15
  "@bobfrankston/iflow-direct": "^0.1.41",
16
16
  "@bobfrankston/tcp-transport": "^0.1.6",
17
17
  "@bobfrankston/smtp-direct": "^0.1.8",
@@ -39,7 +39,7 @@
39
39
  "dependencies": {
40
40
  "@bobfrankston/mailx-types": "^0.1.11",
41
41
  "@bobfrankston/mailx-settings": "^0.1.16",
42
- "@bobfrankston/mailx-store": "^0.1.19",
42
+ "@bobfrankston/mailx-store": "^0.1.21",
43
43
  "@bobfrankston/iflow-direct": "^0.1.41",
44
44
  "@bobfrankston/tcp-transport": "^0.1.6",
45
45
  "@bobfrankston/smtp-direct": "^0.1.8",