@bobfrankston/mailx 1.0.360 → 1.0.361
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/package.json
CHANGED
|
@@ -689,6 +689,14 @@ export class ImapManager extends EventEmitter {
|
|
|
689
689
|
}
|
|
690
690
|
if (msg.uid <= highestUid)
|
|
691
691
|
continue; // already have it
|
|
692
|
+
// Tombstone check: if the user locally deleted this Message-ID,
|
|
693
|
+
// don't re-import it. Server-side EXPUNGE may lag, or reconcile
|
|
694
|
+
// may find the message in an old list snapshot. Without this,
|
|
695
|
+
// deleted messages reappear on the next sync pass.
|
|
696
|
+
if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
|
|
697
|
+
console.log(` [tombstone] ${accountId}: skipping ${msg.messageId} (locally deleted)`);
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
692
700
|
const source = msg.source || "";
|
|
693
701
|
let bodyPath = "";
|
|
694
702
|
let preview = "";
|
|
@@ -1581,6 +1589,17 @@ export class ImapManager extends EventEmitter {
|
|
|
1581
1589
|
this.syncIntervals.set("prefetch", prefetchInterval);
|
|
1582
1590
|
console.log(` [periodic] body prefetch every 60s (independent of sync)`);
|
|
1583
1591
|
}
|
|
1592
|
+
// Tombstone prune: age out local-delete records older than 30 days.
|
|
1593
|
+
// Runs hourly — cheap (one indexed DELETE).
|
|
1594
|
+
const TOMBSTONE_RETENTION_DAYS = 30;
|
|
1595
|
+
const pruneTombstones = () => {
|
|
1596
|
+
const cutoff = Date.now() - TOMBSTONE_RETENTION_DAYS * 86400_000;
|
|
1597
|
+
const n = this.db.pruneTombstones(cutoff);
|
|
1598
|
+
if (n > 0)
|
|
1599
|
+
console.log(` [tombstones] pruned ${n} older than ${TOMBSTONE_RETENTION_DAYS} days`);
|
|
1600
|
+
};
|
|
1601
|
+
setTimeout(pruneTombstones, 30_000); // first run after startup settles
|
|
1602
|
+
this.syncIntervals.set("tombstone-prune", setInterval(pruneTombstones, 3600_000));
|
|
1584
1603
|
// Full sync (all folders + IDLE restart) at configured interval
|
|
1585
1604
|
const fullInterval = setInterval(async () => {
|
|
1586
1605
|
console.log(` [periodic] Full sync at ${new Date().toLocaleTimeString()}`);
|
|
@@ -646,6 +646,11 @@ export class MailxService {
|
|
|
646
646
|
const envelope = this.db.getMessageByUid(accountId, uid);
|
|
647
647
|
if (!envelope)
|
|
648
648
|
throw new Error("Message not found");
|
|
649
|
+
// Tombstone the Message-ID so a subsequent sync pass can't resurrect
|
|
650
|
+
// the row if the server's EXPUNGE hasn't propagated yet. `undelete`
|
|
651
|
+
// removes the tombstone.
|
|
652
|
+
if (envelope.messageId)
|
|
653
|
+
this.db.addTombstone(accountId, envelope.messageId, envelope.subject || "");
|
|
649
654
|
await this.imapManager.trashMessage(accountId, envelope.folderId, envelope.uid);
|
|
650
655
|
}
|
|
651
656
|
async deleteMessages(accountId, uids) {
|
|
@@ -653,6 +658,9 @@ export class MailxService {
|
|
|
653
658
|
const env = this.db.getMessageByUid(accountId, uid);
|
|
654
659
|
if (!env)
|
|
655
660
|
return null;
|
|
661
|
+
// Tombstone each — same reason as single-delete above.
|
|
662
|
+
if (env.messageId)
|
|
663
|
+
this.db.addTombstone(accountId, env.messageId, env.subject || "");
|
|
656
664
|
return { uid: env.uid, folderId: env.folderId };
|
|
657
665
|
}).filter(m => m !== null);
|
|
658
666
|
await this.imapManager.trashMessages(accountId, messages);
|
|
@@ -711,6 +719,12 @@ export class MailxService {
|
|
|
711
719
|
return { targetFolderId: target.id, moved: uids.length };
|
|
712
720
|
}
|
|
713
721
|
async undeleteMessage(accountId, uid, folderId) {
|
|
722
|
+
// Clear the tombstone first so a subsequent sync can re-import if
|
|
723
|
+
// the server still has the row. Messages with no Message-ID just
|
|
724
|
+
// didn't get a tombstone — this is a no-op for them.
|
|
725
|
+
const envelope = this.db.getMessageByUid(accountId, uid, folderId);
|
|
726
|
+
if (envelope?.messageId)
|
|
727
|
+
this.db.removeTombstone(accountId, envelope.messageId);
|
|
714
728
|
await this.imapManager.undeleteMessage(accountId, uid, folderId);
|
|
715
729
|
}
|
|
716
730
|
async deleteOnServer(accountId, folderPath, uid) {
|
|
@@ -12,6 +12,20 @@ export declare class MailxDB {
|
|
|
12
12
|
hasSentMessage(messageId: string): boolean;
|
|
13
13
|
/** Record a successfully sent message so future attempts are skipped. */
|
|
14
14
|
recordSent(messageId: string, accountId: string, subject: string, recipients: string[]): void;
|
|
15
|
+
/** Mark a Message-ID as locally-deleted for an account. No-op if messageId
|
|
16
|
+
* is empty (e.g. provider stripped the header) — without a stable id we
|
|
17
|
+
* can't check against future sync results anyway. */
|
|
18
|
+
addTombstone(accountId: string, messageId: string, subject?: string): void;
|
|
19
|
+
/** Is this Message-ID tombstoned for this account? */
|
|
20
|
+
hasTombstone(accountId: string, messageId: string): boolean;
|
|
21
|
+
/** Remove a tombstone — used by "undelete" (Ctrl-Z) so a subsequent sync
|
|
22
|
+
* re-imports the message as normal. Also lets the user recover from a
|
|
23
|
+
* mistaken local delete. */
|
|
24
|
+
removeTombstone(accountId: string, messageId: string): void;
|
|
25
|
+
/** Age-out tombstones older than the given cutoff. Keeps the table from
|
|
26
|
+
* growing unboundedly. Default retention is 30 days; caller passes the
|
|
27
|
+
* actual cutoff in ms since epoch. */
|
|
28
|
+
pruneTombstones(olderThanMs: number): number;
|
|
15
29
|
/** Idempotently add a column to a table if it's missing. */
|
|
16
30
|
private addColumnIfMissing;
|
|
17
31
|
/** Compute a thread id for an incoming message. Strategy:
|
|
@@ -126,6 +126,21 @@ const SCHEMA = `
|
|
|
126
126
|
last_error TEXT,
|
|
127
127
|
UNIQUE(account_id, action, uid, folder_id)
|
|
128
128
|
);
|
|
129
|
+
|
|
130
|
+
-- Tombstones: messages the user deleted locally. Sync checks this table
|
|
131
|
+
-- before inserting a new row so a server-side delete that hasn't yet
|
|
132
|
+
-- propagated (or a stale server listing during the EXPUNGE race) can't
|
|
133
|
+
-- resurrect a message the user already removed. Keyed by Message-ID
|
|
134
|
+
-- because that's the only identifier stable across UID renumbers,
|
|
135
|
+
-- UIDVALIDITY bumps, and cross-folder moves.
|
|
136
|
+
CREATE TABLE IF NOT EXISTS tombstones (
|
|
137
|
+
account_id TEXT NOT NULL,
|
|
138
|
+
message_id TEXT NOT NULL,
|
|
139
|
+
deleted_at INTEGER NOT NULL,
|
|
140
|
+
subject TEXT DEFAULT '',
|
|
141
|
+
PRIMARY KEY (account_id, message_id)
|
|
142
|
+
);
|
|
143
|
+
CREATE INDEX IF NOT EXISTS idx_tombstones_deleted_at ON tombstones(deleted_at);
|
|
129
144
|
`;
|
|
130
145
|
export class MailxDB {
|
|
131
146
|
db;
|
|
@@ -172,6 +187,53 @@ export class MailxDB {
|
|
|
172
187
|
console.error(` [sent_log] failed to record ${messageId}: ${e.message}`);
|
|
173
188
|
}
|
|
174
189
|
}
|
|
190
|
+
// ── Tombstones (local-delete record so server echo can't resurrect) ──
|
|
191
|
+
/** Mark a Message-ID as locally-deleted for an account. No-op if messageId
|
|
192
|
+
* is empty (e.g. provider stripped the header) — without a stable id we
|
|
193
|
+
* can't check against future sync results anyway. */
|
|
194
|
+
addTombstone(accountId, messageId, subject = "") {
|
|
195
|
+
if (!messageId)
|
|
196
|
+
return;
|
|
197
|
+
try {
|
|
198
|
+
this.db.prepare("INSERT INTO tombstones (account_id, message_id, deleted_at, subject) VALUES (?, ?, ?, ?) ON CONFLICT(account_id, message_id) DO UPDATE SET deleted_at = excluded.deleted_at").run(accountId, messageId, Date.now(), subject || "");
|
|
199
|
+
}
|
|
200
|
+
catch (e) {
|
|
201
|
+
console.error(` [tombstones] failed to record ${messageId}: ${e.message}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/** Is this Message-ID tombstoned for this account? */
|
|
205
|
+
hasTombstone(accountId, messageId) {
|
|
206
|
+
if (!messageId)
|
|
207
|
+
return false;
|
|
208
|
+
const row = this.db.prepare("SELECT 1 FROM tombstones WHERE account_id = ? AND message_id = ? LIMIT 1").get(accountId, messageId);
|
|
209
|
+
return !!row;
|
|
210
|
+
}
|
|
211
|
+
/** Remove a tombstone — used by "undelete" (Ctrl-Z) so a subsequent sync
|
|
212
|
+
* re-imports the message as normal. Also lets the user recover from a
|
|
213
|
+
* mistaken local delete. */
|
|
214
|
+
removeTombstone(accountId, messageId) {
|
|
215
|
+
if (!messageId)
|
|
216
|
+
return;
|
|
217
|
+
try {
|
|
218
|
+
this.db.prepare("DELETE FROM tombstones WHERE account_id = ? AND message_id = ?").run(accountId, messageId);
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
console.error(` [tombstones] failed to remove ${messageId}: ${e.message}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** Age-out tombstones older than the given cutoff. Keeps the table from
|
|
225
|
+
* growing unboundedly. Default retention is 30 days; caller passes the
|
|
226
|
+
* actual cutoff in ms since epoch. */
|
|
227
|
+
pruneTombstones(olderThanMs) {
|
|
228
|
+
try {
|
|
229
|
+
const res = this.db.prepare("DELETE FROM tombstones WHERE deleted_at < ?").run(olderThanMs);
|
|
230
|
+
return Number(res.changes || 0);
|
|
231
|
+
}
|
|
232
|
+
catch (e) {
|
|
233
|
+
console.error(` [tombstones] prune failed: ${e.message}`);
|
|
234
|
+
return 0;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
175
237
|
/** Idempotently add a column to a table if it's missing. */
|
|
176
238
|
addColumnIfMissing(table, column, sqlType) {
|
|
177
239
|
try {
|