@bobfrankston/mailx-store 0.1.22 → 0.1.24

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/db.d.ts CHANGED
@@ -53,15 +53,46 @@ export declare class MailxDB {
53
53
  * user's own Sent copy, so it's still a reliable signal that this user
54
54
  * habitually Bccs when writing to this recipient. */
55
55
  hasBccHistoryTo(recipientEmail: string): boolean;
56
- /** Mark a Message-ID as locally-deleted for an account. No-op if messageId
57
- * is empty (e.g. provider stripped the header) without a stable id we
58
- * can't check against future sync results anyway. */
56
+ /** Mark a local row as "delete pending server confirmation". The row
57
+ * stays visible (UI greys / strikes it through) until the server
58
+ * confirms via QRESYNC VANISHED or its UID drops out of the set-diff. */
59
+ setMessagePendingDelete(accountId: string, uid: number, folderId: number): void;
60
+ /** Clear the pending-delete flag — used when the IMAP MOVE/EXPUNGE is
61
+ * refused by the server and the row should revert to a normal visible
62
+ * state, and by Ctrl-Z undelete. */
63
+ clearMessagePendingDelete(accountId: string, uid: number, folderId: number): void;
64
+ /** Query: is this (folder, uid) pending server-side delete? */
65
+ isMessagePendingDelete(accountId: string, uid: number, folderId: number): boolean;
66
+ /** @deprecated — superseded by setMessagePendingDelete. Retained as a
67
+ * no-op so legacy delete paths still compile; the table is preserved
68
+ * but never read by the sync path anymore. */
59
69
  addTombstone(accountId: string, messageId: string, subject?: string): void;
60
- /** Is this Message-ID tombstoned for this account? */
70
+ /** Is this Message-ID tombstoned for this account? Tombstones suppress
71
+ * re-import of a row the user just deleted/moved while the server-side
72
+ * IMAP action is still in flight; without this, the moved-out source
73
+ * folder would re-insert the message on the next sync (server still
74
+ * sees the UID; mailx has no other way to know "I just moved this").
75
+ *
76
+ * Lifetime: created at the local-delete/move moment, removed on
77
+ * successful IMAP action complete OR on PERMANENT IMAP action failure.
78
+ * The failure-clear is the 2026-05-13 fix for the "tombstones outlive
79
+ * failed deletes" bug — `failSyncAction` calls `clearTombstoneForUid`
80
+ * after the final retry attempt so the user sees the row reappear on
81
+ * the next sync (server still has it, mailx now agrees). QRESYNC
82
+ * servers don't need this fallback because VANISHED is authoritative;
83
+ * tombstones are no-ops on the QRESYNC fast path. */
61
84
  hasTombstone(accountId: string, messageId: string): boolean;
62
- /** Remove a tombstone used by "undelete" (Ctrl-Z) so a subsequent sync
63
- * re-imports the message as normal. Also lets the user recover from a
64
- * mistaken local delete. */
85
+ /** Clear the tombstone for the message currently at (accountId, uid,
86
+ * folderId). Used by the sync-action failure path: when an IMAP MOVE
87
+ * or EXPUNGE fails terminally, the row is still on the server and the
88
+ * user should see it again — so the suppression must lift. The lookup
89
+ * is by (accountId, uid, folderId) → message_id, since the action
90
+ * table stores UID not Message-ID. No-op if the row no longer exists
91
+ * (concurrent server-side EXPUNGE etc.). */
92
+ clearTombstoneForUid(accountId: string, uid: number, folderId: number): void;
93
+ /** @deprecated — use clearMessagePendingDelete instead. Retained as a
94
+ * no-op for legacy callers (Ctrl-Z undelete paths still work because
95
+ * their sibling clearMessagePendingDelete is called). */
65
96
  removeTombstone(accountId: string, messageId: string): void;
66
97
  /** Age-out tombstones older than the given cutoff. Keeps the table from
67
98
  * growing unboundedly. Default retention is 30 days; caller passes the
package/db.js CHANGED
@@ -357,6 +357,14 @@ const SCHEMA = `
357
357
  folder_id INTEGER NOT NULL,
358
358
  uid INTEGER NOT NULL,
359
359
  last_seen_at INTEGER NOT NULL,
360
+ -- 0 = normal, 1 = user initiated delete, server-sync in flight.
361
+ -- Replaces the old tombstones table: rather than hide a row server
362
+ -- still has, we keep it visible with this flag and let the UI render
363
+ -- it as a struck-through "in flight" row. On server-confirmed
364
+ -- deletion (QRESYNC VANISHED, or absence from a set-diff with the
365
+ -- 50% safety guard) the row is dropped. On server-refused deletion
366
+ -- the flag is cleared and the row reverts to normal.
367
+ pending_delete INTEGER DEFAULT 0,
360
368
  UNIQUE(folder_id, uid)
361
369
  );
362
370
  CREATE INDEX IF NOT EXISTS idx_message_folders_msg ON message_folders(message_row_id);
@@ -420,6 +428,9 @@ export class MailxDB {
420
428
  // calendar's defaultReminders pre-resolved server-side and pulled
421
429
  // through here too.
422
430
  this.addColumnIfMissing("calendar_events", "reminder_minutes_json", "TEXT DEFAULT '[]'");
431
+ // pending_delete on message_folders replaces the old tombstones table.
432
+ // See the table comment for semantics — visible in-flight delete state.
433
+ this.addColumnIfMissing("message_folders", "pending_delete", "INTEGER DEFAULT 0");
423
434
  // Backfill UUIDs for any pre-existing rows that were inserted before
424
435
  // this column landed. One UPDATE + an id roundtrip per row — cheap
425
436
  // at our row counts, runs once per DB upgrade.
@@ -692,10 +703,61 @@ export class MailxDB {
692
703
  return false;
693
704
  }
694
705
  }
695
- // ── Tombstones (local-delete record so server echo can't resurrect) ──
696
- /** Mark a Message-ID as locally-deleted for an account. No-op if messageId
697
- * is empty (e.g. provider stripped the header) without a stable id we
698
- * can't check against future sync results anyway. */
706
+ // ── Pending-delete state (replaces tombstones) ──
707
+ //
708
+ // Tombstones were a client-side invention that hid messages the server
709
+ // still considered present. With QRESYNC (RFC 7162) the server is
710
+ // authoritative — VANISHED tells us definitively what's deleted, so the
711
+ // tombstone hack is obsolete. For the non-QRESYNC case, the natural
712
+ // upsert-by-(folder, uid) behaviour of the sync path is already correct:
713
+ // a row pending deletion just has `pending_delete = 1`; the sync upserts
714
+ // refresh `last_seen_at` but don't reset the flag.
715
+ //
716
+ // The old tombstone API stubs below remain temporarily so legacy callers
717
+ // compile; they delegate to pending_delete by (folder, uid) when possible
718
+ // and no-op otherwise. Plan is to remove the stubs once every caller has
719
+ // been migrated to the (uid, folderId) form.
720
+ /** Mark a local row as "delete pending server confirmation". The row
721
+ * stays visible (UI greys / strikes it through) until the server
722
+ * confirms via QRESYNC VANISHED or its UID drops out of the set-diff. */
723
+ setMessagePendingDelete(accountId, uid, folderId) {
724
+ try {
725
+ this.db.prepare(`UPDATE message_folders SET pending_delete = 1
726
+ WHERE folder_id = ? AND uid = ?
727
+ AND message_row_id IN (SELECT id FROM messages WHERE account_id = ?)`).run(folderId, uid, accountId);
728
+ }
729
+ catch (e) {
730
+ console.error(` [pending-delete] failed to mark ${accountId}/${folderId}/${uid}: ${e.message}`);
731
+ }
732
+ }
733
+ /** Clear the pending-delete flag — used when the IMAP MOVE/EXPUNGE is
734
+ * refused by the server and the row should revert to a normal visible
735
+ * state, and by Ctrl-Z undelete. */
736
+ clearMessagePendingDelete(accountId, uid, folderId) {
737
+ try {
738
+ this.db.prepare(`UPDATE message_folders SET pending_delete = 0
739
+ WHERE folder_id = ? AND uid = ?
740
+ AND message_row_id IN (SELECT id FROM messages WHERE account_id = ?)`).run(folderId, uid, accountId);
741
+ }
742
+ catch (e) {
743
+ console.error(` [pending-delete] failed to clear ${accountId}/${folderId}/${uid}: ${e.message}`);
744
+ }
745
+ }
746
+ /** Query: is this (folder, uid) pending server-side delete? */
747
+ isMessagePendingDelete(accountId, uid, folderId) {
748
+ const row = this.db.prepare(`SELECT mf.pending_delete
749
+ FROM message_folders mf
750
+ JOIN messages m ON m.id = mf.message_row_id
751
+ WHERE mf.folder_id = ? AND mf.uid = ? AND m.account_id = ?
752
+ LIMIT 1`).get(folderId, uid, accountId);
753
+ return !!row && row.pending_delete === 1;
754
+ }
755
+ // ── Legacy tombstone API — stubs for backwards compatibility ────────────
756
+ // TODO(tombstone-removal): once all call sites use the (uid, folderId)
757
+ // pending_delete API, delete these stubs and drop the tombstones table.
758
+ /** @deprecated — superseded by setMessagePendingDelete. Retained as a
759
+ * no-op so legacy delete paths still compile; the table is preserved
760
+ * but never read by the sync path anymore. */
699
761
  addTombstone(accountId, messageId, subject = "") {
700
762
  if (!messageId)
701
763
  return;
@@ -706,16 +768,42 @@ export class MailxDB {
706
768
  console.error(` [tombstones] failed to record ${messageId}: ${e.message}`);
707
769
  }
708
770
  }
709
- /** Is this Message-ID tombstoned for this account? */
771
+ /** Is this Message-ID tombstoned for this account? Tombstones suppress
772
+ * re-import of a row the user just deleted/moved while the server-side
773
+ * IMAP action is still in flight; without this, the moved-out source
774
+ * folder would re-insert the message on the next sync (server still
775
+ * sees the UID; mailx has no other way to know "I just moved this").
776
+ *
777
+ * Lifetime: created at the local-delete/move moment, removed on
778
+ * successful IMAP action complete OR on PERMANENT IMAP action failure.
779
+ * The failure-clear is the 2026-05-13 fix for the "tombstones outlive
780
+ * failed deletes" bug — `failSyncAction` calls `clearTombstoneForUid`
781
+ * after the final retry attempt so the user sees the row reappear on
782
+ * the next sync (server still has it, mailx now agrees). QRESYNC
783
+ * servers don't need this fallback because VANISHED is authoritative;
784
+ * tombstones are no-ops on the QRESYNC fast path. */
710
785
  hasTombstone(accountId, messageId) {
711
786
  if (!messageId)
712
787
  return false;
713
788
  const row = this.db.prepare("SELECT 1 FROM tombstones WHERE account_id = ? AND message_id = ? LIMIT 1").get(accountId, messageId);
714
789
  return !!row;
715
790
  }
716
- /** Remove a tombstone used by "undelete" (Ctrl-Z) so a subsequent sync
717
- * re-imports the message as normal. Also lets the user recover from a
718
- * mistaken local delete. */
791
+ /** Clear the tombstone for the message currently at (accountId, uid,
792
+ * folderId). Used by the sync-action failure path: when an IMAP MOVE
793
+ * or EXPUNGE fails terminally, the row is still on the server and the
794
+ * user should see it again — so the suppression must lift. The lookup
795
+ * is by (accountId, uid, folderId) → message_id, since the action
796
+ * table stores UID not Message-ID. No-op if the row no longer exists
797
+ * (concurrent server-side EXPUNGE etc.). */
798
+ clearTombstoneForUid(accountId, uid, folderId) {
799
+ const env = this.getMessageByUid(accountId, uid, folderId);
800
+ if (!env?.messageId)
801
+ return;
802
+ this.removeTombstone(accountId, env.messageId);
803
+ }
804
+ /** @deprecated — use clearMessagePendingDelete instead. Retained as a
805
+ * no-op for legacy callers (Ctrl-Z undelete paths still work because
806
+ * their sibling clearMessagePendingDelete is called). */
719
807
  removeTombstone(accountId, messageId) {
720
808
  if (!messageId)
721
809
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
package/parse-serial.d.ts CHANGED
@@ -27,7 +27,8 @@
27
27
  * pool is built.
28
28
  */
29
29
  import { type ParsedMail, type Source } from "mailparser";
30
- /** Serialized wrapper around `mailparser.simpleParser`. Drop-in replacement —
31
- * same signature, same return value. */
32
- export declare function parseSerial(source: Source): Promise<ParsedMail>;
30
+ /** Serialized wrapper around `mailparser.simpleParser`. `priority` defaults
31
+ * to "foreground" only sync/background callers should pass "background"
32
+ * so user clicks aren't stuck behind a back-fill. */
33
+ export declare function parseSerial(source: Source, priority?: "foreground" | "background"): Promise<ParsedMail>;
33
34
  //# sourceMappingURL=parse-serial.d.ts.map
package/parse-serial.js CHANGED
@@ -27,12 +27,42 @@
27
27
  * pool is built.
28
28
  */
29
29
  import { simpleParser } from "mailparser";
30
- let _chain = Promise.resolve();
31
- /** Serialized wrapper around `mailparser.simpleParser`. Drop-in replacement
32
- * same signature, same return value. */
33
- export async function parseSerial(source) {
34
- const queued = _chain.then(() => simpleParser(source));
35
- _chain = queued.catch(() => undefined);
36
- return queued;
30
+ const _head = []; // UI / foreground — drained first
31
+ const _tail = []; // sync / background
32
+ let _pumping = false;
33
+ async function pump() {
34
+ if (_pumping)
35
+ return;
36
+ _pumping = true;
37
+ try {
38
+ for (;;) {
39
+ const next = _head.shift() ?? _tail.shift();
40
+ if (!next)
41
+ break;
42
+ try {
43
+ const parsed = await simpleParser(next.source);
44
+ next.resolve(parsed);
45
+ }
46
+ catch (e) {
47
+ next.reject(e);
48
+ }
49
+ }
50
+ }
51
+ finally {
52
+ _pumping = false;
53
+ }
54
+ }
55
+ /** Serialized wrapper around `mailparser.simpleParser`. `priority` defaults
56
+ * to "foreground" — only sync/background callers should pass "background"
57
+ * so user clicks aren't stuck behind a back-fill. */
58
+ export async function parseSerial(source, priority = "foreground") {
59
+ return new Promise((resolve, reject) => {
60
+ const entry = { source, resolve, reject };
61
+ if (priority === "foreground")
62
+ _head.push(entry);
63
+ else
64
+ _tail.push(entry);
65
+ pump();
66
+ });
37
67
  }
38
68
  //# sourceMappingURL=parse-serial.js.map