@bobfrankston/mailx-imap 0.1.32 → 0.1.34

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.
Files changed (3) hide show
  1. package/index.d.ts +12 -1
  2. package/index.js +66 -9
  3. package/package.json +5 -5
package/index.d.ts CHANGED
@@ -398,7 +398,18 @@ export declare class ImapManager extends EventEmitter {
398
398
  moveMessage(accountId: string, uid: number, fromFolderId: number, toFolderId: number): Promise<void>;
399
399
  /** Move message across accounts using iflow's moveMessageToServer */
400
400
  moveMessageCrossAccount(fromAccountId: string, uid: number, fromFolderId: number, toAccountId: string, toFolderId: number): Promise<void>;
401
- /** Undelete — move from Trash back to original folder */
401
+ /** Undelete — move from Trash back to original folder. Local-first:
402
+ * the row was moved (not deleted) on trash, so we just move it back
403
+ * in the local DB and reconcile the IMAP queue. Two cases:
404
+ * (a) the to-trash MOVE is still pending — cancel it; the server
405
+ * never saw the delete, so no counter-action is needed.
406
+ * (b) the to-trash MOVE drained — the message is now in Trash on
407
+ * the server with a new uid. Queue a counter-move from
408
+ * trash → original. The IMAP processor's fetchByUid in trash
409
+ * will use the local membership uid (which the reconciler
410
+ * rebound to the server's new trash uid via Message-ID match).
411
+ * If reconcile hasn't run yet (unlikely race), action retries
412
+ * until it does. */
402
413
  undeleteMessage(accountId: string, uid: number, originalFolderId: number): Promise<void>;
403
414
  /** Update flags — local-first, queues IMAP sync */
404
415
  updateFlagsLocal(accountId: string, uid: number, folderId: number, flags: string[]): Promise<void>;
package/index.js CHANGED
@@ -943,6 +943,9 @@ export class ImapManager extends EventEmitter {
943
943
  const parsed = await simpleParser(source);
944
944
  // Coerce mailparser AddressObject(s) into the flat `{name, address}[]`
945
945
  // shape storeMessages's downstream toEmailAddresses expects.
946
+ // RFC 2047 encoded-word decoding (incl. inside quoted-strings) is
947
+ // handled uniformly in db.upsertMessage, not per-callsite — see
948
+ // `decodeHeaderWords` there.
946
949
  const flat = (a) => {
947
950
  if (!a)
948
951
  return [];
@@ -2696,12 +2699,24 @@ export class ImapManager extends EventEmitter {
2696
2699
  if (messages.length === 0)
2697
2700
  return;
2698
2701
  const trash = this.findFolder(accountId, "trash");
2699
- // Local first — remove all from DB immediately
2702
+ // Local first — move to trash folder locally so the row stays
2703
+ // visible in Trash and Ctrl+Z can restore it. Body file stays in
2704
+ // its original folder dir; the next sync rebinds path on
2705
+ // membership uid change. Old behavior was `db.deleteMessage` +
2706
+ // `unlinkBodyFile` which made undelete impossible (no row to
2707
+ // restore, no body to read). For folders that ARE the trash
2708
+ // already, fall through to hard delete (the action will EXPUNGE
2709
+ // and reconciliation cleans up).
2700
2710
  for (const msg of messages) {
2701
- this.unlinkBodyFile(accountId, msg.uid, msg.folderId).catch(() => { });
2702
- this.db.deleteMessage(accountId, msg.uid, "user-initiated delete (bulk)", "mailx-imap deleteMessages");
2711
+ if (trash && trash.id !== msg.folderId) {
2712
+ this.db.moveMessageLocal(accountId, msg.uid, msg.folderId, trash.id);
2713
+ }
2714
+ else {
2715
+ this.unlinkBodyFile(accountId, msg.uid, msg.folderId).catch(() => { });
2716
+ this.db.deleteMessage(accountId, msg.uid, "user-initiated trash (already in trash → expunge)", "mailx-imap trashMessages");
2717
+ }
2703
2718
  }
2704
- console.log(` Deleted ${messages.length} messages locally`);
2719
+ console.log(` Trashed ${messages.length} messages locally (moved to trash folder, body files retained)`);
2705
2720
  // Queue IMAP actions
2706
2721
  for (const msg of messages) {
2707
2722
  if (trash && trash.id !== msg.folderId) {
@@ -2750,9 +2765,17 @@ export class ImapManager extends EventEmitter {
2750
2765
  /** Move a message to Trash (delete) — local-first, queues IMAP sync */
2751
2766
  async trashMessage(accountId, folderId, uid) {
2752
2767
  const trash = this.findFolder(accountId, "trash");
2753
- // Local first — remove from DB immediately
2754
- this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
2755
- this.db.deleteMessage(accountId, uid, "user-initiated trash", "mailx-imap trashMessage");
2768
+ // Local first — move to trash folder so the row stays visible in
2769
+ // Trash and Ctrl+Z can restore. Body file retained for undelete.
2770
+ // If we're already in trash (or no trash configured), fall through
2771
+ // to hard delete + EXPUNGE.
2772
+ if (trash && trash.id !== folderId) {
2773
+ this.db.moveMessageLocal(accountId, uid, folderId, trash.id);
2774
+ }
2775
+ else {
2776
+ this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
2777
+ this.db.deleteMessage(accountId, uid, "user-initiated trash (already in trash → expunge)", "mailx-imap trashMessage");
2778
+ }
2756
2779
  // Queue IMAP action + log the resolution so "I deleted a message and
2757
2780
  // now it's in neither trash nor deleted" is diagnosable from the log.
2758
2781
  if (trash && trash.id !== folderId) {
@@ -2764,6 +2787,12 @@ export class ImapManager extends EventEmitter {
2764
2787
  this.db.queueSyncAction(accountId, "delete", uid, folderId);
2765
2788
  console.log(` [trash] ${accountId} UID ${uid}: queued EXPUNGE in folder ${folderId} (already in trash or no trash configured)`);
2766
2789
  }
2790
+ // Folder counts moved — refresh both source and trash so the
2791
+ // tree badges update immediately, not at the next sync.
2792
+ this.db.recalcFolderCounts(folderId);
2793
+ if (trash && trash.id !== folderId)
2794
+ this.db.recalcFolderCounts(trash.id);
2795
+ this.emit("folderCountsChanged", accountId, {});
2767
2796
  // Debounced sync — batches multiple deletes into one IMAP session
2768
2797
  this.debounceSyncActions(accountId);
2769
2798
  }
@@ -2799,12 +2828,40 @@ export class ImapManager extends EventEmitter {
2799
2828
  });
2800
2829
  });
2801
2830
  }
2802
- /** Undelete — move from Trash back to original folder */
2831
+ /** Undelete — move from Trash back to original folder. Local-first:
2832
+ * the row was moved (not deleted) on trash, so we just move it back
2833
+ * in the local DB and reconcile the IMAP queue. Two cases:
2834
+ * (a) the to-trash MOVE is still pending — cancel it; the server
2835
+ * never saw the delete, so no counter-action is needed.
2836
+ * (b) the to-trash MOVE drained — the message is now in Trash on
2837
+ * the server with a new uid. Queue a counter-move from
2838
+ * trash → original. The IMAP processor's fetchByUid in trash
2839
+ * will use the local membership uid (which the reconciler
2840
+ * rebound to the server's new trash uid via Message-ID match).
2841
+ * If reconcile hasn't run yet (unlikely race), action retries
2842
+ * until it does. */
2803
2843
  async undeleteMessage(accountId, uid, originalFolderId) {
2804
2844
  const trash = this.findFolder(accountId, "trash");
2805
2845
  if (!trash)
2806
2846
  throw new Error("No Trash folder found");
2807
- await this.moveMessage(accountId, uid, trash.id, originalFolderId);
2847
+ // Move locally back to the original folder.
2848
+ const moved = this.db.moveMessageLocal(accountId, uid, trash.id, originalFolderId);
2849
+ if (!moved) {
2850
+ console.log(` [undelete] ${accountId} UID ${uid}: no row in trash — nothing to restore locally (sync may have already pruned)`);
2851
+ }
2852
+ // (a) cancel still-pending to-trash action.
2853
+ const pending = this.db.findPendingSyncAction(accountId, "move", uid, originalFolderId, trash.id);
2854
+ if (pending) {
2855
+ this.db.completeSyncAction(pending.id);
2856
+ console.log(` [undelete] ${accountId} UID ${uid}: cancelled pending MOVE to trash (server never saw delete)`);
2857
+ this.emit("folderCountsChanged", accountId, {});
2858
+ return;
2859
+ }
2860
+ // (b) queue counter-move from trash → original.
2861
+ this.db.queueSyncAction(accountId, "move", uid, trash.id, { targetFolderId: originalFolderId });
2862
+ console.log(` [undelete] ${accountId} UID ${uid}: queued counter-MOVE trash → folder ${originalFolderId}`);
2863
+ this.debounceSyncActions(accountId);
2864
+ this.emit("folderCountsChanged", accountId, {});
2808
2865
  }
2809
2866
  /** Update flags — local-first, queues IMAP sync */
2810
2867
  async updateFlagsLocal(accountId, uid, folderId, flags) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -10,8 +10,8 @@
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
12
  "@bobfrankston/mailx-types": "^0.1.10",
13
- "@bobfrankston/mailx-settings": "^0.1.13",
14
- "@bobfrankston/mailx-store": "^0.1.15",
13
+ "@bobfrankston/mailx-settings": "^0.1.14",
14
+ "@bobfrankston/mailx-store": "^0.1.16",
15
15
  "@bobfrankston/iflow-direct": "^0.1.39",
16
16
  "@bobfrankston/tcp-transport": "^0.1.6",
17
17
  "@bobfrankston/smtp-direct": "^0.1.8",
@@ -38,8 +38,8 @@
38
38
  ".transformedSnapshot": {
39
39
  "dependencies": {
40
40
  "@bobfrankston/mailx-types": "^0.1.10",
41
- "@bobfrankston/mailx-settings": "^0.1.13",
42
- "@bobfrankston/mailx-store": "^0.1.15",
41
+ "@bobfrankston/mailx-settings": "^0.1.14",
42
+ "@bobfrankston/mailx-store": "^0.1.16",
43
43
  "@bobfrankston/iflow-direct": "^0.1.39",
44
44
  "@bobfrankston/tcp-transport": "^0.1.6",
45
45
  "@bobfrankston/smtp-direct": "^0.1.8",