@bobfrankston/mailx 1.0.109 → 1.0.111

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/client/app.js CHANGED
@@ -5,7 +5,7 @@
5
5
  import { initFolderTree, refreshFolderTree } from "./components/folder-tree.js";
6
6
  import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, getSelectedMessages } from "./components/message-list.js";
7
7
  import { showMessage, getCurrentMessage } from "./components/message-viewer.js";
8
- import { connectWebSocket, onWsEvent, triggerSync, getAccounts, getFolders, deleteMessage, undeleteMessage, restartServer, rebuildServer } from "./lib/api-client.js";
8
+ import { connectWebSocket, onWsEvent, triggerSync, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, rebuildServer } from "./lib/api-client.js";
9
9
  // ── New message badge (favicon + title) ──
10
10
  let baseTitle = "mailx";
11
11
  let lastSeenCount = 0;
@@ -309,10 +309,19 @@ async function deleteSelectedMessages() {
309
309
  if (lastRow)
310
310
  nextRow = (lastRow.nextElementSibling || lastRow.previousElementSibling);
311
311
  }
312
- // Delete all selected
312
+ // Delete all selected — bulk operation, one IMAP session
313
+ // Group by account
314
+ const byAccount = new Map();
315
+ for (const msg of selected) {
316
+ const uids = byAccount.get(msg.accountId) || [];
317
+ uids.push(msg.uid);
318
+ byAccount.set(msg.accountId, uids);
319
+ }
320
+ for (const [accountId, uids] of byAccount) {
321
+ await deleteMessages(accountId, uids);
322
+ }
323
+ // Remove rows from DOM
313
324
  for (const msg of selected) {
314
- await deleteMessage(msg.accountId, msg.uid);
315
- // Remove row from DOM
316
325
  if (mlBody)
317
326
  mlBody.querySelector(`.ml-row[data-uid="${msg.uid}"]`)?.remove();
318
327
  }
@@ -2,7 +2,7 @@
2
2
  * Folder tree component -- renders account folders with hierarchy,
3
3
  * expand/collapse, and optional unified inbox.
4
4
  */
5
- import { getAccounts, getFolders, moveMessage, markFolderRead, createFolder, renameFolder, deleteFolder, emptyFolder } from "../lib/api-client.js";
5
+ import { getAccounts, getFolders, moveMessage, moveMessages, markFolderRead, createFolder, renameFolder, deleteFolder, emptyFolder } from "../lib/api-client.js";
6
6
  import { showContextMenu } from "./context-menu.js";
7
7
  let onFolderSelect;
8
8
  let onUnifiedInbox = null;
@@ -257,12 +257,20 @@ function renderNode(node, container, depth) {
257
257
  const statusEl = document.getElementById("status-sync");
258
258
  const crossAccount = toMove.some(m => m.accountId !== node.accountId);
259
259
  try {
260
- let moved = 0;
261
- for (const msg of toMove) {
262
- const targetAccountId = msg.accountId !== node.accountId ? node.accountId : undefined;
263
- await moveMessage(msg.accountId, msg.uid, node.id, targetAccountId);
264
- moved++;
260
+ if (crossAccount) {
261
+ // Cross-account: must do one at a time
262
+ for (const msg of toMove) {
263
+ const targetAccountId = msg.accountId !== node.accountId ? node.accountId : undefined;
264
+ await moveMessage(msg.accountId, msg.uid, node.id, targetAccountId);
265
+ }
266
+ }
267
+ else {
268
+ // Same account: bulk move
269
+ const accountId = toMove[0].accountId;
270
+ const uids = toMove.map(m => m.uid);
271
+ await moveMessages(accountId, uids, node.id);
265
272
  }
273
+ const moved = toMove.length;
266
274
  if (statusEl)
267
275
  statusEl.textContent = `Moved ${moved} message${moved > 1 ? "s" : ""} to ${node.name}`;
268
276
  const treeContainer = document.getElementById("folder-tree");
@@ -125,6 +125,27 @@ export function deleteMessage(accountId, uid) {
125
125
  return mailxapi.deleteMessage?.(accountId, uid);
126
126
  return api(`/message/${accountId}/${uid}`, { method: "DELETE" });
127
127
  }
128
+ export function deleteMessages(accountId, uids) {
129
+ if (uids.length === 1)
130
+ return deleteMessage(accountId, uids[0]);
131
+ if (hasIPC)
132
+ return mailxapi.deleteMessages?.(accountId, uids);
133
+ return api("/messages/delete", {
134
+ method: "POST", body: JSON.stringify({ accountId, uids })
135
+ });
136
+ }
137
+ export function moveMessages(accountId, uids, targetFolderId, targetAccountId) {
138
+ if (uids.length === 1)
139
+ return moveMessage(accountId, uids[0], targetFolderId, targetAccountId);
140
+ if (hasIPC)
141
+ return mailxapi.moveMessages?.(accountId, uids, targetFolderId, targetAccountId);
142
+ const body = { accountId, uids, targetFolderId };
143
+ if (targetAccountId)
144
+ body.targetAccountId = targetAccountId;
145
+ return api("/messages/move", {
146
+ method: "POST", body: JSON.stringify(body)
147
+ });
148
+ }
128
149
  export function undeleteMessage(accountId, uid, folderId) {
129
150
  if (hasIPC)
130
151
  return mailxapi.undeleteMessage?.(accountId, uid, folderId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.109",
3
+ "version": "1.0.111",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -20,7 +20,7 @@
20
20
  "postinstall": "node launcher/builder/postinstall.js"
21
21
  },
22
22
  "dependencies": {
23
- "@bobfrankston/iflow": "^1.0.42",
23
+ "@bobfrankston/iflow": "^1.0.44",
24
24
  "@bobfrankston/miscinfo": "^1.0.7",
25
25
  "@bobfrankston/oauthsupport": "^1.0.20",
26
26
  "@bobfrankston/rust-builder": "^0.1.3",
@@ -148,7 +148,36 @@ export function createApiRouter(db, imapManager) {
148
148
  res.status(500).json({ error: e.message });
149
149
  }
150
150
  });
151
- // ── Delete ──
151
+ // ── Bulk operations ──
152
+ router.post("/messages/delete", async (req, res) => {
153
+ try {
154
+ const { accountId, uids } = req.body;
155
+ await svc.deleteMessages(accountId, uids);
156
+ res.json({ ok: true, count: uids.length });
157
+ }
158
+ catch (e) {
159
+ res.status(500).json({ error: e.message });
160
+ }
161
+ });
162
+ router.post("/messages/move", async (req, res) => {
163
+ try {
164
+ const { accountId, uids, targetFolderId, targetAccountId } = req.body;
165
+ if (targetAccountId && targetAccountId !== accountId) {
166
+ // Cross-account: must do one at a time
167
+ for (const uid of uids) {
168
+ await svc.moveMessage(accountId, uid, targetFolderId, targetAccountId);
169
+ }
170
+ }
171
+ else {
172
+ await svc.moveMessages(accountId, uids, targetFolderId);
173
+ }
174
+ res.json({ ok: true, count: uids.length });
175
+ }
176
+ catch (e) {
177
+ res.status(500).json({ error: e.message });
178
+ }
179
+ });
180
+ // ── Delete (single — kept for backward compat) ──
152
181
  router.delete("/message/:accountId/:uid", async (req, res) => {
153
182
  try {
154
183
  await svc.deleteMessage(req.params.accountId, Number(req.params.uid));
@@ -86,6 +86,19 @@ export declare class ImapManager extends EventEmitter {
86
86
  fetchMessageBody(accountId: string, folderId: number, uid: number): Promise<Buffer | null>;
87
87
  /** Get the body store for direct access */
88
88
  getBodyStore(): FileMessageStore;
89
+ /** Bulk trash messages — local-first, single IMAP connection for all */
90
+ trashMessages(accountId: string, messages: {
91
+ uid: number;
92
+ folderId: number;
93
+ }[]): Promise<void>;
94
+ /** Bulk move messages — local-first, single IMAP connection for all */
95
+ moveMessages(accountId: string, messages: {
96
+ uid: number;
97
+ folderId: number;
98
+ }[], targetFolderId: number): Promise<void>;
99
+ /** Debounced sync actions — batches rapid local changes into one IMAP operation */
100
+ private syncActionTimers;
101
+ private debounceSyncActions;
89
102
  /** Move a message to Trash (delete) — local-first, queues IMAP sync */
90
103
  trashMessage(accountId: string, folderId: number, uid: number): Promise<void>;
91
104
  /** Move a message between folders — local-first, queues IMAP sync */
@@ -822,6 +822,56 @@ export class ImapManager extends EventEmitter {
822
822
  getBodyStore() {
823
823
  return this.bodyStore;
824
824
  }
825
+ /** Bulk trash messages — local-first, single IMAP connection for all */
826
+ async trashMessages(accountId, messages) {
827
+ if (messages.length === 0)
828
+ return;
829
+ const trash = this.findFolder(accountId, "trash");
830
+ // Local first — remove all from DB immediately
831
+ for (const msg of messages) {
832
+ this.db.deleteMessage(accountId, msg.uid);
833
+ this.bodyStore.deleteMessage(accountId, msg.folderId, msg.uid).catch(() => { });
834
+ }
835
+ console.log(` Deleted ${messages.length} messages locally`);
836
+ // Queue IMAP actions
837
+ for (const msg of messages) {
838
+ if (trash && trash.id !== msg.folderId) {
839
+ this.db.queueSyncAction(accountId, "move", msg.uid, msg.folderId, { targetFolderId: trash.id });
840
+ }
841
+ else {
842
+ this.db.queueSyncAction(accountId, "delete", msg.uid, msg.folderId);
843
+ }
844
+ }
845
+ // Process all queued actions in one IMAP session
846
+ this.debounceSyncActions(accountId);
847
+ }
848
+ /** Bulk move messages — local-first, single IMAP connection for all */
849
+ async moveMessages(accountId, messages, targetFolderId) {
850
+ if (messages.length === 0)
851
+ return;
852
+ // Local first
853
+ for (const msg of messages) {
854
+ this.db.deleteMessage(accountId, msg.uid);
855
+ }
856
+ console.log(` Moved ${messages.length} messages locally (→ folder ${targetFolderId})`);
857
+ // Queue IMAP actions
858
+ for (const msg of messages) {
859
+ this.db.queueSyncAction(accountId, "move", msg.uid, msg.folderId, { targetFolderId });
860
+ }
861
+ // Process all queued actions in one IMAP session
862
+ this.debounceSyncActions(accountId);
863
+ }
864
+ /** Debounced sync actions — batches rapid local changes into one IMAP operation */
865
+ syncActionTimers = new Map();
866
+ debounceSyncActions(accountId) {
867
+ const existing = this.syncActionTimers.get(accountId);
868
+ if (existing)
869
+ clearTimeout(existing);
870
+ this.syncActionTimers.set(accountId, setTimeout(() => {
871
+ this.syncActionTimers.delete(accountId);
872
+ this.processSyncActions(accountId).catch(() => { });
873
+ }, 1000));
874
+ }
825
875
  /** Move a message to Trash (delete) — local-first, queues IMAP sync */
826
876
  async trashMessage(accountId, folderId, uid) {
827
877
  const trash = this.findFolder(accountId, "trash");
@@ -836,8 +886,8 @@ export class ImapManager extends EventEmitter {
836
886
  else {
837
887
  this.db.queueSyncAction(accountId, "delete", uid, folderId);
838
888
  }
839
- // Try immediate sync
840
- this.processSyncActions(accountId).catch(() => { });
889
+ // Debounced sync — batches multiple deletes into one IMAP session
890
+ this.debounceSyncActions(accountId);
841
891
  }
842
892
  /** Move a message between folders — local-first, queues IMAP sync */
843
893
  async moveMessage(accountId, uid, fromFolderId, toFolderId) {
@@ -846,8 +896,8 @@ export class ImapManager extends EventEmitter {
846
896
  console.log(` Moved UID ${uid} locally (folder ${fromFolderId} → ${toFolderId})`);
847
897
  // Queue IMAP action
848
898
  this.db.queueSyncAction(accountId, "move", uid, fromFolderId, { targetFolderId: toFolderId });
849
- // Try immediate sync
850
- this.processSyncActions(accountId).catch(() => { });
899
+ // Debounced sync — batches multiple moves into one IMAP session
900
+ this.debounceSyncActions(accountId);
851
901
  }
852
902
  /** Move message across accounts using iflow's moveMessageToServer */
853
903
  async moveMessageCrossAccount(fromAccountId, uid, fromFolderId, toAccountId, toFolderId) {
@@ -32,7 +32,9 @@ export declare class MailxService {
32
32
  reauthenticate(accountId: string): Promise<boolean>;
33
33
  send(msg: any): Promise<void>;
34
34
  deleteMessage(accountId: string, uid: number): Promise<void>;
35
+ deleteMessages(accountId: string, uids: number[]): Promise<void>;
35
36
  moveMessage(accountId: string, uid: number, targetFolderId: number, targetAccountId?: string): Promise<void>;
37
+ moveMessages(accountId: string, uids: number[], targetFolderId: number): Promise<void>;
36
38
  undeleteMessage(accountId: string, uid: number, folderId: number): Promise<void>;
37
39
  deleteOnServer(accountId: string, folderPath: string, uid: number): Promise<void>;
38
40
  createFolder(accountId: string, parentPath: string, name: string): Promise<void>;
@@ -300,6 +300,15 @@ export class MailxService {
300
300
  throw new Error("Message not found");
301
301
  await this.imapManager.trashMessage(accountId, envelope.folderId, envelope.uid);
302
302
  }
303
+ async deleteMessages(accountId, uids) {
304
+ const messages = uids.map(uid => {
305
+ const env = this.db.getMessageByUid(accountId, uid);
306
+ if (!env)
307
+ return null;
308
+ return { uid: env.uid, folderId: env.folderId };
309
+ }).filter(m => m !== null);
310
+ await this.imapManager.trashMessages(accountId, messages);
311
+ }
303
312
  async moveMessage(accountId, uid, targetFolderId, targetAccountId) {
304
313
  const envelope = this.db.getMessageByUid(accountId, uid);
305
314
  if (!envelope)
@@ -311,6 +320,15 @@ export class MailxService {
311
320
  await this.imapManager.moveMessage(accountId, envelope.uid, envelope.folderId, targetFolderId);
312
321
  }
313
322
  }
323
+ async moveMessages(accountId, uids, targetFolderId) {
324
+ const messages = uids.map(uid => {
325
+ const env = this.db.getMessageByUid(accountId, uid);
326
+ if (!env)
327
+ return null;
328
+ return { uid: env.uid, folderId: env.folderId };
329
+ }).filter(m => m !== null);
330
+ await this.imapManager.moveMessages(accountId, messages, targetFolderId);
331
+ }
314
332
  async undeleteMessage(accountId, uid, folderId) {
315
333
  await this.imapManager.undeleteMessage(accountId, uid, folderId);
316
334
  }
package/rebuild.cmd CHANGED
@@ -1,4 +1,6 @@
1
- cld
2
- call npmglobalize
3
- call killmail.cmd
4
- launch.ps1
1
+ setlocal
2
+ : set MAILX_NATIVE_IMAP=1
3
+ call npmglobalize
4
+ call killmail.cmd
5
+ launch.ps1
6
+ endlocal