@bobfrankston/mailx-imap 0.1.38 → 0.1.39

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 +46 -9
  2. package/package.json +3 -3
package/index.js CHANGED
@@ -1041,6 +1041,11 @@ export class ImapManager extends EventEmitter {
1041
1041
  // matches our local count, NOTHING changed — skip SELECT entirely.
1042
1042
  // For first sync (highestUid = 0) skip the STATUS check and let the
1043
1043
  // existing first-sync path (sequence-FETCH the latest N) run.
1044
+ // Captured at function scope so the set-diff block below can decide
1045
+ // when to skip the date-bound and fetch all UIDs (small folder
1046
+ // where a full UID SEARCH is cheap and reveals server-side deletes
1047
+ // of older messages).
1048
+ let statusMessageCount = null;
1044
1049
  if (highestUid > 0) {
1045
1050
  try {
1046
1051
  console.log(` [sync-status] ${accountId}/${folder.path}: calling STATUS...`);
@@ -1048,6 +1053,8 @@ export class ImapManager extends EventEmitter {
1048
1053
  const status = await client.native?.getStatus?.(folder.path)
1049
1054
  ?? await client.getStatus?.(folder.path);
1050
1055
  console.log(` [sync-status] ${accountId}/${folder.path}: STATUS returned in ${Date.now() - __statusT0}ms`);
1056
+ if (status && typeof status.messages === "number")
1057
+ statusMessageCount = status.messages;
1051
1058
  if (status && typeof status.uidNext === "number") {
1052
1059
  const serverHighest = status.uidNext - 1;
1053
1060
  const noNewUids = serverHighest <= highestUid;
@@ -1132,19 +1139,38 @@ export class ImapManager extends EventEmitter {
1132
1139
  // archive every cycle. First sync gets all UIDs (no anchor
1133
1140
  // yet so we have to compare against the empty local set
1134
1141
  // anyway).
1142
+ // Small folders (Drafts, Sent, Outbox, Trash, …) get a FULL
1143
+ // UID fetch instead of date-bounded. Without this, server-side
1144
+ // deletions of OLDER messages (e.g., user emptied Trash from
1145
+ // Thunderbird) are never reflected — date-bound filters them
1146
+ // out of the comparison entirely. Bob 2026-05-12: "I deleted
1147
+ // my drafts and Thunderbird shows that but rmfmail is not
1148
+ // acknowledging the deletions." Threshold tuned conservatively
1149
+ // — UID SEARCH ALL on a 500-message folder is sub-second; the
1150
+ // pathology being avoided is the same call on 130k+ INBOX.
1151
+ const SMALL_FOLDER_THRESHOLD = 500;
1152
+ const isSmallFolder = statusMessageCount !== null && statusMessageCount <= SMALL_FOLDER_THRESHOLD;
1135
1153
  const SINCE_DAYS = effectiveDays > 0 ? effectiveDays : 1825;
1136
1154
  const sinceDate = new Date(Date.now() - SINCE_DAYS * 86400000);
1137
- console.log(` [sync-uids] ${accountId}/${folder.path}: getUidsSince ${sinceDate.toISOString().slice(0, 10)}...`);
1155
+ let allServerUids;
1138
1156
  const __uidsT0 = Date.now();
1139
- const allServerUids = typeof client.getUidsSince === "function"
1140
- ? await client.getUidsSince(folder.path, sinceDate)
1141
- : await client.getUids(folder.path);
1157
+ if (isSmallFolder) {
1158
+ console.log(` [sync-uids] ${accountId}/${folder.path}: small folder (server=${statusMessageCount}) — getUids ALL`);
1159
+ allServerUids = await client.getUids(folder.path);
1160
+ serverUidsAreDateBounded = false;
1161
+ }
1162
+ else {
1163
+ console.log(` [sync-uids] ${accountId}/${folder.path}: getUidsSince ${sinceDate.toISOString().slice(0, 10)}...`);
1164
+ allServerUids = typeof client.getUidsSince === "function"
1165
+ ? await client.getUidsSince(folder.path, sinceDate)
1166
+ : await client.getUids(folder.path);
1167
+ serverUidsAreDateBounded = true;
1168
+ }
1142
1169
  console.log(` [sync-uids] ${accountId}/${folder.path}: ${allServerUids.length} UIDs in ${Date.now() - __uidsT0}ms`);
1143
1170
  // 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.
1171
+ // already have the server UID list, no point hitting the
1172
+ // server a second time.
1146
1173
  serverUidsCached = allServerUids;
1147
- serverUidsAreDateBounded = true;
1148
1174
  const existingSet = new Set(existingUids);
1149
1175
  const newSet = new Set(messages.map(m => m.uid));
1150
1176
  const missingUids = allServerUids.filter((uid) => !existingSet.has(uid) && !newSet.has(uid));
@@ -1367,10 +1393,21 @@ export class ImapManager extends EventEmitter {
1367
1393
  localUids = localUidsAll.filter(u => u >= minServerUid);
1368
1394
  }
1369
1395
  const toDelete = localUids.filter(uid => !serverUids.has(uid));
1370
- if (serverUidsArr.length === 0 && localUidsAll.length > 0) {
1396
+ // Authoritative-list signal: when the server's UID list came
1397
+ // back as the FULL set (not date-bounded) AND the size matches
1398
+ // what STATUS reported, we know we have the server's truth.
1399
+ // The 50% safety guard exists to protect against partial /
1400
+ // transient server responses; it should NOT block reconcile
1401
+ // when the response is authoritative. Without this carve-out,
1402
+ // emptying a folder externally (Thunderbird, webmail, mobile)
1403
+ // leaves the old rows in mailx forever (Bob 2026-05-12).
1404
+ const authoritative = !serverUidsAreDateBounded
1405
+ && statusMessageCount !== null
1406
+ && serverUidsArr.length === statusMessageCount;
1407
+ if (serverUidsArr.length === 0 && localUidsAll.length > 0 && !authoritative) {
1371
1408
  console.log(` [sync] ${accountId}/${folder.path}: reconcile skipped — server UID list empty but local has ${localUidsAll.length} (treating as transient)`);
1372
1409
  }
1373
- else if (localUids.length > 0 && toDelete.length / localUids.length > 0.5) {
1410
+ else if (!authoritative && localUids.length > 0 && toDelete.length / localUids.length > 0.5) {
1374
1411
  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
1412
  }
1376
1413
  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.39",
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.20",
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.20",
43
43
  "@bobfrankston/iflow-direct": "^0.1.41",
44
44
  "@bobfrankston/tcp-transport": "^0.1.6",
45
45
  "@bobfrankston/smtp-direct": "^0.1.8",