@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.
- package/index.js +82 -12
- 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
|
-
|
|
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
|
-
|
|
956
|
+
commitTxn();
|
|
924
957
|
}
|
|
925
958
|
catch (e) {
|
|
926
|
-
|
|
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
|
-
|
|
1188
|
+
let allServerUids;
|
|
1138
1189
|
const __uidsT0 = Date.now();
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
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
|
|
1145
|
-
//
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|