@bobfrankston/mailx-imap 0.1.43 → 0.1.45

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 +5 -37
  2. package/index.js +48 -173
  3. package/package.json +5 -5
package/index.d.ts CHANGED
@@ -67,6 +67,11 @@ export declare class ImapManager extends EventEmitter {
67
67
  private accountErrorShown;
68
68
  private syncing;
69
69
  private inboxSyncing;
70
+ /** Wall-clock of ImapManager construction. Used by the lazy-folder-sync
71
+ * gate so non-priority folders defer their first-sync until ~3 min
72
+ * past startup, when the event loop has quieted down. Each restart
73
+ * resets this; the gate auto-lifts as time passes. */
74
+ private _startupAt;
70
75
  /** Use native IMAP client instead of imapflow. Set to true to enable. */
71
76
  useNativeClient: boolean;
72
77
  /** Per-account health counters. Incremented when the server misbehaves
@@ -374,45 +379,8 @@ export declare class ImapManager extends EventEmitter {
374
379
  private _prefetchBodies;
375
380
  /** Get the body store for direct access */
376
381
  getBodyStore(): FileMessageStore;
377
- /** Bulk trash messages — local-first, single IMAP connection for all */
378
- trashMessages(accountId: string, messages: {
379
- uid: number;
380
- folderId: number;
381
- }[]): Promise<void>;
382
- /** Bulk move messages — queues the IMAP action only. The service layer
383
- * (MailxService.moveMessages) owns the local DB mutation via
384
- * updateMessageFolder; this method used to ALSO deleteMessage here,
385
- * which wiped the row the service just updated — the message vanished
386
- * on the next reconcile and "spam folder empty" was the symptom. */
387
- moveMessages(accountId: string, messages: {
388
- uid: number;
389
- folderId: number;
390
- }[], targetFolderId: number): Promise<void>;
391
- /** Debounced sync actions — batches rapid local changes into one IMAP operation */
392
- private syncActionTimers;
393
- private debounceSyncActions;
394
- /** Move a message to Trash (delete) — local-first, queues IMAP sync */
395
- trashMessage(accountId: string, folderId: number, uid: number): Promise<void>;
396
- /** Move a message between folders — queues IMAP sync only. Service
397
- * layer owns the local DB update (see MailxService.moveMessage). */
398
- moveMessage(accountId: string, uid: number, fromFolderId: number, toFolderId: number): Promise<void>;
399
382
  /** Move message across accounts using iflow's moveMessageToServer */
400
383
  moveMessageCrossAccount(fromAccountId: string, uid: number, fromFolderId: number, toAccountId: string, toFolderId: number): Promise<void>;
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. */
413
- undeleteMessage(accountId: string, uid: number, originalFolderId: number): Promise<void>;
414
- /** Update flags — local-first, queues IMAP sync */
415
- updateFlagsLocal(accountId: string, uid: number, folderId: number, flags: string[]): Promise<void>;
416
384
  /** Process pending sync actions for an account */
417
385
  processSyncActions(accountId: string): Promise<void>;
418
386
  /** Find a folder by specialUse, case-insensitive */
package/index.js CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { createAutoImapConfig, CompatImapClient } from "@bobfrankston/iflow-direct";
7
7
  import { authenticateOAuth } from "@bobfrankston/oauthsupport";
8
- import { FileMessageStore, parseSerial } from "@bobfrankston/mailx-store";
8
+ import { FileMessageStore, parseSerial, storeBus } from "@bobfrankston/mailx-store";
9
9
  import { loadSettings, getStorePath, getConfigDir, getHistoryDays, getPrefetch } from "@bobfrankston/mailx-settings";
10
10
  import { EventEmitter } from "node:events";
11
11
  import * as fs from "node:fs";
@@ -142,6 +142,11 @@ export class ImapManager extends EventEmitter {
142
142
  accountErrorShown = new Set();
143
143
  syncing = false;
144
144
  inboxSyncing = false;
145
+ /** Wall-clock of ImapManager construction. Used by the lazy-folder-sync
146
+ * gate so non-priority folders defer their first-sync until ~3 min
147
+ * past startup, when the event loop has quieted down. Each restart
148
+ * resets this; the gate auto-lifts as time passes. */
149
+ _startupAt = Date.now();
145
150
  /** Use native IMAP client instead of imapflow. Set to true to enable. */
146
151
  useNativeClient = false;
147
152
  // Connection management: see withConnection() below.
@@ -211,6 +216,22 @@ export class ImapManager extends EventEmitter {
211
216
  console.log(` [reconcile-cancel] ${info.accountId} ${info.fromFolderId}/${info.fromUid}: deferred delete cancelled (move-detect rebound to ${info.toFolderId}/${info.toUid})`);
212
217
  }
213
218
  });
219
+ // Bridge legacy EventEmitter "folderCountsChanged" to the Store bus
220
+ // so subscribers can listen on a single mechanism. Done in-class
221
+ // rather than at every call site (~20 emit points in this file) to
222
+ // keep the diff small and avoid a per-call regression risk during
223
+ // the refactor. The Store bus is the long-term home; once every
224
+ // consumer subscribes via the bus, the legacy listeners in
225
+ // bin/mailx.ts get retired and the `this.emit("folderCountsChanged"...)`
226
+ // calls can convert in bulk.
227
+ this.on("folderCountsChanged", (accountId, payload) => {
228
+ storeBus.publish({
229
+ topic: `account:${accountId}`,
230
+ kind: "folderCountsChanged",
231
+ accountId,
232
+ folderId: payload?.folderId,
233
+ });
234
+ });
214
235
  }
215
236
  /** Get OAuth access token for an account (for SMTP auth) */
216
237
  async getOAuthToken(accountId) {
@@ -1716,12 +1737,37 @@ export class ImapManager extends EventEmitter {
1716
1737
  // timeout abandons a stalled command instead of waiting out
1717
1738
  // Dovecot's 300s server-side inactivity timer; the next sync tick
1718
1739
  // retries on a fresh socket.
1719
- const remaining = folders.filter(f => f.specialUse !== "inbox");
1740
+ // 2026-05-13: defer first-sync of non-priority folders by default
1741
+ // (C119 lazy folder sync). On bobma with 90+ folders, doing a
1742
+ // first-sync of every folder on every startup hammers the IMAP
1743
+ // socket buffer and pegs the daemon's event loop processing
1744
+ // FETCH literals — user clicks get a 20+ second IPC delay
1745
+ // because no IPC can squeeze in between FETCH chunks. Now: only
1746
+ // special-use folders (Sent/Drafts/Archive/Junk/Trash) + folders
1747
+ // mailx has previously seen (highestUid > 0, i.e. tracked
1748
+ // across the prior session) sync automatically. First-sync of
1749
+ // a "never-touched" non-special folder is deferred until the
1750
+ // user opens that folder (on-demand syncFolder call) or until
1751
+ // ~3 minutes after startup when the event loop is quiet.
1752
+ const STARTUP_LAZY_DELAY_MS = 3 * 60 * 1000;
1753
+ const startupQuietPoint = (this._startupAt || 0) + STARTUP_LAZY_DELAY_MS;
1754
+ const isLazyEligible = (f) => {
1755
+ if (f.specialUse && priorityOrder.includes(f.specialUse))
1756
+ return false;
1757
+ if (this.db.getHighestUid(accountId, f.id) > 0)
1758
+ return false;
1759
+ return true;
1760
+ };
1761
+ const remaining = folders.filter(f => f.specialUse !== "inbox" && !(isLazyEligible(f) && Date.now() < startupQuietPoint));
1720
1762
  remaining.sort((a, b) => {
1721
1763
  const pa = priorityOrder.indexOf(a.specialUse || "") >= 0 ? priorityOrder.indexOf(a.specialUse || "") : 5;
1722
1764
  const pb = priorityOrder.indexOf(b.specialUse || "") >= 0 ? priorityOrder.indexOf(b.specialUse || "") : 5;
1723
1765
  return pa - pb;
1724
1766
  });
1767
+ const deferredCount = folders.filter(f => f.specialUse !== "inbox" && isLazyEligible(f) && Date.now() < startupQuietPoint).length;
1768
+ if (deferredCount > 0) {
1769
+ console.log(` [sync] ${accountId}: deferring first-sync of ${deferredCount} non-priority folder(s) until ${new Date(startupQuietPoint).toLocaleTimeString()} or user opens them`);
1770
+ }
1725
1771
  const CONCURRENCY = 2;
1726
1772
  // First-sync of a fresh account on a cold Dovecot is dominated by
1727
1773
  // `UID SEARCH SINCE 30-days-ago`, which can take 5+ minutes on a
@@ -2898,131 +2944,6 @@ export class ImapManager extends EventEmitter {
2898
2944
  getBodyStore() {
2899
2945
  return this.bodyStore;
2900
2946
  }
2901
- /** Bulk trash messages — local-first, single IMAP connection for all */
2902
- async trashMessages(accountId, messages) {
2903
- if (messages.length === 0)
2904
- return;
2905
- const trash = this.findFolder(accountId, "trash");
2906
- // Tombstone each Message-ID so sync won't re-import the source-folder
2907
- // row before the server-side MOVE completes. Cleared on permanent
2908
- // failure (clearTombstoneForUid in processSyncActions).
2909
- for (const msg of messages) {
2910
- const env = this.db.getMessageByUid(accountId, msg.uid, msg.folderId);
2911
- if (env?.messageId)
2912
- this.db.addTombstone(accountId, env.messageId, env.subject || "");
2913
- }
2914
- // Local first — move to trash folder locally so the row stays
2915
- // visible in Trash and Ctrl+Z can restore it. Body file stays in
2916
- // its original folder dir; the next sync rebinds path on
2917
- // membership uid change. Old behavior was `db.deleteMessage` +
2918
- // `unlinkBodyFile` which made undelete impossible (no row to
2919
- // restore, no body to read). For folders that ARE the trash
2920
- // already, fall through to hard delete (the action will EXPUNGE
2921
- // and reconciliation cleans up).
2922
- for (const msg of messages) {
2923
- if (trash && trash.id !== msg.folderId) {
2924
- this.db.moveMessageLocal(accountId, msg.uid, msg.folderId, trash.id);
2925
- }
2926
- else {
2927
- this.unlinkBodyFile(accountId, msg.uid, msg.folderId).catch(() => { });
2928
- this.db.deleteMessage(accountId, msg.uid, "user-initiated trash (already in trash → expunge)", "mailx-imap trashMessages");
2929
- }
2930
- }
2931
- console.log(` Trashed ${messages.length} messages locally (moved to trash folder, body files retained)`);
2932
- // Queue IMAP actions
2933
- for (const msg of messages) {
2934
- if (trash && trash.id !== msg.folderId) {
2935
- this.db.queueSyncAction(accountId, "move", msg.uid, msg.folderId, { targetFolderId: trash.id });
2936
- }
2937
- else {
2938
- this.db.queueSyncAction(accountId, "delete", msg.uid, msg.folderId);
2939
- }
2940
- }
2941
- // Recalc folder counts so the tree badge updates immediately instead
2942
- // of showing stale numbers until the next full sync.
2943
- const sourceFolderIds = new Set(messages.map(m => m.folderId));
2944
- for (const fid of sourceFolderIds)
2945
- this.db.recalcFolderCounts(fid);
2946
- if (trash)
2947
- this.db.recalcFolderCounts(trash.id);
2948
- this.emit("folderCountsChanged", accountId, {});
2949
- // Process all queued actions in one IMAP session
2950
- this.debounceSyncActions(accountId);
2951
- }
2952
- /** Bulk move messages — queues the IMAP action only. The service layer
2953
- * (MailxService.moveMessages) owns the local DB mutation via
2954
- * updateMessageFolder; this method used to ALSO deleteMessage here,
2955
- * which wiped the row the service just updated — the message vanished
2956
- * on the next reconcile and "spam folder empty" was the symptom. */
2957
- async moveMessages(accountId, messages, targetFolderId) {
2958
- if (messages.length === 0)
2959
- return;
2960
- for (const msg of messages) {
2961
- this.db.queueSyncAction(accountId, "move", msg.uid, msg.folderId, { targetFolderId });
2962
- }
2963
- console.log(` [move] ${accountId}: queued IMAP MOVE for ${messages.length} message(s) → folder ${targetFolderId}`);
2964
- this.debounceSyncActions(accountId);
2965
- }
2966
- /** Debounced sync actions — batches rapid local changes into one IMAP operation */
2967
- syncActionTimers = new Map();
2968
- debounceSyncActions(accountId) {
2969
- const existing = this.syncActionTimers.get(accountId);
2970
- if (existing)
2971
- clearTimeout(existing);
2972
- this.syncActionTimers.set(accountId, setTimeout(() => {
2973
- this.syncActionTimers.delete(accountId);
2974
- this.processSyncActions(accountId).catch(() => { });
2975
- }, 1000));
2976
- }
2977
- /** Move a message to Trash (delete) — local-first, queues IMAP sync */
2978
- async trashMessage(accountId, folderId, uid) {
2979
- const trash = this.findFolder(accountId, "trash");
2980
- // Tombstone the Message-ID so sync won't re-import the row in the
2981
- // source folder before the server-side move completes. Cleared on
2982
- // permanent failure of the queued sync_action (see processSyncActions
2983
- // catch block, where clearTombstoneForUid runs after attempts >= 5)
2984
- // so the user sees the row reappear when their action didn't take.
2985
- const env = this.db.getMessageByUid(accountId, uid, folderId);
2986
- if (env?.messageId)
2987
- this.db.addTombstone(accountId, env.messageId, env.subject || "");
2988
- // Local first — move to trash folder so the row stays visible in
2989
- // Trash and Ctrl+Z can restore. Body file retained for undelete.
2990
- // If we're already in trash (or no trash configured), fall through
2991
- // to hard delete + EXPUNGE.
2992
- if (trash && trash.id !== folderId) {
2993
- this.db.moveMessageLocal(accountId, uid, folderId, trash.id);
2994
- }
2995
- else {
2996
- this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
2997
- this.db.deleteMessage(accountId, uid, "user-initiated trash (already in trash → expunge)", "mailx-imap trashMessage");
2998
- }
2999
- // Queue IMAP action + log the resolution so "I deleted a message and
3000
- // now it's in neither trash nor deleted" is diagnosable from the log.
3001
- if (trash && trash.id !== folderId) {
3002
- const trashFolder = this.db.getFolders(accountId).find(f => f.id === trash.id);
3003
- this.db.queueSyncAction(accountId, "move", uid, folderId, { targetFolderId: trash.id });
3004
- console.log(` [trash] ${accountId} UID ${uid}: queued MOVE to "${trashFolder?.path || trash.path}" (id=${trash.id}, specialUse=trash)`);
3005
- }
3006
- else {
3007
- this.db.queueSyncAction(accountId, "delete", uid, folderId);
3008
- console.log(` [trash] ${accountId} UID ${uid}: queued EXPUNGE in folder ${folderId} (already in trash or no trash configured)`);
3009
- }
3010
- // Folder counts moved — refresh both source and trash so the
3011
- // tree badges update immediately, not at the next sync.
3012
- this.db.recalcFolderCounts(folderId);
3013
- if (trash && trash.id !== folderId)
3014
- this.db.recalcFolderCounts(trash.id);
3015
- this.emit("folderCountsChanged", accountId, {});
3016
- // Debounced sync — batches multiple deletes into one IMAP session
3017
- this.debounceSyncActions(accountId);
3018
- }
3019
- /** Move a message between folders — queues IMAP sync only. Service
3020
- * layer owns the local DB update (see MailxService.moveMessage). */
3021
- async moveMessage(accountId, uid, fromFolderId, toFolderId) {
3022
- this.db.queueSyncAction(accountId, "move", uid, fromFolderId, { targetFolderId: toFolderId });
3023
- console.log(` [move] ${accountId}: queued IMAP MOVE UID ${uid} folder ${fromFolderId} → ${toFolderId}`);
3024
- this.debounceSyncActions(accountId);
3025
- }
3026
2947
  /** Move message across accounts using iflow's moveMessageToServer */
3027
2948
  async moveMessageCrossAccount(fromAccountId, uid, fromFolderId, toAccountId, toFolderId) {
3028
2949
  const fromFolders = this.db.getFolders(fromAccountId);
@@ -3048,52 +2969,6 @@ export class ImapManager extends EventEmitter {
3048
2969
  });
3049
2970
  });
3050
2971
  }
3051
- /** Undelete — move from Trash back to original folder. Local-first:
3052
- * the row was moved (not deleted) on trash, so we just move it back
3053
- * in the local DB and reconcile the IMAP queue. Two cases:
3054
- * (a) the to-trash MOVE is still pending — cancel it; the server
3055
- * never saw the delete, so no counter-action is needed.
3056
- * (b) the to-trash MOVE drained — the message is now in Trash on
3057
- * the server with a new uid. Queue a counter-move from
3058
- * trash → original. The IMAP processor's fetchByUid in trash
3059
- * will use the local membership uid (which the reconciler
3060
- * rebound to the server's new trash uid via Message-ID match).
3061
- * If reconcile hasn't run yet (unlikely race), action retries
3062
- * until it does. */
3063
- async undeleteMessage(accountId, uid, originalFolderId) {
3064
- const trash = this.findFolder(accountId, "trash");
3065
- if (!trash)
3066
- throw new Error("No Trash folder found");
3067
- // Move locally back to the original folder.
3068
- const moved = this.db.moveMessageLocal(accountId, uid, trash.id, originalFolderId);
3069
- if (!moved) {
3070
- console.log(` [undelete] ${accountId} UID ${uid}: no row in trash — nothing to restore locally (sync may have already pruned)`);
3071
- }
3072
- // (a) cancel still-pending to-trash action.
3073
- const pending = this.db.findPendingSyncAction(accountId, "move", uid, originalFolderId, trash.id);
3074
- if (pending) {
3075
- this.db.completeSyncAction(pending.id);
3076
- console.log(` [undelete] ${accountId} UID ${uid}: cancelled pending MOVE to trash (server never saw delete)`);
3077
- this.emit("folderCountsChanged", accountId, {});
3078
- return;
3079
- }
3080
- // (b) queue counter-move from trash → original.
3081
- this.db.queueSyncAction(accountId, "move", uid, trash.id, { targetFolderId: originalFolderId });
3082
- console.log(` [undelete] ${accountId} UID ${uid}: queued counter-MOVE trash → folder ${originalFolderId}`);
3083
- this.debounceSyncActions(accountId);
3084
- this.emit("folderCountsChanged", accountId, {});
3085
- }
3086
- /** Update flags — local-first, queues IMAP sync */
3087
- async updateFlagsLocal(accountId, uid, folderId, flags) {
3088
- this.db.updateMessageFlags(accountId, uid, flags);
3089
- this.db.queueSyncAction(accountId, "flags", uid, folderId, { flags });
3090
- // User-visible pink-dot pending state stays until the action drains.
3091
- // The 30-second periodic tick was too slow — opening one message to
3092
- // auto-mark-as-read left it pink for half a minute. Same 1-second
3093
- // debounce as moves/deletes batches rapid flag churn without the
3094
- // visual lag.
3095
- this.debounceSyncActions(accountId);
3096
- }
3097
2972
  /** Process pending sync actions for an account */
3098
2973
  async processSyncActions(accountId) {
3099
2974
  const actions = this.db.getPendingSyncActions(accountId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.43",
3
+ "version": "0.1.45",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -9,9 +9,9 @@
9
9
  },
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
- "@bobfrankston/mailx-types": "^0.1.11",
12
+ "@bobfrankston/mailx-types": "^0.1.13",
13
13
  "@bobfrankston/mailx-settings": "^0.1.16",
14
- "@bobfrankston/mailx-store": "^0.1.23",
14
+ "@bobfrankston/mailx-store": "^0.1.24",
15
15
  "@bobfrankston/iflow-direct": "^0.1.44",
16
16
  "@bobfrankston/tcp-transport": "^0.1.6",
17
17
  "@bobfrankston/smtp-direct": "^0.1.8",
@@ -37,9 +37,9 @@
37
37
  },
38
38
  ".transformedSnapshot": {
39
39
  "dependencies": {
40
- "@bobfrankston/mailx-types": "^0.1.11",
40
+ "@bobfrankston/mailx-types": "^0.1.13",
41
41
  "@bobfrankston/mailx-settings": "^0.1.16",
42
- "@bobfrankston/mailx-store": "^0.1.23",
42
+ "@bobfrankston/mailx-store": "^0.1.24",
43
43
  "@bobfrankston/iflow-direct": "^0.1.44",
44
44
  "@bobfrankston/tcp-transport": "^0.1.6",
45
45
  "@bobfrankston/smtp-direct": "^0.1.8",