@bobfrankston/rmfmail 1.0.698 → 1.0.700
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/client/app.bundle.js +7 -2
- package/client/app.bundle.js.map +2 -2
- package/client/components/message-list.js +18 -4
- package/client/components/message-list.js.map +1 -1
- package/client/components/message-list.ts +15 -4
- package/package.json +5 -5
- package/packages/mailx-imap/index.d.ts.map +1 -1
- package/packages/mailx-imap/index.js +152 -14
- package/packages/mailx-imap/index.js.map +1 -1
- package/packages/mailx-imap/index.ts +156 -14
- package/packages/mailx-imap/package-lock.json +2 -2
- package/packages/mailx-imap/package.json +1 -1
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +26 -11
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +25 -9
- package/packages/mailx-store/db.d.ts +38 -7
- package/packages/mailx-store/db.d.ts.map +1 -1
- package/packages/mailx-store/db.js +96 -8
- package/packages/mailx-store/db.js.map +1 -1
- package/packages/mailx-store/db.ts +104 -8
- package/packages/mailx-store/package.json +1 -1
|
@@ -899,12 +899,15 @@ export class ImapManager extends EventEmitter {
|
|
|
899
899
|
// upsert turns into an UPDATE (cheap); if it doesn't, the
|
|
900
900
|
// insert proceeds. Trust the constraint — don't second-
|
|
901
901
|
// guess it on a stale highestUid snapshot.
|
|
902
|
-
// Tombstone check:
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
//
|
|
902
|
+
// Tombstone check: skip re-import while a local delete/move
|
|
903
|
+
// is in flight to the server. Cleared on action success
|
|
904
|
+
// (server EXPUNGED → next sync's set-diff drops the row
|
|
905
|
+
// anyway) or on permanent failure (clearTombstoneForUid in
|
|
906
|
+
// failSyncAction → row reappears, user sees the action
|
|
907
|
+
// didn't take). QRESYNC servers bypass this entirely
|
|
908
|
+
// because their VANISHED responses are authoritative.
|
|
906
909
|
if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
|
|
907
|
-
console.log(` [tombstone] ${accountId}: skipping ${msg.messageId} (locally deleted)`);
|
|
910
|
+
console.log(` [tombstone] ${accountId}: skipping ${msg.messageId} (locally deleted, awaiting server confirm)`);
|
|
908
911
|
continue;
|
|
909
912
|
}
|
|
910
913
|
const source = msg.source || "";
|
|
@@ -1119,6 +1122,93 @@ export class ImapManager extends EventEmitter {
|
|
|
1119
1122
|
}
|
|
1120
1123
|
}
|
|
1121
1124
|
console.log(` [sync] ${accountId}/${folder.path}: highestUid=${highestUid}, fetching...`);
|
|
1125
|
+
// ── QRESYNC fast path (RFC 7162) ─────────────────────────────────
|
|
1126
|
+
// When the server supports QRESYNC AND we have a saved (uidValidity,
|
|
1127
|
+
// modSeq) watermark for this folder, ask the server "what changed
|
|
1128
|
+
// since modSeq?". The server replies with an authoritative VANISHED
|
|
1129
|
+
// list (no client-side diffing needed, no tombstones needed) plus
|
|
1130
|
+
// unsolicited FETCH for flag/state changes. New UIDs > our prior
|
|
1131
|
+
// highest still go through the normal fetchMessagesSinceUid path
|
|
1132
|
+
// afterwards. Skips the heavy UID SEARCH set-diff below entirely.
|
|
1133
|
+
const folderSyncState = this.db.getFolderSync(folderId);
|
|
1134
|
+
const prevModSeq = parseInt(folderSyncState.highestModseq || "0", 10);
|
|
1135
|
+
const prevUidValidity = folderSyncState.uidvalidity || 0;
|
|
1136
|
+
const caps = (typeof client.getCapabilities === "function")
|
|
1137
|
+
? client.getCapabilities()
|
|
1138
|
+
: new Set();
|
|
1139
|
+
const qresyncSupported = caps.has("QRESYNC");
|
|
1140
|
+
const haveWatermark = highestUid > 0 && prevModSeq > 0 && prevUidValidity > 0;
|
|
1141
|
+
if (qresyncSupported && haveWatermark && typeof client.resyncFolder === "function") {
|
|
1142
|
+
try {
|
|
1143
|
+
// Ensure ENABLE QRESYNC has been issued on this connection.
|
|
1144
|
+
// enableQresync is idempotent + capability-gated so this is a
|
|
1145
|
+
// no-op on the second-and-subsequent call per connection.
|
|
1146
|
+
if (typeof client.enableQresync === "function") {
|
|
1147
|
+
await client.enableQresync();
|
|
1148
|
+
}
|
|
1149
|
+
const __qrT0 = Date.now();
|
|
1150
|
+
const qr = await client.resyncFolder(folder.path, prevUidValidity, prevModSeq);
|
|
1151
|
+
console.log(` [qresync] ${accountId}/${folder.path}: vanished=${qr.vanishedUids.length} changed=${qr.changedMessages.length} newModSeq=${qr.newHighestModSeq} in ${Date.now() - __qrT0}ms`);
|
|
1152
|
+
if (qr.uidValidityChanged) {
|
|
1153
|
+
// UIDVALIDITY rolled — our local UIDs are stale. Fall through
|
|
1154
|
+
// to the full set-diff path; that'll discover the new state
|
|
1155
|
+
// and the deletion safeguards (50% threshold) will keep us
|
|
1156
|
+
// from wiping anything we shouldn't.
|
|
1157
|
+
console.log(` [qresync] ${accountId}/${folder.path}: UIDVALIDITY changed (was ${prevUidValidity}, now ${qr.exists}); falling back to full sync`);
|
|
1158
|
+
}
|
|
1159
|
+
else {
|
|
1160
|
+
// Apply VANISHED — server says these UIDs are gone. No
|
|
1161
|
+
// tombstone, no diff, just delete the local rows.
|
|
1162
|
+
let vanishedApplied = 0;
|
|
1163
|
+
for (const uid of qr.vanishedUids) {
|
|
1164
|
+
const env = this.db.getMessageByUid(accountId, uid, folderId);
|
|
1165
|
+
if (env) {
|
|
1166
|
+
this.db.deleteMessage(accountId, uid, "server VANISHED via QRESYNC", "mailx-imap syncFolder/qresync");
|
|
1167
|
+
vanishedApplied++;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
if (vanishedApplied > 0) {
|
|
1171
|
+
this.db.recalcFolderCounts(folderId);
|
|
1172
|
+
this.emit("folderCountsChanged", accountId, {});
|
|
1173
|
+
}
|
|
1174
|
+
// Apply changed-state FETCH — flag updates since prior modSeq.
|
|
1175
|
+
for (const m of qr.changedMessages) {
|
|
1176
|
+
try {
|
|
1177
|
+
const flagsArr = Array.from(m.flags || []).map(f => String(f));
|
|
1178
|
+
this.db.updateMessageFlags(accountId, m.uid, flagsArr);
|
|
1179
|
+
}
|
|
1180
|
+
catch { /* row may have just been VANISHED */ }
|
|
1181
|
+
}
|
|
1182
|
+
// Fetch genuinely new messages — UID > our prior highest.
|
|
1183
|
+
// resyncFolder doesn't return these directly; the server
|
|
1184
|
+
// emits them only via state-change FETCH if their flags
|
|
1185
|
+
// changed since modSeq, which is unreliable. Pull them
|
|
1186
|
+
// explicitly via the existing incremental path.
|
|
1187
|
+
const fetched = await client.fetchMessagesSinceUid(folder.path, highestUid, { source: false });
|
|
1188
|
+
const newOnes = fetched.filter((m) => m.uid > highestUid);
|
|
1189
|
+
if (newOnes.length > 0) {
|
|
1190
|
+
await this.storeMessages(accountId, folderId, folder, newOnes, highestUid);
|
|
1191
|
+
}
|
|
1192
|
+
// Persist new watermark — next resync starts here.
|
|
1193
|
+
if (qr.newHighestModSeq !== undefined) {
|
|
1194
|
+
this.db.updateFolderSync(folderId, qr.exists ? prevUidValidity : prevUidValidity, String(qr.newHighestModSeq));
|
|
1195
|
+
}
|
|
1196
|
+
this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
|
|
1197
|
+
console.log(` [qresync] ${accountId}/${folder.path}: done in ${Date.now() - __sfStart}ms (path: QRESYNC)`);
|
|
1198
|
+
return newOnes.length;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
catch (qrErr) {
|
|
1202
|
+
// QRESYNC failed (server quirk, network glitch, etc.) — log
|
|
1203
|
+
// and fall through to the legacy set-diff path. We do NOT
|
|
1204
|
+
// clear the modSeq watermark here; the next attempt will
|
|
1205
|
+
// re-try QRESYNC, and if that also fails enough times the
|
|
1206
|
+
// operator can manually clear `highest_modseq` to force a
|
|
1207
|
+
// full resync.
|
|
1208
|
+
console.warn(` [qresync] ${accountId}/${folder.path}: failed (${qrErr?.message || qrErr}) — falling back to set-diff`);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
// ────────────────────────────────────────────────────────────────
|
|
1122
1212
|
let messages;
|
|
1123
1213
|
// Cache the server-UID list across set-diff and deletion-recon so we
|
|
1124
1214
|
// don't pay for two `UID SEARCH` round-trips (the second one was
|
|
@@ -1315,10 +1405,9 @@ export class ImapManager extends EventEmitter {
|
|
|
1315
1405
|
// UNIQUE constraint to dedupe — if mailx truly has the
|
|
1316
1406
|
// row, the upsert becomes an UPDATE that refreshes
|
|
1317
1407
|
// flags too, all in one path.
|
|
1318
|
-
// Tombstone
|
|
1319
|
-
//
|
|
1320
|
-
//
|
|
1321
|
-
// User-visible symptom: "I deleted it but it came back."
|
|
1408
|
+
// Tombstone-skip: in-flight local delete/move waiting
|
|
1409
|
+
// for server confirmation. Cleared on action success or
|
|
1410
|
+
// permanent failure (see clearTombstoneForUid).
|
|
1322
1411
|
if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
|
|
1323
1412
|
console.log(` [tombstone] ${accountId}: skipping ${msg.messageId} in syncFolder (locally deleted)`);
|
|
1324
1413
|
continue;
|
|
@@ -1484,6 +1573,22 @@ export class ImapManager extends EventEmitter {
|
|
|
1484
1573
|
}
|
|
1485
1574
|
this.emit("folderSynced", accountId, folderId, syncedAt);
|
|
1486
1575
|
this.db.updateLastSync(accountId, syncedAt);
|
|
1576
|
+
// Seed the QRESYNC watermark from the SELECT response of whatever
|
|
1577
|
+
// legacy operation just ran (fetchMessagesSinceUid, getUids, etc.).
|
|
1578
|
+
// Without this, a CONDSTORE/QRESYNC-capable server's `HIGHESTMODSEQ`
|
|
1579
|
+
// is captured by the native layer but never persisted, and the
|
|
1580
|
+
// QRESYNC fast path above stays permanently disabled because
|
|
1581
|
+
// `prevModSeq` is 0. Captures on every sync (cheap) so the watermark
|
|
1582
|
+
// converges to the latest known modSeq.
|
|
1583
|
+
try {
|
|
1584
|
+
if (typeof client.getCurrentMailboxInfo === "function") {
|
|
1585
|
+
const info = client.getCurrentMailboxInfo();
|
|
1586
|
+
if (info && info.highestModSeq && info.uidValidity) {
|
|
1587
|
+
this.db.updateFolderSync(folderId, info.uidValidity, String(info.highestModSeq));
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
catch { /* non-fatal — QRESYNC just stays disabled */ }
|
|
1487
1592
|
return newCount;
|
|
1488
1593
|
}
|
|
1489
1594
|
/** Sync all folders for all accounts */
|
|
@@ -1892,10 +1997,9 @@ export class ImapManager extends EventEmitter {
|
|
|
1892
1997
|
// standalone — bad rows are logged and skipped.
|
|
1893
1998
|
for (const msg of msgs) {
|
|
1894
1999
|
try {
|
|
1895
|
-
// Tombstone
|
|
1896
|
-
//
|
|
1897
|
-
//
|
|
1898
|
-
// promoted the trash yet. Symmetric with the IMAP paths.
|
|
2000
|
+
// Tombstone-skip: in-flight local delete/move on Gmail.
|
|
2001
|
+
// Cleared by clearTombstoneForUid on failSyncAction so a
|
|
2002
|
+
// refused trash doesn't permanently hide the message.
|
|
1899
2003
|
if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
|
|
1900
2004
|
continue;
|
|
1901
2005
|
}
|
|
@@ -2799,6 +2903,14 @@ export class ImapManager extends EventEmitter {
|
|
|
2799
2903
|
if (messages.length === 0)
|
|
2800
2904
|
return;
|
|
2801
2905
|
const trash = this.findFolder(accountId, "trash");
|
|
2906
|
+
// Tombstone each Message-ID so sync won't re-import the source-folder
|
|
2907
|
+
// row before the server-side MOVE completes. Cleared on permanent
|
|
2908
|
+
// failure (clearTombstoneForUid in processSyncActions).
|
|
2909
|
+
for (const msg of messages) {
|
|
2910
|
+
const env = this.db.getMessageByUid(accountId, msg.uid, msg.folderId);
|
|
2911
|
+
if (env?.messageId)
|
|
2912
|
+
this.db.addTombstone(accountId, env.messageId, env.subject || "");
|
|
2913
|
+
}
|
|
2802
2914
|
// Local first — move to trash folder locally so the row stays
|
|
2803
2915
|
// visible in Trash and Ctrl+Z can restore it. Body file stays in
|
|
2804
2916
|
// its original folder dir; the next sync rebinds path on
|
|
@@ -2865,6 +2977,14 @@ export class ImapManager extends EventEmitter {
|
|
|
2865
2977
|
/** Move a message to Trash (delete) — local-first, queues IMAP sync */
|
|
2866
2978
|
async trashMessage(accountId, folderId, uid) {
|
|
2867
2979
|
const trash = this.findFolder(accountId, "trash");
|
|
2980
|
+
// Tombstone the Message-ID so sync won't re-import the row in the
|
|
2981
|
+
// source folder before the server-side move completes. Cleared on
|
|
2982
|
+
// permanent failure of the queued sync_action (see processSyncActions
|
|
2983
|
+
// catch block, where clearTombstoneForUid runs after attempts >= 5)
|
|
2984
|
+
// so the user sees the row reappear when their action didn't take.
|
|
2985
|
+
const env = this.db.getMessageByUid(accountId, uid, folderId);
|
|
2986
|
+
if (env?.messageId)
|
|
2987
|
+
this.db.addTombstone(accountId, env.messageId, env.subject || "");
|
|
2868
2988
|
// Local first — move to trash folder so the row stays visible in
|
|
2869
2989
|
// Trash and Ctrl+Z can restore. Body file retained for undelete.
|
|
2870
2990
|
// If we're already in trash (or no trash configured), fall through
|
|
@@ -3034,8 +3154,15 @@ export class ImapManager extends EventEmitter {
|
|
|
3034
3154
|
catch (e) {
|
|
3035
3155
|
console.error(` [api] ${accountId}: flag sync failed UID ${action.uid}: ${e.message}`);
|
|
3036
3156
|
this.db.failSyncAction(action.id, e.message);
|
|
3037
|
-
if (action.attempts >= 5)
|
|
3157
|
+
if (action.attempts >= 5) {
|
|
3158
|
+
// Terminal failure on delete/move → clear tombstone
|
|
3159
|
+
// so the row reappears on next sync (server still
|
|
3160
|
+
// has it). Same rationale as the IMAP branch below.
|
|
3161
|
+
if (action.action === "delete" || action.action === "move") {
|
|
3162
|
+
this.db.clearTombstoneForUid(accountId, action.uid, action.folderId);
|
|
3163
|
+
}
|
|
3038
3164
|
this.db.completeSyncAction(action.id);
|
|
3165
|
+
}
|
|
3039
3166
|
}
|
|
3040
3167
|
}
|
|
3041
3168
|
}
|
|
@@ -3115,6 +3242,17 @@ export class ImapManager extends EventEmitter {
|
|
|
3115
3242
|
this.emit("syncActionFailed", accountId, action.action, action.uid, e.message);
|
|
3116
3243
|
if (action.attempts >= 5) {
|
|
3117
3244
|
console.error(` [sync] Giving up on action ${action.id} after 5 attempts`);
|
|
3245
|
+
// 2026-05-13: terminal failure on a delete/move
|
|
3246
|
+
// means the server still has the message — so any
|
|
3247
|
+
// tombstone we set for it would otherwise hide it
|
|
3248
|
+
// from the user forever. Clear the tombstone so
|
|
3249
|
+
// the next sync re-imports the row, accurately
|
|
3250
|
+
// reflecting "your action didn't take, here it is
|
|
3251
|
+
// again." Applies to delete + move; flags/append
|
|
3252
|
+
// never tombstone.
|
|
3253
|
+
if (action.action === "delete" || action.action === "move") {
|
|
3254
|
+
this.db.clearTombstoneForUid(accountId, action.uid, action.folderId);
|
|
3255
|
+
}
|
|
3118
3256
|
this.db.completeSyncAction(action.id);
|
|
3119
3257
|
this.emit("syncActionFailed", accountId, action.action, action.uid, `Gave up after 5 attempts: ${e.message}`);
|
|
3120
3258
|
}
|