@bobfrankston/mailx-imap 0.1.42 → 0.1.44
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 +5 -0
- package/index.js +183 -15
- package/package.json +5 -5
package/index.d.ts
CHANGED
|
@@ -67,6 +67,11 @@ export declare class ImapManager extends EventEmitter {
|
|
|
67
67
|
private accountErrorShown;
|
|
68
68
|
private syncing;
|
|
69
69
|
private inboxSyncing;
|
|
70
|
+
/** Wall-clock of ImapManager construction. Used by the lazy-folder-sync
|
|
71
|
+
* gate so non-priority folders defer their first-sync until ~3 min
|
|
72
|
+
* past startup, when the event loop has quieted down. Each restart
|
|
73
|
+
* resets this; the gate auto-lifts as time passes. */
|
|
74
|
+
private _startupAt;
|
|
70
75
|
/** Use native IMAP client instead of imapflow. Set to true to enable. */
|
|
71
76
|
useNativeClient: boolean;
|
|
72
77
|
/** Per-account health counters. Incremented when the server misbehaves
|
package/index.js
CHANGED
|
@@ -142,6 +142,11 @@ export class ImapManager extends EventEmitter {
|
|
|
142
142
|
accountErrorShown = new Set();
|
|
143
143
|
syncing = false;
|
|
144
144
|
inboxSyncing = false;
|
|
145
|
+
/** Wall-clock of ImapManager construction. Used by the lazy-folder-sync
|
|
146
|
+
* gate so non-priority folders defer their first-sync until ~3 min
|
|
147
|
+
* past startup, when the event loop has quieted down. Each restart
|
|
148
|
+
* resets this; the gate auto-lifts as time passes. */
|
|
149
|
+
_startupAt = Date.now();
|
|
145
150
|
/** Use native IMAP client instead of imapflow. Set to true to enable. */
|
|
146
151
|
useNativeClient = false;
|
|
147
152
|
// Connection management: see withConnection() below.
|
|
@@ -899,12 +904,15 @@ export class ImapManager extends EventEmitter {
|
|
|
899
904
|
// upsert turns into an UPDATE (cheap); if it doesn't, the
|
|
900
905
|
// insert proceeds. Trust the constraint — don't second-
|
|
901
906
|
// guess it on a stale highestUid snapshot.
|
|
902
|
-
// Tombstone check:
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
//
|
|
907
|
+
// Tombstone check: skip re-import while a local delete/move
|
|
908
|
+
// is in flight to the server. Cleared on action success
|
|
909
|
+
// (server EXPUNGED → next sync's set-diff drops the row
|
|
910
|
+
// anyway) or on permanent failure (clearTombstoneForUid in
|
|
911
|
+
// failSyncAction → row reappears, user sees the action
|
|
912
|
+
// didn't take). QRESYNC servers bypass this entirely
|
|
913
|
+
// because their VANISHED responses are authoritative.
|
|
906
914
|
if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
|
|
907
|
-
console.log(` [tombstone] ${accountId}: skipping ${msg.messageId} (locally deleted)`);
|
|
915
|
+
console.log(` [tombstone] ${accountId}: skipping ${msg.messageId} (locally deleted, awaiting server confirm)`);
|
|
908
916
|
continue;
|
|
909
917
|
}
|
|
910
918
|
const source = msg.source || "";
|
|
@@ -1119,6 +1127,93 @@ export class ImapManager extends EventEmitter {
|
|
|
1119
1127
|
}
|
|
1120
1128
|
}
|
|
1121
1129
|
console.log(` [sync] ${accountId}/${folder.path}: highestUid=${highestUid}, fetching...`);
|
|
1130
|
+
// ── QRESYNC fast path (RFC 7162) ─────────────────────────────────
|
|
1131
|
+
// When the server supports QRESYNC AND we have a saved (uidValidity,
|
|
1132
|
+
// modSeq) watermark for this folder, ask the server "what changed
|
|
1133
|
+
// since modSeq?". The server replies with an authoritative VANISHED
|
|
1134
|
+
// list (no client-side diffing needed, no tombstones needed) plus
|
|
1135
|
+
// unsolicited FETCH for flag/state changes. New UIDs > our prior
|
|
1136
|
+
// highest still go through the normal fetchMessagesSinceUid path
|
|
1137
|
+
// afterwards. Skips the heavy UID SEARCH set-diff below entirely.
|
|
1138
|
+
const folderSyncState = this.db.getFolderSync(folderId);
|
|
1139
|
+
const prevModSeq = parseInt(folderSyncState.highestModseq || "0", 10);
|
|
1140
|
+
const prevUidValidity = folderSyncState.uidvalidity || 0;
|
|
1141
|
+
const caps = (typeof client.getCapabilities === "function")
|
|
1142
|
+
? client.getCapabilities()
|
|
1143
|
+
: new Set();
|
|
1144
|
+
const qresyncSupported = caps.has("QRESYNC");
|
|
1145
|
+
const haveWatermark = highestUid > 0 && prevModSeq > 0 && prevUidValidity > 0;
|
|
1146
|
+
if (qresyncSupported && haveWatermark && typeof client.resyncFolder === "function") {
|
|
1147
|
+
try {
|
|
1148
|
+
// Ensure ENABLE QRESYNC has been issued on this connection.
|
|
1149
|
+
// enableQresync is idempotent + capability-gated so this is a
|
|
1150
|
+
// no-op on the second-and-subsequent call per connection.
|
|
1151
|
+
if (typeof client.enableQresync === "function") {
|
|
1152
|
+
await client.enableQresync();
|
|
1153
|
+
}
|
|
1154
|
+
const __qrT0 = Date.now();
|
|
1155
|
+
const qr = await client.resyncFolder(folder.path, prevUidValidity, prevModSeq);
|
|
1156
|
+
console.log(` [qresync] ${accountId}/${folder.path}: vanished=${qr.vanishedUids.length} changed=${qr.changedMessages.length} newModSeq=${qr.newHighestModSeq} in ${Date.now() - __qrT0}ms`);
|
|
1157
|
+
if (qr.uidValidityChanged) {
|
|
1158
|
+
// UIDVALIDITY rolled — our local UIDs are stale. Fall through
|
|
1159
|
+
// to the full set-diff path; that'll discover the new state
|
|
1160
|
+
// and the deletion safeguards (50% threshold) will keep us
|
|
1161
|
+
// from wiping anything we shouldn't.
|
|
1162
|
+
console.log(` [qresync] ${accountId}/${folder.path}: UIDVALIDITY changed (was ${prevUidValidity}, now ${qr.exists}); falling back to full sync`);
|
|
1163
|
+
}
|
|
1164
|
+
else {
|
|
1165
|
+
// Apply VANISHED — server says these UIDs are gone. No
|
|
1166
|
+
// tombstone, no diff, just delete the local rows.
|
|
1167
|
+
let vanishedApplied = 0;
|
|
1168
|
+
for (const uid of qr.vanishedUids) {
|
|
1169
|
+
const env = this.db.getMessageByUid(accountId, uid, folderId);
|
|
1170
|
+
if (env) {
|
|
1171
|
+
this.db.deleteMessage(accountId, uid, "server VANISHED via QRESYNC", "mailx-imap syncFolder/qresync");
|
|
1172
|
+
vanishedApplied++;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
if (vanishedApplied > 0) {
|
|
1176
|
+
this.db.recalcFolderCounts(folderId);
|
|
1177
|
+
this.emit("folderCountsChanged", accountId, {});
|
|
1178
|
+
}
|
|
1179
|
+
// Apply changed-state FETCH — flag updates since prior modSeq.
|
|
1180
|
+
for (const m of qr.changedMessages) {
|
|
1181
|
+
try {
|
|
1182
|
+
const flagsArr = Array.from(m.flags || []).map(f => String(f));
|
|
1183
|
+
this.db.updateMessageFlags(accountId, m.uid, flagsArr);
|
|
1184
|
+
}
|
|
1185
|
+
catch { /* row may have just been VANISHED */ }
|
|
1186
|
+
}
|
|
1187
|
+
// Fetch genuinely new messages — UID > our prior highest.
|
|
1188
|
+
// resyncFolder doesn't return these directly; the server
|
|
1189
|
+
// emits them only via state-change FETCH if their flags
|
|
1190
|
+
// changed since modSeq, which is unreliable. Pull them
|
|
1191
|
+
// explicitly via the existing incremental path.
|
|
1192
|
+
const fetched = await client.fetchMessagesSinceUid(folder.path, highestUid, { source: false });
|
|
1193
|
+
const newOnes = fetched.filter((m) => m.uid > highestUid);
|
|
1194
|
+
if (newOnes.length > 0) {
|
|
1195
|
+
await this.storeMessages(accountId, folderId, folder, newOnes, highestUid);
|
|
1196
|
+
}
|
|
1197
|
+
// Persist new watermark — next resync starts here.
|
|
1198
|
+
if (qr.newHighestModSeq !== undefined) {
|
|
1199
|
+
this.db.updateFolderSync(folderId, qr.exists ? prevUidValidity : prevUidValidity, String(qr.newHighestModSeq));
|
|
1200
|
+
}
|
|
1201
|
+
this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
|
|
1202
|
+
console.log(` [qresync] ${accountId}/${folder.path}: done in ${Date.now() - __sfStart}ms (path: QRESYNC)`);
|
|
1203
|
+
return newOnes.length;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
catch (qrErr) {
|
|
1207
|
+
// QRESYNC failed (server quirk, network glitch, etc.) — log
|
|
1208
|
+
// and fall through to the legacy set-diff path. We do NOT
|
|
1209
|
+
// clear the modSeq watermark here; the next attempt will
|
|
1210
|
+
// re-try QRESYNC, and if that also fails enough times the
|
|
1211
|
+
// operator can manually clear `highest_modseq` to force a
|
|
1212
|
+
// full resync.
|
|
1213
|
+
console.warn(` [qresync] ${accountId}/${folder.path}: failed (${qrErr?.message || qrErr}) — falling back to set-diff`);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
// ────────────────────────────────────────────────────────────────
|
|
1122
1217
|
let messages;
|
|
1123
1218
|
// Cache the server-UID list across set-diff and deletion-recon so we
|
|
1124
1219
|
// don't pay for two `UID SEARCH` round-trips (the second one was
|
|
@@ -1315,10 +1410,9 @@ export class ImapManager extends EventEmitter {
|
|
|
1315
1410
|
// UNIQUE constraint to dedupe — if mailx truly has the
|
|
1316
1411
|
// row, the upsert becomes an UPDATE that refreshes
|
|
1317
1412
|
// flags too, all in one path.
|
|
1318
|
-
// Tombstone
|
|
1319
|
-
//
|
|
1320
|
-
//
|
|
1321
|
-
// User-visible symptom: "I deleted it but it came back."
|
|
1413
|
+
// Tombstone-skip: in-flight local delete/move waiting
|
|
1414
|
+
// for server confirmation. Cleared on action success or
|
|
1415
|
+
// permanent failure (see clearTombstoneForUid).
|
|
1322
1416
|
if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
|
|
1323
1417
|
console.log(` [tombstone] ${accountId}: skipping ${msg.messageId} in syncFolder (locally deleted)`);
|
|
1324
1418
|
continue;
|
|
@@ -1484,6 +1578,22 @@ export class ImapManager extends EventEmitter {
|
|
|
1484
1578
|
}
|
|
1485
1579
|
this.emit("folderSynced", accountId, folderId, syncedAt);
|
|
1486
1580
|
this.db.updateLastSync(accountId, syncedAt);
|
|
1581
|
+
// Seed the QRESYNC watermark from the SELECT response of whatever
|
|
1582
|
+
// legacy operation just ran (fetchMessagesSinceUid, getUids, etc.).
|
|
1583
|
+
// Without this, a CONDSTORE/QRESYNC-capable server's `HIGHESTMODSEQ`
|
|
1584
|
+
// is captured by the native layer but never persisted, and the
|
|
1585
|
+
// QRESYNC fast path above stays permanently disabled because
|
|
1586
|
+
// `prevModSeq` is 0. Captures on every sync (cheap) so the watermark
|
|
1587
|
+
// converges to the latest known modSeq.
|
|
1588
|
+
try {
|
|
1589
|
+
if (typeof client.getCurrentMailboxInfo === "function") {
|
|
1590
|
+
const info = client.getCurrentMailboxInfo();
|
|
1591
|
+
if (info && info.highestModSeq && info.uidValidity) {
|
|
1592
|
+
this.db.updateFolderSync(folderId, info.uidValidity, String(info.highestModSeq));
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
catch { /* non-fatal — QRESYNC just stays disabled */ }
|
|
1487
1597
|
return newCount;
|
|
1488
1598
|
}
|
|
1489
1599
|
/** Sync all folders for all accounts */
|
|
@@ -1611,12 +1721,37 @@ export class ImapManager extends EventEmitter {
|
|
|
1611
1721
|
// timeout abandons a stalled command instead of waiting out
|
|
1612
1722
|
// Dovecot's 300s server-side inactivity timer; the next sync tick
|
|
1613
1723
|
// retries on a fresh socket.
|
|
1614
|
-
|
|
1724
|
+
// 2026-05-13: defer first-sync of non-priority folders by default
|
|
1725
|
+
// (C119 lazy folder sync). On bobma with 90+ folders, doing a
|
|
1726
|
+
// first-sync of every folder on every startup hammers the IMAP
|
|
1727
|
+
// socket buffer and pegs the daemon's event loop processing
|
|
1728
|
+
// FETCH literals — user clicks get a 20+ second IPC delay
|
|
1729
|
+
// because no IPC can squeeze in between FETCH chunks. Now: only
|
|
1730
|
+
// special-use folders (Sent/Drafts/Archive/Junk/Trash) + folders
|
|
1731
|
+
// mailx has previously seen (highestUid > 0, i.e. tracked
|
|
1732
|
+
// across the prior session) sync automatically. First-sync of
|
|
1733
|
+
// a "never-touched" non-special folder is deferred until the
|
|
1734
|
+
// user opens that folder (on-demand syncFolder call) or until
|
|
1735
|
+
// ~3 minutes after startup when the event loop is quiet.
|
|
1736
|
+
const STARTUP_LAZY_DELAY_MS = 3 * 60 * 1000;
|
|
1737
|
+
const startupQuietPoint = (this._startupAt || 0) + STARTUP_LAZY_DELAY_MS;
|
|
1738
|
+
const isLazyEligible = (f) => {
|
|
1739
|
+
if (f.specialUse && priorityOrder.includes(f.specialUse))
|
|
1740
|
+
return false;
|
|
1741
|
+
if (this.db.getHighestUid(accountId, f.id) > 0)
|
|
1742
|
+
return false;
|
|
1743
|
+
return true;
|
|
1744
|
+
};
|
|
1745
|
+
const remaining = folders.filter(f => f.specialUse !== "inbox" && !(isLazyEligible(f) && Date.now() < startupQuietPoint));
|
|
1615
1746
|
remaining.sort((a, b) => {
|
|
1616
1747
|
const pa = priorityOrder.indexOf(a.specialUse || "") >= 0 ? priorityOrder.indexOf(a.specialUse || "") : 5;
|
|
1617
1748
|
const pb = priorityOrder.indexOf(b.specialUse || "") >= 0 ? priorityOrder.indexOf(b.specialUse || "") : 5;
|
|
1618
1749
|
return pa - pb;
|
|
1619
1750
|
});
|
|
1751
|
+
const deferredCount = folders.filter(f => f.specialUse !== "inbox" && isLazyEligible(f) && Date.now() < startupQuietPoint).length;
|
|
1752
|
+
if (deferredCount > 0) {
|
|
1753
|
+
console.log(` [sync] ${accountId}: deferring first-sync of ${deferredCount} non-priority folder(s) until ${new Date(startupQuietPoint).toLocaleTimeString()} or user opens them`);
|
|
1754
|
+
}
|
|
1620
1755
|
const CONCURRENCY = 2;
|
|
1621
1756
|
// First-sync of a fresh account on a cold Dovecot is dominated by
|
|
1622
1757
|
// `UID SEARCH SINCE 30-days-ago`, which can take 5+ minutes on a
|
|
@@ -1892,10 +2027,9 @@ export class ImapManager extends EventEmitter {
|
|
|
1892
2027
|
// standalone — bad rows are logged and skipped.
|
|
1893
2028
|
for (const msg of msgs) {
|
|
1894
2029
|
try {
|
|
1895
|
-
// Tombstone
|
|
1896
|
-
//
|
|
1897
|
-
//
|
|
1898
|
-
// promoted the trash yet. Symmetric with the IMAP paths.
|
|
2030
|
+
// Tombstone-skip: in-flight local delete/move on Gmail.
|
|
2031
|
+
// Cleared by clearTombstoneForUid on failSyncAction so a
|
|
2032
|
+
// refused trash doesn't permanently hide the message.
|
|
1899
2033
|
if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
|
|
1900
2034
|
continue;
|
|
1901
2035
|
}
|
|
@@ -2799,6 +2933,14 @@ export class ImapManager extends EventEmitter {
|
|
|
2799
2933
|
if (messages.length === 0)
|
|
2800
2934
|
return;
|
|
2801
2935
|
const trash = this.findFolder(accountId, "trash");
|
|
2936
|
+
// Tombstone each Message-ID so sync won't re-import the source-folder
|
|
2937
|
+
// row before the server-side MOVE completes. Cleared on permanent
|
|
2938
|
+
// failure (clearTombstoneForUid in processSyncActions).
|
|
2939
|
+
for (const msg of messages) {
|
|
2940
|
+
const env = this.db.getMessageByUid(accountId, msg.uid, msg.folderId);
|
|
2941
|
+
if (env?.messageId)
|
|
2942
|
+
this.db.addTombstone(accountId, env.messageId, env.subject || "");
|
|
2943
|
+
}
|
|
2802
2944
|
// Local first — move to trash folder locally so the row stays
|
|
2803
2945
|
// visible in Trash and Ctrl+Z can restore it. Body file stays in
|
|
2804
2946
|
// its original folder dir; the next sync rebinds path on
|
|
@@ -2865,6 +3007,14 @@ export class ImapManager extends EventEmitter {
|
|
|
2865
3007
|
/** Move a message to Trash (delete) — local-first, queues IMAP sync */
|
|
2866
3008
|
async trashMessage(accountId, folderId, uid) {
|
|
2867
3009
|
const trash = this.findFolder(accountId, "trash");
|
|
3010
|
+
// Tombstone the Message-ID so sync won't re-import the row in the
|
|
3011
|
+
// source folder before the server-side move completes. Cleared on
|
|
3012
|
+
// permanent failure of the queued sync_action (see processSyncActions
|
|
3013
|
+
// catch block, where clearTombstoneForUid runs after attempts >= 5)
|
|
3014
|
+
// so the user sees the row reappear when their action didn't take.
|
|
3015
|
+
const env = this.db.getMessageByUid(accountId, uid, folderId);
|
|
3016
|
+
if (env?.messageId)
|
|
3017
|
+
this.db.addTombstone(accountId, env.messageId, env.subject || "");
|
|
2868
3018
|
// Local first — move to trash folder so the row stays visible in
|
|
2869
3019
|
// Trash and Ctrl+Z can restore. Body file retained for undelete.
|
|
2870
3020
|
// If we're already in trash (or no trash configured), fall through
|
|
@@ -3034,8 +3184,15 @@ export class ImapManager extends EventEmitter {
|
|
|
3034
3184
|
catch (e) {
|
|
3035
3185
|
console.error(` [api] ${accountId}: flag sync failed UID ${action.uid}: ${e.message}`);
|
|
3036
3186
|
this.db.failSyncAction(action.id, e.message);
|
|
3037
|
-
if (action.attempts >= 5)
|
|
3187
|
+
if (action.attempts >= 5) {
|
|
3188
|
+
// Terminal failure on delete/move → clear tombstone
|
|
3189
|
+
// so the row reappears on next sync (server still
|
|
3190
|
+
// has it). Same rationale as the IMAP branch below.
|
|
3191
|
+
if (action.action === "delete" || action.action === "move") {
|
|
3192
|
+
this.db.clearTombstoneForUid(accountId, action.uid, action.folderId);
|
|
3193
|
+
}
|
|
3038
3194
|
this.db.completeSyncAction(action.id);
|
|
3195
|
+
}
|
|
3039
3196
|
}
|
|
3040
3197
|
}
|
|
3041
3198
|
}
|
|
@@ -3115,6 +3272,17 @@ export class ImapManager extends EventEmitter {
|
|
|
3115
3272
|
this.emit("syncActionFailed", accountId, action.action, action.uid, e.message);
|
|
3116
3273
|
if (action.attempts >= 5) {
|
|
3117
3274
|
console.error(` [sync] Giving up on action ${action.id} after 5 attempts`);
|
|
3275
|
+
// 2026-05-13: terminal failure on a delete/move
|
|
3276
|
+
// means the server still has the message — so any
|
|
3277
|
+
// tombstone we set for it would otherwise hide it
|
|
3278
|
+
// from the user forever. Clear the tombstone so
|
|
3279
|
+
// the next sync re-imports the row, accurately
|
|
3280
|
+
// reflecting "your action didn't take, here it is
|
|
3281
|
+
// again." Applies to delete + move; flags/append
|
|
3282
|
+
// never tombstone.
|
|
3283
|
+
if (action.action === "delete" || action.action === "move") {
|
|
3284
|
+
this.db.clearTombstoneForUid(accountId, action.uid, action.folderId);
|
|
3285
|
+
}
|
|
3118
3286
|
this.db.completeSyncAction(action.id);
|
|
3119
3287
|
this.emit("syncActionFailed", accountId, action.action, action.uid, `Gave up after 5 attempts: ${e.message}`);
|
|
3120
3288
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-imap",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.44",
|
|
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.11",
|
|
13
13
|
"@bobfrankston/mailx-settings": "^0.1.16",
|
|
14
|
-
"@bobfrankston/mailx-store": "^0.1.
|
|
15
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
14
|
+
"@bobfrankston/mailx-store": "^0.1.24",
|
|
15
|
+
"@bobfrankston/iflow-direct": "^0.1.44",
|
|
16
16
|
"@bobfrankston/tcp-transport": "^0.1.6",
|
|
17
17
|
"@bobfrankston/smtp-direct": "^0.1.8",
|
|
18
18
|
"@bobfrankston/mailx-sync": "^0.1.16",
|
|
@@ -39,8 +39,8 @@
|
|
|
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.
|
|
43
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
42
|
+
"@bobfrankston/mailx-store": "^0.1.24",
|
|
43
|
+
"@bobfrankston/iflow-direct": "^0.1.44",
|
|
44
44
|
"@bobfrankston/tcp-transport": "^0.1.6",
|
|
45
45
|
"@bobfrankston/smtp-direct": "^0.1.8",
|
|
46
46
|
"@bobfrankston/mailx-sync": "^0.1.16",
|