@bobfrankston/mailx 1.0.110 → 1.0.112
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 +13 -4
- package/client/components/folder-tree.js +14 -6
- package/client/lib/api-client.js +21 -0
- package/package.json +2 -2
- package/packages/mailx-api/index.js +30 -1
- package/packages/mailx-imap/index.d.ts +13 -0
- package/packages/mailx-imap/index.js +54 -4
- package/packages/mailx-service/index.d.ts +2 -0
- package/packages/mailx-service/index.js +18 -0
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,
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
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");
|
package/client/lib/api-client.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "1.0.112",
|
|
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.
|
|
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
|
-
// ──
|
|
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
|
-
//
|
|
840
|
-
this.
|
|
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
|
-
//
|
|
850
|
-
this.
|
|
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
|
}
|