@bobfrankston/mailx-imap 0.1.31 → 0.1.33

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 +83 -12
  3. package/package.json +3 -3
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
@@ -2696,12 +2696,24 @@ export class ImapManager extends EventEmitter {
2696
2696
  if (messages.length === 0)
2697
2697
  return;
2698
2698
  const trash = this.findFolder(accountId, "trash");
2699
- // Local first — remove all from DB immediately
2699
+ // Local first — move to trash folder locally so the row stays
2700
+ // visible in Trash and Ctrl+Z can restore it. Body file stays in
2701
+ // its original folder dir; the next sync rebinds path on
2702
+ // membership uid change. Old behavior was `db.deleteMessage` +
2703
+ // `unlinkBodyFile` which made undelete impossible (no row to
2704
+ // restore, no body to read). For folders that ARE the trash
2705
+ // already, fall through to hard delete (the action will EXPUNGE
2706
+ // and reconciliation cleans up).
2700
2707
  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");
2708
+ if (trash && trash.id !== msg.folderId) {
2709
+ this.db.moveMessageLocal(accountId, msg.uid, msg.folderId, trash.id);
2710
+ }
2711
+ else {
2712
+ this.unlinkBodyFile(accountId, msg.uid, msg.folderId).catch(() => { });
2713
+ this.db.deleteMessage(accountId, msg.uid, "user-initiated trash (already in trash → expunge)", "mailx-imap trashMessages");
2714
+ }
2703
2715
  }
2704
- console.log(` Deleted ${messages.length} messages locally`);
2716
+ console.log(` Trashed ${messages.length} messages locally (moved to trash folder, body files retained)`);
2705
2717
  // Queue IMAP actions
2706
2718
  for (const msg of messages) {
2707
2719
  if (trash && trash.id !== msg.folderId) {
@@ -2750,9 +2762,17 @@ export class ImapManager extends EventEmitter {
2750
2762
  /** Move a message to Trash (delete) — local-first, queues IMAP sync */
2751
2763
  async trashMessage(accountId, folderId, uid) {
2752
2764
  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");
2765
+ // Local first — move to trash folder so the row stays visible in
2766
+ // Trash and Ctrl+Z can restore. Body file retained for undelete.
2767
+ // If we're already in trash (or no trash configured), fall through
2768
+ // to hard delete + EXPUNGE.
2769
+ if (trash && trash.id !== folderId) {
2770
+ this.db.moveMessageLocal(accountId, uid, folderId, trash.id);
2771
+ }
2772
+ else {
2773
+ this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
2774
+ this.db.deleteMessage(accountId, uid, "user-initiated trash (already in trash → expunge)", "mailx-imap trashMessage");
2775
+ }
2756
2776
  // Queue IMAP action + log the resolution so "I deleted a message and
2757
2777
  // now it's in neither trash nor deleted" is diagnosable from the log.
2758
2778
  if (trash && trash.id !== folderId) {
@@ -2764,6 +2784,12 @@ export class ImapManager extends EventEmitter {
2764
2784
  this.db.queueSyncAction(accountId, "delete", uid, folderId);
2765
2785
  console.log(` [trash] ${accountId} UID ${uid}: queued EXPUNGE in folder ${folderId} (already in trash or no trash configured)`);
2766
2786
  }
2787
+ // Folder counts moved — refresh both source and trash so the
2788
+ // tree badges update immediately, not at the next sync.
2789
+ this.db.recalcFolderCounts(folderId);
2790
+ if (trash && trash.id !== folderId)
2791
+ this.db.recalcFolderCounts(trash.id);
2792
+ this.emit("folderCountsChanged", accountId, {});
2767
2793
  // Debounced sync — batches multiple deletes into one IMAP session
2768
2794
  this.debounceSyncActions(accountId);
2769
2795
  }
@@ -2799,12 +2825,40 @@ export class ImapManager extends EventEmitter {
2799
2825
  });
2800
2826
  });
2801
2827
  }
2802
- /** Undelete — move from Trash back to original folder */
2828
+ /** Undelete — move from Trash back to original folder. Local-first:
2829
+ * the row was moved (not deleted) on trash, so we just move it back
2830
+ * in the local DB and reconcile the IMAP queue. Two cases:
2831
+ * (a) the to-trash MOVE is still pending — cancel it; the server
2832
+ * never saw the delete, so no counter-action is needed.
2833
+ * (b) the to-trash MOVE drained — the message is now in Trash on
2834
+ * the server with a new uid. Queue a counter-move from
2835
+ * trash → original. The IMAP processor's fetchByUid in trash
2836
+ * will use the local membership uid (which the reconciler
2837
+ * rebound to the server's new trash uid via Message-ID match).
2838
+ * If reconcile hasn't run yet (unlikely race), action retries
2839
+ * until it does. */
2803
2840
  async undeleteMessage(accountId, uid, originalFolderId) {
2804
2841
  const trash = this.findFolder(accountId, "trash");
2805
2842
  if (!trash)
2806
2843
  throw new Error("No Trash folder found");
2807
- await this.moveMessage(accountId, uid, trash.id, originalFolderId);
2844
+ // Move locally back to the original folder.
2845
+ const moved = this.db.moveMessageLocal(accountId, uid, trash.id, originalFolderId);
2846
+ if (!moved) {
2847
+ console.log(` [undelete] ${accountId} UID ${uid}: no row in trash — nothing to restore locally (sync may have already pruned)`);
2848
+ }
2849
+ // (a) cancel still-pending to-trash action.
2850
+ const pending = this.db.findPendingSyncAction(accountId, "move", uid, originalFolderId, trash.id);
2851
+ if (pending) {
2852
+ this.db.completeSyncAction(pending.id);
2853
+ console.log(` [undelete] ${accountId} UID ${uid}: cancelled pending MOVE to trash (server never saw delete)`);
2854
+ this.emit("folderCountsChanged", accountId, {});
2855
+ return;
2856
+ }
2857
+ // (b) queue counter-move from trash → original.
2858
+ this.db.queueSyncAction(accountId, "move", uid, trash.id, { targetFolderId: originalFolderId });
2859
+ console.log(` [undelete] ${accountId} UID ${uid}: queued counter-MOVE trash → folder ${originalFolderId}`);
2860
+ this.debounceSyncActions(accountId);
2861
+ this.emit("folderCountsChanged", accountId, {});
2808
2862
  }
2809
2863
  /** Update flags — local-first, queues IMAP sync */
2810
2864
  async updateFlagsLocal(accountId, uid, folderId, flags) {
@@ -3368,7 +3422,12 @@ export class ImapManager extends EventEmitter {
3368
3422
  // this exact case: `.sending-rmf39-63196` sat in the queue
3369
3423
  // for 7+ hours because PID 63196 was now an unrelated Node.
3370
3424
  // (c) it's our PID — never sweep our own claim.
3371
- const STALE_CLAIM_MS = 3600_000;
3425
+ // 5 minutes: SMTP transactions complete in seconds; 5m of
3426
+ // claim with no progress means the worker is wedged or PID
3427
+ // got recycled. Earlier 1h was a band-aid that left Bob's
3428
+ // outbox stuck for hours when an SMTP wedge crashed the
3429
+ // worker mid-flight.
3430
+ const STALE_CLAIM_MS = 5 * 60_000;
3372
3431
  const myPid = process.pid;
3373
3432
  for (const dir of [outboxDir, queuedDir]) {
3374
3433
  if (!fs.existsSync(dir))
@@ -3470,11 +3529,23 @@ export class ImapManager extends EventEmitter {
3470
3529
  // file is visible to the scan loop again.
3471
3530
  const nextAt = new Date(nowMs + OUTBOX_RETRY_DELAY_MS).toISOString();
3472
3531
  const withDelay = insertHeaderBeforeBody(raw, `X-Mailx-Retry-After: ${nextAt}`);
3473
- fs.writeFileSync(claimedPath, withDelay, "utf-8");
3532
+ try {
3533
+ fs.writeFileSync(claimedPath, withDelay, "utf-8");
3534
+ }
3535
+ catch (we) {
3536
+ console.error(` [outbox] FAIL writeBack ${claimedPath}: ${we?.message || we}`);
3537
+ }
3474
3538
  try {
3475
3539
  fs.renameSync(claimedPath, filePath);
3476
3540
  }
3477
- catch { /* file stays claimed; recovery sweeper will handle */ }
3541
+ catch (re) {
3542
+ // Loud — the file is now stuck in `.sending-` state
3543
+ // until the recovery sweeper's stale-claim timer
3544
+ // (5 min) reclaims it. User-visible as "stuck" in
3545
+ // the outbox view; cancelable via the new always-
3546
+ // allowed Cancel button.
3547
+ console.error(` [outbox] FAIL renameBack ${claimedPath} → ${filePath}: ${re?.message || re} — claim will sit until stale-recovery (5 min)`);
3548
+ }
3478
3549
  console.error(` [outbox] Send failed for ${file} (attempt ${attempt}, retry after ${nextAt}): ${e.message}`);
3479
3550
  }
3480
3551
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -10,7 +10,7 @@
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
12
  "@bobfrankston/mailx-types": "^0.1.10",
13
- "@bobfrankston/mailx-settings": "^0.1.13",
13
+ "@bobfrankston/mailx-settings": "^0.1.14",
14
14
  "@bobfrankston/mailx-store": "^0.1.15",
15
15
  "@bobfrankston/iflow-direct": "^0.1.39",
16
16
  "@bobfrankston/tcp-transport": "^0.1.6",
@@ -38,7 +38,7 @@
38
38
  ".transformedSnapshot": {
39
39
  "dependencies": {
40
40
  "@bobfrankston/mailx-types": "^0.1.10",
41
- "@bobfrankston/mailx-settings": "^0.1.13",
41
+ "@bobfrankston/mailx-settings": "^0.1.14",
42
42
  "@bobfrankston/mailx-store": "^0.1.15",
43
43
  "@bobfrankston/iflow-direct": "^0.1.39",
44
44
  "@bobfrankston/tcp-transport": "^0.1.6",