@bobfrankston/mailx-imap 0.1.26 → 0.1.27
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.d.ts +17 -0
- package/index.js +93 -56
- package/package.json +5 -5
package/index.d.ts
CHANGED
|
@@ -257,6 +257,23 @@ export declare class ImapManager extends EventEmitter {
|
|
|
257
257
|
/** Unlink the on-disk body file for a message by reading its `body_path`
|
|
258
258
|
* from the DB. Safe to call either before or after `db.deleteMessage`
|
|
259
259
|
* — read body_path first, store it, then unlink whenever. */
|
|
260
|
+
/** Per-(account, folder, uid) deferred-delete timer. Reconcile populates
|
|
261
|
+
* this; the timer fires 60s later and re-checks whether the row at that
|
|
262
|
+
* exact (folder, uid) still exists. If yes → really delete (server
|
|
263
|
+
* expunged it). If no → skip (move-detect rebound it elsewhere; the
|
|
264
|
+
* row already moved to a new folder/uid in the same DB row, so the
|
|
265
|
+
* original key no longer matches anything). Net effect: server-side
|
|
266
|
+
* moves preserve UUID + body cache + flags. */
|
|
267
|
+
private deferredDeletes;
|
|
268
|
+
/** Grace window for reconcile-delete. Set to 30 minutes because the
|
|
269
|
+
* full-folder sync loop walks ~96 folders sequentially and each one
|
|
270
|
+
* takes 1-30 seconds; a server-side move from INBOX to a folder that
|
|
271
|
+
* syncs late (alphabetically far from INBOX) can take 10+ minutes
|
|
272
|
+
* before move-detect fires in the destination. 60s was way too short
|
|
273
|
+
* in production — the grace expired and rows were committed-deleted
|
|
274
|
+
* before _Spam / Sent / archive folders had a chance to rebind. */
|
|
275
|
+
private static readonly RECONCILE_DELETE_GRACE_MS;
|
|
276
|
+
private scheduleDeferredReconcileDelete;
|
|
260
277
|
private unlinkBodyFile;
|
|
261
278
|
/** Fetch a single message body on demand, caching in the store.
|
|
262
279
|
*
|
package/index.js
CHANGED
|
@@ -199,6 +199,19 @@ export class ImapManager extends EventEmitter {
|
|
|
199
199
|
this.transportFactory = transportFactory;
|
|
200
200
|
const storePath = getStorePath();
|
|
201
201
|
this.bodyStore = new FileMessageStore(storePath);
|
|
202
|
+
// Cancel pending deferred-delete when move-detect rebinds a row.
|
|
203
|
+
// Without this, the source folder's reconcile-delete grace timer
|
|
204
|
+
// would fire 30 minutes after detection for a row that's already
|
|
205
|
+
// moved elsewhere — the user would see the message vanish.
|
|
206
|
+
this.db.setOnMoveDetected((info) => {
|
|
207
|
+
const key = `${info.accountId}:${info.fromFolderId}:${info.fromUid}`;
|
|
208
|
+
const t = this.deferredDeletes.get(key);
|
|
209
|
+
if (t) {
|
|
210
|
+
clearTimeout(t);
|
|
211
|
+
this.deferredDeletes.delete(key);
|
|
212
|
+
console.log(` [reconcile-cancel] ${info.accountId} ${info.fromFolderId}/${info.fromUid}: deferred delete cancelled (move-detect rebound to ${info.toFolderId}/${info.toUid})`);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
202
215
|
}
|
|
203
216
|
/** Get OAuth access token for an account (for SMTP auth) */
|
|
204
217
|
async getOAuthToken(accountId) {
|
|
@@ -813,8 +826,15 @@ export class ImapManager extends EventEmitter {
|
|
|
813
826
|
const hex = Buffer.from(msg.subject, "utf-8").subarray(0, 40).toString("hex");
|
|
814
827
|
console.log(` [encoding] subject: "${msg.subject.substring(0, 60)}" hex: ${hex}`);
|
|
815
828
|
}
|
|
816
|
-
|
|
817
|
-
|
|
829
|
+
// CRITICAL: do NOT skip on `uid <= highestUid`. That check
|
|
830
|
+
// was a major bug — it silently dropped every gap-filled
|
|
831
|
+
// message (gap-fill specifically recovers UIDs in the
|
|
832
|
+
// already-scanned range, all of which are <= highestUid).
|
|
833
|
+
// upsertMessage's UNIQUE(account, folder, uid) constraint
|
|
834
|
+
// already deduplicates: if mailx truly has the row, the
|
|
835
|
+
// upsert turns into an UPDATE (cheap); if it doesn't, the
|
|
836
|
+
// insert proceeds. Trust the constraint — don't second-
|
|
837
|
+
// guess it on a stale highestUid snapshot.
|
|
818
838
|
// Tombstone check: if the user locally deleted this Message-ID,
|
|
819
839
|
// don't re-import it. Server-side EXPUNGE may lag, or reconcile
|
|
820
840
|
// may find the message in an old list snapshot. Without this,
|
|
@@ -878,36 +898,11 @@ export class ImapManager extends EventEmitter {
|
|
|
878
898
|
// monotonically increasing within a UIDVALIDITY (RFC 3501); a
|
|
879
899
|
// high-water mark is the right anchor for incremental fetch.
|
|
880
900
|
const highestUid = this.db.getHighestUid(accountId, folderId);
|
|
881
|
-
// STATUS-before-SELECT
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
//
|
|
885
|
-
//
|
|
886
|
-
// Apply uniformly to every folder — no special-casing.
|
|
887
|
-
try {
|
|
888
|
-
if (typeof client.getStatus === "function") {
|
|
889
|
-
const status = await client.getStatus(folder.path);
|
|
890
|
-
const serverHighest = (status.uidNext || 1) - 1;
|
|
891
|
-
const serverCount = status.messages ?? -1;
|
|
892
|
-
const localCount = this.db.getMessageCount(accountId, folderId);
|
|
893
|
-
const isUpToDate = highestUid > 0
|
|
894
|
-
&& serverHighest <= highestUid
|
|
895
|
-
&& serverCount >= 0
|
|
896
|
-
&& serverCount === localCount;
|
|
897
|
-
if (isUpToDate) {
|
|
898
|
-
console.log(` [sync] ${accountId}/${folder.path}: STATUS up-to-date (uidNext=${status.uidNext}, server=${serverCount}, local=${localCount}) — skipping SELECT`);
|
|
899
|
-
this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
|
|
900
|
-
return 0;
|
|
901
|
-
}
|
|
902
|
-
console.log(` [sync] ${accountId}/${folder.path}: STATUS uidNext=${status.uidNext} server=${serverCount} local=${localCount} highestUid=${highestUid} — needs SELECT`);
|
|
903
|
-
}
|
|
904
|
-
}
|
|
905
|
-
catch (e) {
|
|
906
|
-
// STATUS shouldn't fail, but don't make it load-bearing — fall
|
|
907
|
-
// through to the SELECT path on any error and let that path's
|
|
908
|
-
// existing handling deal with it.
|
|
909
|
-
console.log(` [sync] ${accountId}/${folder.path}: STATUS failed (${e?.message || e}) — falling through to SELECT`);
|
|
910
|
-
}
|
|
901
|
+
// STATUS-before-SELECT was here. Removed — added a round-trip per
|
|
902
|
+
// folder with no measured benefit on Bob's link, and the speculative
|
|
903
|
+
// "skip SELECT when nothing changed" optimization didn't actually
|
|
904
|
+
// skip anything in practice (server count vs local count nearly
|
|
905
|
+
// always differs slightly because of in-flight deletes/moves).
|
|
911
906
|
console.log(` [sync] ${accountId}/${folder.path}: highestUid=${highestUid}, fetching...`);
|
|
912
907
|
let messages;
|
|
913
908
|
const firstSync = highestUid === 0;
|
|
@@ -1027,21 +1022,14 @@ export class ImapManager extends EventEmitter {
|
|
|
1027
1022
|
try {
|
|
1028
1023
|
for (let i = batchStart; i < batchEnd; i++) {
|
|
1029
1024
|
const msg = messages[i];
|
|
1030
|
-
//
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
if (msg.answered)
|
|
1039
|
-
flags.push("\\Answered");
|
|
1040
|
-
if (msg.draft)
|
|
1041
|
-
flags.push("\\Draft");
|
|
1042
|
-
this.db.updateMessageFlags(accountId, msg.uid, flags);
|
|
1043
|
-
continue;
|
|
1044
|
-
}
|
|
1025
|
+
// CRITICAL: was `if (msg.uid <= highestUid) { update flags; continue }`.
|
|
1026
|
+
// Same bug as the streamy storeMessages path on line ~861:
|
|
1027
|
+
// it dropped every gap-fill message because gap-fills
|
|
1028
|
+
// recover UIDs in the already-scanned range (all <=
|
|
1029
|
+
// highestUid by definition). Trust upsertMessage's
|
|
1030
|
+
// UNIQUE constraint to dedupe — if mailx truly has the
|
|
1031
|
+
// row, the upsert becomes an UPDATE that refreshes
|
|
1032
|
+
// flags too, all in one path.
|
|
1045
1033
|
// Tombstone check — same reason as the streamy onChunk path
|
|
1046
1034
|
// at storeMessages: a locally-deleted message that the server
|
|
1047
1035
|
// hasn't EXPUNGEd yet would otherwise reappear on next sync.
|
|
@@ -1132,17 +1120,25 @@ export class ImapManager extends EventEmitter {
|
|
|
1132
1120
|
else if (localUids.length > 0 && toDelete.length / localUids.length > 0.5) {
|
|
1133
1121
|
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`);
|
|
1134
1122
|
}
|
|
1135
|
-
else {
|
|
1123
|
+
else if (toDelete.length > 0) {
|
|
1124
|
+
// DEFERRED DELETE — DO NOT delete immediately. Server-side
|
|
1125
|
+
// moves (Sieve filters, IMAP MOVE from another client)
|
|
1126
|
+
// make a UID disappear from the source folder before
|
|
1127
|
+
// showing up in the destination. mailx syncs folders
|
|
1128
|
+
// sequentially; if we delete on first detection, the
|
|
1129
|
+
// dest's `upsertMessage`-side move-detect can't rebind
|
|
1130
|
+
// (the row is gone), and we lose the UUID + body cache,
|
|
1131
|
+
// forcing a re-fetch from server.
|
|
1132
|
+
//
|
|
1133
|
+
// Instead: schedule the delete 60s out. If between now
|
|
1134
|
+
// and then move-detect rebinds the row (folder_id changes
|
|
1135
|
+
// to dest), the (acc, folder, uid) lookup at fire time
|
|
1136
|
+
// won't match — skip. If still at original (truly gone
|
|
1137
|
+
// from server, not just moved), commit the delete.
|
|
1136
1138
|
for (const uid of toDelete) {
|
|
1137
|
-
|
|
1138
|
-
const tag = env ? `msgid=${env.messageId || "?"} subj="${(env.subject || "").slice(0, 60)}"` : "unknown";
|
|
1139
|
-
console.log(` [reconcile-delete] ${accountId}/${folder.path} uid=${uid} ${tag}`);
|
|
1140
|
-
this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
|
|
1141
|
-
this.db.deleteMessage(accountId, uid, "reconcile: server returned UID list without this row", `mailx-imap syncFolder reconcile (${folder.path})`);
|
|
1142
|
-
deletedCount++;
|
|
1139
|
+
this.scheduleDeferredReconcileDelete(accountId, folderId, uid, folder.path);
|
|
1143
1140
|
}
|
|
1144
|
-
|
|
1145
|
-
console.log(` removed ${deletedCount} deleted messages`);
|
|
1141
|
+
console.log(` [reconcile-defer] ${accountId}/${folder.path}: scheduled ${toDelete.length} deletes (60s grace for move-detect)`);
|
|
1146
1142
|
}
|
|
1147
1143
|
}
|
|
1148
1144
|
catch (e) {
|
|
@@ -1935,6 +1931,47 @@ export class ImapManager extends EventEmitter {
|
|
|
1935
1931
|
/** Unlink the on-disk body file for a message by reading its `body_path`
|
|
1936
1932
|
* from the DB. Safe to call either before or after `db.deleteMessage`
|
|
1937
1933
|
* — read body_path first, store it, then unlink whenever. */
|
|
1934
|
+
/** Per-(account, folder, uid) deferred-delete timer. Reconcile populates
|
|
1935
|
+
* this; the timer fires 60s later and re-checks whether the row at that
|
|
1936
|
+
* exact (folder, uid) still exists. If yes → really delete (server
|
|
1937
|
+
* expunged it). If no → skip (move-detect rebound it elsewhere; the
|
|
1938
|
+
* row already moved to a new folder/uid in the same DB row, so the
|
|
1939
|
+
* original key no longer matches anything). Net effect: server-side
|
|
1940
|
+
* moves preserve UUID + body cache + flags. */
|
|
1941
|
+
deferredDeletes = new Map();
|
|
1942
|
+
/** Grace window for reconcile-delete. Set to 30 minutes because the
|
|
1943
|
+
* full-folder sync loop walks ~96 folders sequentially and each one
|
|
1944
|
+
* takes 1-30 seconds; a server-side move from INBOX to a folder that
|
|
1945
|
+
* syncs late (alphabetically far from INBOX) can take 10+ minutes
|
|
1946
|
+
* before move-detect fires in the destination. 60s was way too short
|
|
1947
|
+
* in production — the grace expired and rows were committed-deleted
|
|
1948
|
+
* before _Spam / Sent / archive folders had a chance to rebind. */
|
|
1949
|
+
static RECONCILE_DELETE_GRACE_MS = 30 * 60_000;
|
|
1950
|
+
scheduleDeferredReconcileDelete(accountId, folderId, uid, folderPath) {
|
|
1951
|
+
const key = `${accountId}:${folderId}:${uid}`;
|
|
1952
|
+
// If already pending, don't reset — the 60s clock should run from
|
|
1953
|
+
// the FIRST detection so a flapping server can't keep deferring.
|
|
1954
|
+
if (this.deferredDeletes.has(key))
|
|
1955
|
+
return;
|
|
1956
|
+
const t = setTimeout(() => {
|
|
1957
|
+
this.deferredDeletes.delete(key);
|
|
1958
|
+
// Verify the row still exists at the original (folder, uid).
|
|
1959
|
+
// If move-detect rebound it during the grace window, this
|
|
1960
|
+
// lookup returns null — skip the delete.
|
|
1961
|
+
const env = this.db.getMessageByUid(accountId, uid, folderId);
|
|
1962
|
+
if (!env || env.folderId !== folderId) {
|
|
1963
|
+
console.log(` [reconcile-skip] ${accountId}/${folderPath} uid=${uid}: row moved during grace window (rebound to folder ${env?.folderId ?? "deleted"})`);
|
|
1964
|
+
return;
|
|
1965
|
+
}
|
|
1966
|
+
const tag = env.messageId ? `msgid=${env.messageId} subj="${(env.subject || "").slice(0, 60)}"` : "no-msgid";
|
|
1967
|
+
console.log(` [reconcile-delete] ${accountId}/${folderPath} uid=${uid} ${tag} (after 60s grace, no rebind)`);
|
|
1968
|
+
this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
|
|
1969
|
+
this.db.deleteMessage(accountId, uid, "reconcile: server missing this UID 60s after detection (no move-detect rebind)", `mailx-imap syncFolder deferred reconcile (${folderPath})`);
|
|
1970
|
+
this.db.recalcFolderCounts(folderId);
|
|
1971
|
+
this.emit("folderCountsChanged", accountId, {});
|
|
1972
|
+
}, ImapManager.RECONCILE_DELETE_GRACE_MS);
|
|
1973
|
+
this.deferredDeletes.set(key, t);
|
|
1974
|
+
}
|
|
1938
1975
|
async unlinkBodyFile(accountId, uid, folderId) {
|
|
1939
1976
|
try {
|
|
1940
1977
|
const row = this.db.getMessageByUid(accountId, uid, folderId);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-imap",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.27",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@bobfrankston/mailx-types": "^0.1.10",
|
|
13
13
|
"@bobfrankston/mailx-settings": "^0.1.13",
|
|
14
|
-
"@bobfrankston/mailx-store": "^0.1.
|
|
15
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
14
|
+
"@bobfrankston/mailx-store": "^0.1.12",
|
|
15
|
+
"@bobfrankston/iflow-direct": "^0.1.35",
|
|
16
16
|
"@bobfrankston/tcp-transport": "^0.1.5",
|
|
17
17
|
"@bobfrankston/smtp-direct": "^0.1.5",
|
|
18
18
|
"@bobfrankston/mailx-sync": "^0.1.15",
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bobfrankston/mailx-types": "^0.1.10",
|
|
41
41
|
"@bobfrankston/mailx-settings": "^0.1.13",
|
|
42
|
-
"@bobfrankston/mailx-store": "^0.1.
|
|
43
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
42
|
+
"@bobfrankston/mailx-store": "^0.1.12",
|
|
43
|
+
"@bobfrankston/iflow-direct": "^0.1.35",
|
|
44
44
|
"@bobfrankston/tcp-transport": "^0.1.5",
|
|
45
45
|
"@bobfrankston/smtp-direct": "^0.1.5",
|
|
46
46
|
"@bobfrankston/mailx-sync": "^0.1.15",
|