@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.360",
3
+ "version": "1.0.361",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -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 {