@bobfrankston/mailx 1.0.169 → 1.0.172
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 +9 -40
- package/client/components/folder-tree.js +5 -2
- package/client/components/message-list.js +95 -75
- package/client/components/message-viewer.js +31 -0
- package/client/lib/message-state.js +83 -0
- package/package.json +3 -3
- package/packages/mailx-imap/index.d.ts +14 -0
- package/packages/mailx-imap/index.js +254 -22
- package/packages/mailx-imap/providers/gmail-api.d.ts +28 -0
- package/packages/mailx-imap/providers/gmail-api.js +227 -0
- package/packages/mailx-imap/providers/types.d.ts +60 -0
- package/packages/mailx-imap/providers/types.js +6 -0
- package/packages/mailx-settings/index.d.ts +3 -0
- package/packages/mailx-settings/index.js +6 -0
- package/packages/mailx-types/index.d.ts +1 -0
package/client/app.js
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { initFolderTree, refreshFolderTree, updateFolderCounts } from "./components/folder-tree.js";
|
|
6
6
|
import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, getSelectedMessages } from "./components/message-list.js";
|
|
7
|
-
import { showMessage, getCurrentMessage } from "./components/message-viewer.js";
|
|
7
|
+
import { showMessage, getCurrentMessage, initViewer } from "./components/message-viewer.js";
|
|
8
8
|
import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, rebuildServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts } from "./lib/api-client.js";
|
|
9
|
+
import * as messageState from "./lib/message-state.js";
|
|
9
10
|
// ── New message badge (favicon + title) ──
|
|
10
11
|
let baseTitle = "mailx";
|
|
11
12
|
let lastSeenCount = 0;
|
|
@@ -117,15 +118,7 @@ alertDismiss?.addEventListener("click", hideAlert);
|
|
|
117
118
|
const folderTree = document.getElementById("folder-tree");
|
|
118
119
|
let currentFolderSpecialUse = "";
|
|
119
120
|
function clearViewer() {
|
|
120
|
-
|
|
121
|
-
const header = document.getElementById("mv-header");
|
|
122
|
-
const att = document.getElementById("mv-attachments");
|
|
123
|
-
if (body)
|
|
124
|
-
body.innerHTML = "";
|
|
125
|
-
if (header)
|
|
126
|
-
header.hidden = true;
|
|
127
|
-
if (att)
|
|
128
|
-
att.hidden = true;
|
|
121
|
+
messageState.select(null); // Deselect — viewer clears via subscription
|
|
129
122
|
}
|
|
130
123
|
initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
|
|
131
124
|
currentFolderSpecialUse = specialUse;
|
|
@@ -152,6 +145,7 @@ initMessageList((accountId, uid, folderId) => {
|
|
|
152
145
|
document.getElementById("message-list")?.classList.add("narrow-hidden");
|
|
153
146
|
}
|
|
154
147
|
});
|
|
148
|
+
initViewer();
|
|
155
149
|
// ── Auto two-line when message list is narrow ──
|
|
156
150
|
const messageList = document.getElementById("message-list");
|
|
157
151
|
if (messageList) {
|
|
@@ -320,17 +314,8 @@ async function deleteSelectedMessages() {
|
|
|
320
314
|
selected.push({ accountId: current.accountId, uid: current.message.uid, folderId: current.message.folderId });
|
|
321
315
|
}
|
|
322
316
|
const statusSync = document.getElementById("status-sync");
|
|
323
|
-
const mlBody = document.getElementById("ml-body");
|
|
324
317
|
try {
|
|
325
|
-
//
|
|
326
|
-
let nextRow = null;
|
|
327
|
-
if (mlBody) {
|
|
328
|
-
const lastRow = mlBody.querySelector(`.ml-row[data-uid="${selected[selected.length - 1].uid}"]`);
|
|
329
|
-
if (lastRow)
|
|
330
|
-
nextRow = (lastRow.nextElementSibling || lastRow.previousElementSibling);
|
|
331
|
-
}
|
|
332
|
-
// Delete all selected — bulk operation, one IMAP session
|
|
333
|
-
// Group by account
|
|
318
|
+
// Delete on server — group by account for bulk operations
|
|
334
319
|
const byAccount = new Map();
|
|
335
320
|
for (const msg of selected) {
|
|
336
321
|
const uids = byAccount.get(msg.accountId) || [];
|
|
@@ -340,11 +325,6 @@ async function deleteSelectedMessages() {
|
|
|
340
325
|
for (const [accountId, uids] of byAccount) {
|
|
341
326
|
await deleteMessages(accountId, uids);
|
|
342
327
|
}
|
|
343
|
-
// Remove rows from DOM
|
|
344
|
-
for (const msg of selected) {
|
|
345
|
-
if (mlBody)
|
|
346
|
-
mlBody.querySelector(`.ml-row[data-uid="${msg.uid}"]`)?.remove();
|
|
347
|
-
}
|
|
348
328
|
// Undo supports the last batch
|
|
349
329
|
if (selected.length === 1) {
|
|
350
330
|
lastDeleted = { ...selected[0], subject: "" };
|
|
@@ -352,7 +332,7 @@ async function deleteSelectedMessages() {
|
|
|
352
332
|
statusSync.textContent = `Deleted 1 message — Ctrl+Z to undo`;
|
|
353
333
|
}
|
|
354
334
|
else {
|
|
355
|
-
lastDeleted = null;
|
|
335
|
+
lastDeleted = null;
|
|
356
336
|
if (statusSync)
|
|
357
337
|
statusSync.textContent = `Deleted ${selected.length} messages`;
|
|
358
338
|
}
|
|
@@ -363,18 +343,8 @@ async function deleteSelectedMessages() {
|
|
|
363
343
|
if (statusSync?.textContent?.includes("undo"))
|
|
364
344
|
statusSync.textContent = "";
|
|
365
345
|
}, 30000);
|
|
366
|
-
//
|
|
367
|
-
|
|
368
|
-
nextRow.click();
|
|
369
|
-
}
|
|
370
|
-
else {
|
|
371
|
-
const bodyEl = document.getElementById("mv-body");
|
|
372
|
-
const headerEl = document.getElementById("mv-header");
|
|
373
|
-
if (bodyEl)
|
|
374
|
-
bodyEl.innerHTML = `<div class="mv-empty">Select a message to read</div>`;
|
|
375
|
-
if (headerEl)
|
|
376
|
-
headerEl.hidden = true;
|
|
377
|
-
}
|
|
346
|
+
// Remove from shared state — list and viewer update automatically
|
|
347
|
+
messageState.removeMessages(selected);
|
|
378
348
|
}
|
|
379
349
|
catch (e) {
|
|
380
350
|
console.error(`Delete failed: ${e.message}`);
|
|
@@ -454,8 +424,7 @@ searchInput?.addEventListener("keydown", (e) => {
|
|
|
454
424
|
reloadCurrentFolder();
|
|
455
425
|
}
|
|
456
426
|
});
|
|
457
|
-
//
|
|
458
|
-
document.addEventListener("mailx-message-moved", () => reloadCurrentFolder());
|
|
427
|
+
// Message state handles move/delete — no manual event listener needed
|
|
459
428
|
// ── Folder filter ──
|
|
460
429
|
const ftFilterInput = document.getElementById("ft-filter-input");
|
|
461
430
|
if (ftFilterInput) {
|
|
@@ -248,10 +248,11 @@ function renderNode(node, container, depth) {
|
|
|
248
248
|
return;
|
|
249
249
|
try {
|
|
250
250
|
await emptyFolder(node.accountId, node.id);
|
|
251
|
+
const { setMessages } = await import("../lib/message-state.js");
|
|
252
|
+
setMessages([]); // Folder emptied — clear list and viewer
|
|
251
253
|
const treeContainer = document.getElementById("folder-tree");
|
|
252
254
|
if (treeContainer)
|
|
253
255
|
loadFolderTree(treeContainer);
|
|
254
|
-
document.dispatchEvent(new CustomEvent("mailx-message-moved"));
|
|
255
256
|
}
|
|
256
257
|
catch (err) {
|
|
257
258
|
alert(`Failed: ${err.message}`);
|
|
@@ -298,10 +299,12 @@ function renderNode(node, container, depth) {
|
|
|
298
299
|
const moved = toMove.length;
|
|
299
300
|
if (statusEl)
|
|
300
301
|
statusEl.textContent = `Moved ${moved} message${moved > 1 ? "s" : ""} to ${node.name}`;
|
|
302
|
+
// Remove from shared state — list and viewer update automatically
|
|
303
|
+
const { removeMessages } = await import("../lib/message-state.js");
|
|
304
|
+
removeMessages(toMove);
|
|
301
305
|
const treeContainer = document.getElementById("folder-tree");
|
|
302
306
|
if (treeContainer)
|
|
303
307
|
loadFolderTree(treeContainer);
|
|
304
|
-
document.dispatchEvent(new CustomEvent("mailx-message-moved"));
|
|
305
308
|
}
|
|
306
309
|
catch (err) {
|
|
307
310
|
console.error(`Move failed: ${err.message}`);
|
|
@@ -1,17 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Message list component
|
|
3
|
-
*
|
|
2
|
+
* Message list component — renders paginated message rows.
|
|
3
|
+
* Reads from message-state; operations mutate state, list reacts.
|
|
4
4
|
*/
|
|
5
|
-
import { getMessages, getUnifiedInbox, searchMessages, updateFlags } from "../lib/api-client.js";
|
|
6
|
-
|
|
7
|
-
function clearViewer() {
|
|
8
|
-
const bodyEl = document.getElementById("mv-body");
|
|
9
|
-
const headerEl = document.getElementById("mv-header");
|
|
10
|
-
if (bodyEl)
|
|
11
|
-
bodyEl.innerHTML = `<div class="mv-empty">Select a message to read</div>`;
|
|
12
|
-
if (headerEl)
|
|
13
|
-
headerEl.hidden = true;
|
|
14
|
-
}
|
|
5
|
+
import { getMessages as apiGetMessages, getUnifiedInbox as apiGetUnifiedInbox, searchMessages, updateFlags } from "../lib/api-client.js";
|
|
6
|
+
import * as state from "../lib/message-state.js";
|
|
15
7
|
let onMessageSelect;
|
|
16
8
|
let currentAccountId;
|
|
17
9
|
let currentFolderId;
|
|
@@ -71,6 +63,48 @@ export function initMessageList(handler) {
|
|
|
71
63
|
}
|
|
72
64
|
});
|
|
73
65
|
}
|
|
66
|
+
// Subscribe to state changes — react to removeMessages (move/delete)
|
|
67
|
+
state.subscribe((change) => {
|
|
68
|
+
if (change === "removed") {
|
|
69
|
+
syncDomToState();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Sync DOM rows to current state after messages are removed.
|
|
75
|
+
* Removes DOM rows that are no longer in state, updates selection.
|
|
76
|
+
*/
|
|
77
|
+
function syncDomToState() {
|
|
78
|
+
const body = document.getElementById("ml-body");
|
|
79
|
+
if (!body)
|
|
80
|
+
return;
|
|
81
|
+
// Build set of UIDs still in state
|
|
82
|
+
const stateUids = new Set(state.getMessages().map(m => `${m.accountId}:${m.uid}`));
|
|
83
|
+
// Remove rows not in state
|
|
84
|
+
for (const row of Array.from(body.querySelectorAll(".ml-row"))) {
|
|
85
|
+
const el = row;
|
|
86
|
+
const key = `${el.dataset.accountId}:${el.dataset.uid}`;
|
|
87
|
+
if (!stateUids.has(key)) {
|
|
88
|
+
el.remove();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Update selection to match state
|
|
92
|
+
clearSelection();
|
|
93
|
+
const sel = state.getSelected();
|
|
94
|
+
if (sel) {
|
|
95
|
+
const row = body.querySelector(`.ml-row[data-uid="${sel.uid}"][data-account-id="${sel.accountId}"]`)
|
|
96
|
+
|| body.querySelector(`.ml-row[data-uid="${sel.uid}"]`);
|
|
97
|
+
if (row) {
|
|
98
|
+
row.classList.add("selected");
|
|
99
|
+
lastClickedRow = row;
|
|
100
|
+
// Trigger viewer update
|
|
101
|
+
onMessageSelect(sel.accountId, sel.uid, sel.folderId);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// If no messages left, show empty
|
|
105
|
+
if (state.getMessages().length === 0) {
|
|
106
|
+
body.innerHTML = `<div class="ml-empty">No messages</div>`;
|
|
107
|
+
}
|
|
74
108
|
}
|
|
75
109
|
/** Reload the currently displayed folder (preserves current selection) */
|
|
76
110
|
export function reloadCurrentFolder() {
|
|
@@ -86,8 +120,6 @@ export function reloadCurrentFolder() {
|
|
|
86
120
|
}
|
|
87
121
|
/** Load unified inbox (all accounts) */
|
|
88
122
|
export async function loadUnifiedInbox(autoSelect = true) {
|
|
89
|
-
if (autoSelect)
|
|
90
|
-
clearViewer();
|
|
91
123
|
unifiedMode = true;
|
|
92
124
|
currentPage = 1;
|
|
93
125
|
totalMessages = 0;
|
|
@@ -96,36 +128,25 @@ export async function loadUnifiedInbox(autoSelect = true) {
|
|
|
96
128
|
return;
|
|
97
129
|
const savedScroll = !autoSelect ? body.scrollTop : 0;
|
|
98
130
|
const savedUid = !autoSelect ? body.querySelector(".ml-row.selected")?.getAttribute("data-uid") : null;
|
|
99
|
-
// Only show loading indicator on fresh navigation, not reloads
|
|
100
131
|
if (autoSelect) {
|
|
101
132
|
body.innerHTML = `<div class="ml-empty">Loading...</div>`;
|
|
102
133
|
}
|
|
103
134
|
try {
|
|
104
|
-
const result = await
|
|
135
|
+
const result = await apiGetUnifiedInbox(1);
|
|
105
136
|
totalMessages = result.total;
|
|
106
137
|
if (result.items.length === 0) {
|
|
138
|
+
state.setMessages([]);
|
|
107
139
|
body.innerHTML = `<div class="ml-empty">${result.total > 0 ? `${result.total} messages syncing...` : "Syncing — messages will appear shortly"}</div>`;
|
|
108
140
|
return;
|
|
109
141
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const tempDiv = document.createElement("div");
|
|
113
|
-
appendMessages(tempDiv, "", result.items);
|
|
114
|
-
while (tempDiv.firstChild)
|
|
115
|
-
fragment.appendChild(tempDiv.firstChild);
|
|
116
|
-
body.replaceChildren(fragment);
|
|
142
|
+
state.setMessages(result.items);
|
|
143
|
+
renderMessages(body, "", result.items);
|
|
117
144
|
if (autoSelect) {
|
|
118
|
-
|
|
119
|
-
if (firstRow)
|
|
120
|
-
firstRow.click();
|
|
145
|
+
selectFirst(body);
|
|
121
146
|
}
|
|
122
147
|
else {
|
|
123
148
|
body.scrollTop = savedScroll;
|
|
124
|
-
|
|
125
|
-
const row = body.querySelector(`.ml-row[data-uid="${savedUid}"]`);
|
|
126
|
-
if (row)
|
|
127
|
-
row.classList.add("selected");
|
|
128
|
-
}
|
|
149
|
+
restoreSelection(body, savedUid);
|
|
129
150
|
}
|
|
130
151
|
}
|
|
131
152
|
catch (e) {
|
|
@@ -157,29 +178,29 @@ export async function loadSearchResults(query, scope = "all", accountId = "", fo
|
|
|
157
178
|
body.innerHTML = `<div class="ml-empty">Invalid regex</div>`;
|
|
158
179
|
return;
|
|
159
180
|
}
|
|
160
|
-
// Get all messages from current context and filter
|
|
161
181
|
const source = scope === "current" && accountId
|
|
162
|
-
? await
|
|
182
|
+
? await apiGetMessages(accountId, folderId, 1, 10000)
|
|
163
183
|
: await searchMessages("*", 1, 10000, "all");
|
|
164
184
|
const matches = source.items.filter((m) => regex.test(m.subject || "") || regex.test(m.from?.name || "") || regex.test(m.from?.address || "") || regex.test(m.preview || ""));
|
|
165
185
|
totalMessages = matches.length;
|
|
186
|
+
state.setMessages(matches);
|
|
166
187
|
if (matches.length === 0) {
|
|
167
188
|
body.innerHTML = `<div class="ml-empty">No regex matches</div>`;
|
|
168
189
|
return;
|
|
169
190
|
}
|
|
170
191
|
body.innerHTML = "";
|
|
171
192
|
appendMessages(body, "", matches);
|
|
172
|
-
|
|
173
|
-
if (firstRow)
|
|
174
|
-
firstRow.click();
|
|
193
|
+
selectFirst(body);
|
|
175
194
|
return;
|
|
176
195
|
}
|
|
177
196
|
const result = await searchMessages(query, 1, 50, scope, accountId, folderId);
|
|
178
197
|
totalMessages = result.total;
|
|
179
198
|
if (result.items.length === 0) {
|
|
199
|
+
state.setMessages([]);
|
|
180
200
|
body.innerHTML = `<div class="ml-empty">No results for "${query}"</div>`;
|
|
181
201
|
return;
|
|
182
202
|
}
|
|
203
|
+
state.setMessages(result.items);
|
|
183
204
|
body.innerHTML = "";
|
|
184
205
|
appendMessages(body, "", result.items);
|
|
185
206
|
}
|
|
@@ -188,9 +209,6 @@ export async function loadSearchResults(query, scope = "all", accountId = "", fo
|
|
|
188
209
|
}
|
|
189
210
|
}
|
|
190
211
|
export async function loadMessages(accountId, folderId, page = 1, specialUse = "", autoSelect = true) {
|
|
191
|
-
// Clear viewer when navigating to a new folder (not on reloads)
|
|
192
|
-
if (autoSelect)
|
|
193
|
-
clearViewer();
|
|
194
212
|
searchMode = false;
|
|
195
213
|
unifiedMode = false;
|
|
196
214
|
showToInsteadOfFrom = ["sent", "drafts", "outbox"].includes(specialUse) ||
|
|
@@ -206,49 +224,34 @@ export async function loadMessages(accountId, folderId, page = 1, specialUse = "
|
|
|
206
224
|
const fromHeader = document.querySelector(".ml-col-from");
|
|
207
225
|
if (fromHeader)
|
|
208
226
|
fromHeader.textContent = showToInsteadOfFrom ? "To" : "From";
|
|
209
|
-
// Save scroll position and selected UID for non-autoSelect reloads
|
|
210
227
|
const savedScroll = !autoSelect ? body.scrollTop : 0;
|
|
211
228
|
const savedUid = !autoSelect ? body.querySelector(".ml-row.selected")?.getAttribute("data-uid") : null;
|
|
212
|
-
// Only show loading indicator on fresh navigation, not reloads
|
|
213
229
|
if (autoSelect) {
|
|
214
230
|
body.innerHTML = `<div class="ml-empty">Loading...</div>`;
|
|
215
231
|
}
|
|
216
232
|
try {
|
|
217
|
-
const result = await
|
|
233
|
+
const result = await apiGetMessages(accountId, folderId, 1);
|
|
218
234
|
totalMessages = result.total;
|
|
219
235
|
if (result.items.length === 0) {
|
|
236
|
+
state.setMessages([]);
|
|
220
237
|
body.innerHTML = `<div class="ml-empty">No messages</div>`;
|
|
221
|
-
clearViewer();
|
|
222
238
|
return;
|
|
223
239
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const tempDiv = document.createElement("div");
|
|
227
|
-
appendMessages(tempDiv, accountId, result.items);
|
|
228
|
-
while (tempDiv.firstChild)
|
|
229
|
-
fragment.appendChild(tempDiv.firstChild);
|
|
230
|
-
body.replaceChildren(fragment);
|
|
240
|
+
state.setMessages(result.items);
|
|
241
|
+
renderMessages(body, accountId, result.items);
|
|
231
242
|
if (autoSelect) {
|
|
232
|
-
|
|
233
|
-
const firstRow = body.querySelector(".ml-row");
|
|
234
|
-
if (firstRow)
|
|
235
|
-
firstRow.click();
|
|
243
|
+
selectFirst(body);
|
|
236
244
|
}
|
|
237
245
|
else {
|
|
238
|
-
// Sync reload — restore scroll position and selection after layout
|
|
239
246
|
requestAnimationFrame(() => {
|
|
240
247
|
body.scrollTop = savedScroll;
|
|
241
|
-
|
|
242
|
-
const row = body.querySelector(`.ml-row[data-uid="${savedUid}"]`);
|
|
243
|
-
if (row)
|
|
244
|
-
row.classList.add("selected");
|
|
245
|
-
}
|
|
248
|
+
restoreSelection(body, savedUid);
|
|
246
249
|
});
|
|
247
250
|
}
|
|
248
251
|
}
|
|
249
252
|
catch (e) {
|
|
250
253
|
if (e.name === "AbortError")
|
|
251
|
-
return;
|
|
254
|
+
return;
|
|
252
255
|
body.innerHTML = `<div class="ml-empty">Error: ${e.message}</div>`;
|
|
253
256
|
}
|
|
254
257
|
}
|
|
@@ -262,8 +265,11 @@ async function loadMoreMessages() {
|
|
|
262
265
|
const result = searchMode
|
|
263
266
|
? await searchMessages(currentSearchQuery, currentPage)
|
|
264
267
|
: unifiedMode
|
|
265
|
-
? await
|
|
266
|
-
: await
|
|
268
|
+
? await apiGetUnifiedInbox(currentPage)
|
|
269
|
+
: await apiGetMessages(currentAccountId, currentFolderId, currentPage);
|
|
270
|
+
// Append to state
|
|
271
|
+
const current = state.getMessages();
|
|
272
|
+
state.setMessages([...current, ...result.items]);
|
|
267
273
|
appendMessages(body, unifiedMode ? "" : currentAccountId, result.items);
|
|
268
274
|
}
|
|
269
275
|
catch (e) {
|
|
@@ -273,9 +279,29 @@ async function loadMoreMessages() {
|
|
|
273
279
|
loading = false;
|
|
274
280
|
}
|
|
275
281
|
}
|
|
282
|
+
/** Replace body contents with rendered rows */
|
|
283
|
+
function renderMessages(body, accountId, items) {
|
|
284
|
+
const fragment = document.createDocumentFragment();
|
|
285
|
+
const tempDiv = document.createElement("div");
|
|
286
|
+
appendMessages(tempDiv, accountId, items);
|
|
287
|
+
while (tempDiv.firstChild)
|
|
288
|
+
fragment.appendChild(tempDiv.firstChild);
|
|
289
|
+
body.replaceChildren(fragment);
|
|
290
|
+
}
|
|
291
|
+
function selectFirst(body) {
|
|
292
|
+
const firstRow = body.querySelector(".ml-row");
|
|
293
|
+
if (firstRow)
|
|
294
|
+
firstRow.click();
|
|
295
|
+
}
|
|
296
|
+
function restoreSelection(body, savedUid) {
|
|
297
|
+
if (savedUid) {
|
|
298
|
+
const row = body.querySelector(`.ml-row[data-uid="${savedUid}"]`);
|
|
299
|
+
if (row)
|
|
300
|
+
row.classList.add("selected");
|
|
301
|
+
}
|
|
302
|
+
}
|
|
276
303
|
function appendMessages(body, accountId, items) {
|
|
277
304
|
for (const msg of items) {
|
|
278
|
-
// Use per-message accountId for unified inbox, fallback to list-level accountId
|
|
279
305
|
const msgAccountId = msg.accountId || accountId;
|
|
280
306
|
const row = document.createElement("div");
|
|
281
307
|
row.className = "ml-row";
|
|
@@ -289,7 +315,7 @@ function appendMessages(body, accountId, items) {
|
|
|
289
315
|
row.dataset.folderId = String(msg.folderId);
|
|
290
316
|
const flag = document.createElement("span");
|
|
291
317
|
flag.className = "ml-flag";
|
|
292
|
-
flag.textContent = msg.flags.includes("\\Flagged") ? "\u2605" : "\u2606";
|
|
318
|
+
flag.textContent = msg.flags.includes("\\Flagged") ? "\u2605" : "\u2606";
|
|
293
319
|
flag.title = "Toggle flag";
|
|
294
320
|
const from = document.createElement("span");
|
|
295
321
|
from.className = "ml-from";
|
|
@@ -299,13 +325,11 @@ function appendMessages(body, accountId, items) {
|
|
|
299
325
|
else {
|
|
300
326
|
from.textContent = msg.from.name || msg.from.address;
|
|
301
327
|
}
|
|
302
|
-
// Account tag for unified inbox
|
|
303
328
|
if (!accountId && msgAccountId) {
|
|
304
329
|
const tag = document.createElement("span");
|
|
305
330
|
tag.className = "ml-account-tag";
|
|
306
|
-
|
|
307
|
-
tag.
|
|
308
|
-
tag.title = label;
|
|
331
|
+
tag.textContent = msgAccountId.charAt(0).toUpperCase();
|
|
332
|
+
tag.title = msgAccountId;
|
|
309
333
|
from.prepend(tag);
|
|
310
334
|
}
|
|
311
335
|
const subject = document.createElement("span");
|
|
@@ -341,25 +365,23 @@ function appendMessages(body, accountId, items) {
|
|
|
341
365
|
row.appendChild(subject);
|
|
342
366
|
row.addEventListener("click", (e) => {
|
|
343
367
|
if (e.shiftKey && lastClickedRow) {
|
|
344
|
-
// Shift+click: range select from last clicked to this row
|
|
345
368
|
clearSelection();
|
|
346
369
|
selectRange(lastClickedRow, row);
|
|
347
370
|
}
|
|
348
371
|
else if (e.ctrlKey || e.metaKey) {
|
|
349
|
-
// Ctrl+click: toggle this row
|
|
350
372
|
row.classList.toggle("selected");
|
|
351
373
|
}
|
|
352
374
|
else {
|
|
353
|
-
// Plain click: single select
|
|
354
375
|
clearSelection();
|
|
355
376
|
row.classList.add("selected");
|
|
356
377
|
}
|
|
357
378
|
lastClickedRow = row;
|
|
358
379
|
row.classList.remove("unread");
|
|
380
|
+
// Update shared state + notify viewer
|
|
381
|
+
state.select(msg);
|
|
359
382
|
onMessageSelect(msgAccountId, msg.uid, msg.folderId);
|
|
360
383
|
});
|
|
361
384
|
row.addEventListener("dragstart", (e) => {
|
|
362
|
-
// If dragging a non-selected row, select it first
|
|
363
385
|
if (!row.classList.contains("selected")) {
|
|
364
386
|
clearSelection();
|
|
365
387
|
row.classList.add("selected");
|
|
@@ -367,7 +389,6 @@ function appendMessages(body, accountId, items) {
|
|
|
367
389
|
}
|
|
368
390
|
const selected = getSelectedMessages();
|
|
369
391
|
e.dataTransfer.setData("application/x-mailx-messages", JSON.stringify(selected));
|
|
370
|
-
// Legacy single-message format for backwards compat
|
|
371
392
|
e.dataTransfer.setData("application/x-mailx-message", JSON.stringify({
|
|
372
393
|
accountId: msgAccountId,
|
|
373
394
|
uid: msg.uid,
|
|
@@ -376,7 +397,6 @@ function appendMessages(body, accountId, items) {
|
|
|
376
397
|
}));
|
|
377
398
|
e.dataTransfer.effectAllowed = "copyMove";
|
|
378
399
|
row.classList.add("dragging");
|
|
379
|
-
// Show drag count
|
|
380
400
|
if (selected.length > 1) {
|
|
381
401
|
const badge = document.createElement("div");
|
|
382
402
|
badge.textContent = `${selected.length} messages`;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Message viewer component -- displays full message in sandboxed iframe.
|
|
3
|
+
* Subscribes to message-state: clears when selected becomes null.
|
|
3
4
|
*/
|
|
4
5
|
import { getMessage, updateFlags, allowRemoteContent } from "../lib/api-client.js";
|
|
6
|
+
import * as state from "../lib/message-state.js";
|
|
5
7
|
/** Currently displayed message (for reply/forward) */
|
|
6
8
|
let currentMessage = null;
|
|
7
9
|
let currentAccountId = "";
|
|
@@ -12,6 +14,35 @@ export function getCurrentMessage() {
|
|
|
12
14
|
return null;
|
|
13
15
|
return { accountId: currentAccountId, message: currentMessage };
|
|
14
16
|
}
|
|
17
|
+
/** Initialize viewer — subscribe to state changes */
|
|
18
|
+
export function initViewer() {
|
|
19
|
+
state.subscribe((change) => {
|
|
20
|
+
if (change === "removed" || change === "messages") {
|
|
21
|
+
const sel = state.getSelected();
|
|
22
|
+
if (!sel) {
|
|
23
|
+
clearViewer();
|
|
24
|
+
}
|
|
25
|
+
else if (sel.uid !== currentMessage?.uid || sel.accountId !== currentAccountId) {
|
|
26
|
+
// State auto-selected a new message after removal — show it
|
|
27
|
+
showMessage(sel.accountId, sel.uid, sel.folderId);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function clearViewer() {
|
|
33
|
+
currentMessage = null;
|
|
34
|
+
currentAccountId = "";
|
|
35
|
+
showMessageGeneration++;
|
|
36
|
+
const headerEl = document.getElementById("mv-header");
|
|
37
|
+
const bodyEl = document.getElementById("mv-body");
|
|
38
|
+
const attEl = document.getElementById("mv-attachments");
|
|
39
|
+
if (bodyEl)
|
|
40
|
+
bodyEl.innerHTML = `<div class="mv-empty">Select a message to read</div>`;
|
|
41
|
+
if (headerEl)
|
|
42
|
+
headerEl.hidden = true;
|
|
43
|
+
if (attEl)
|
|
44
|
+
attEl.hidden = true;
|
|
45
|
+
}
|
|
15
46
|
export async function showMessage(accountId, uid, folderId, specialUse, isRetry = false) {
|
|
16
47
|
const gen = ++showMessageGeneration;
|
|
17
48
|
if (!isRetry)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared message state — single source of truth for the message list and viewer.
|
|
3
|
+
*
|
|
4
|
+
* The message list and viewer both reference message objects from this store.
|
|
5
|
+
* Operations (move, delete, sync) mutate the store; UI components subscribe
|
|
6
|
+
* and update themselves. No manual DOM cleanup, no custom events.
|
|
7
|
+
*/
|
|
8
|
+
/** The currently loaded messages (what the list shows) */
|
|
9
|
+
let messages = [];
|
|
10
|
+
/** The currently selected/viewed message (same ref as an entry in messages[]) */
|
|
11
|
+
let selected = null;
|
|
12
|
+
const listeners = [];
|
|
13
|
+
/** Subscribe to state changes. Returns unsubscribe function. */
|
|
14
|
+
export function subscribe(fn) {
|
|
15
|
+
listeners.push(fn);
|
|
16
|
+
return () => {
|
|
17
|
+
const i = listeners.indexOf(fn);
|
|
18
|
+
if (i >= 0)
|
|
19
|
+
listeners.splice(i, 1);
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function notify(change) {
|
|
23
|
+
for (const fn of listeners) {
|
|
24
|
+
try {
|
|
25
|
+
fn(change);
|
|
26
|
+
}
|
|
27
|
+
catch { /* don't let one subscriber break others */ }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Replace the entire message list (folder load, search, unified inbox) */
|
|
31
|
+
export function setMessages(msgs) {
|
|
32
|
+
messages = msgs;
|
|
33
|
+
// If the previously selected message is no longer in the list, deselect
|
|
34
|
+
if (selected && !messages.find(m => m.uid === selected.uid && m.accountId === selected.accountId)) {
|
|
35
|
+
selected = null;
|
|
36
|
+
}
|
|
37
|
+
notify("messages");
|
|
38
|
+
}
|
|
39
|
+
/** Get current messages */
|
|
40
|
+
export function getMessages() {
|
|
41
|
+
return messages;
|
|
42
|
+
}
|
|
43
|
+
/** Select a message (by reference or null to deselect) */
|
|
44
|
+
export function select(msg) {
|
|
45
|
+
selected = msg;
|
|
46
|
+
notify("selected");
|
|
47
|
+
}
|
|
48
|
+
/** Get the selected message */
|
|
49
|
+
export function getSelected() {
|
|
50
|
+
return selected;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Remove messages from the list (after move or delete).
|
|
54
|
+
* If the selected message is removed, auto-selects the next message or null.
|
|
55
|
+
*/
|
|
56
|
+
export function removeMessages(uids) {
|
|
57
|
+
const removeSet = new Set(uids.map(u => `${u.accountId}:${u.uid}`));
|
|
58
|
+
let selectedIdx = -1;
|
|
59
|
+
if (selected) {
|
|
60
|
+
selectedIdx = messages.findIndex(m => m.uid === selected.uid && m.accountId === selected.accountId);
|
|
61
|
+
}
|
|
62
|
+
const wasSelectedRemoved = selected && removeSet.has(`${selected.accountId}:${selected.uid}`);
|
|
63
|
+
messages = messages.filter(m => !removeSet.has(`${m.accountId}:${m.uid}`));
|
|
64
|
+
if (wasSelectedRemoved) {
|
|
65
|
+
// Auto-select next message (same index, or last, or null)
|
|
66
|
+
if (messages.length > 0) {
|
|
67
|
+
const nextIdx = Math.min(selectedIdx, messages.length - 1);
|
|
68
|
+
selected = messages[nextIdx];
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
selected = null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
notify("removed");
|
|
75
|
+
}
|
|
76
|
+
/** Update flags on a message in the list */
|
|
77
|
+
export function updateMessageFlags(accountId, uid, flags) {
|
|
78
|
+
const msg = messages.find(m => m.uid === uid && m.accountId === accountId);
|
|
79
|
+
if (msg)
|
|
80
|
+
msg.flags = flags;
|
|
81
|
+
// No notify — flag changes are cosmetic, handled inline by the list
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=message-state.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.172",
|
|
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,11 +20,11 @@
|
|
|
20
20
|
"postinstall": "node bin/postinstall.js"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
23
|
+
"@bobfrankston/iflow-direct": "^0.1.4",
|
|
24
24
|
"@bobfrankston/iflow-node": "^0.1.1",
|
|
25
25
|
"@bobfrankston/miscinfo": "^1.0.7",
|
|
26
26
|
"@bobfrankston/oauthsupport": "^1.0.20",
|
|
27
|
-
"@bobfrankston/msger": "^0.1.
|
|
27
|
+
"@bobfrankston/msger": "^0.1.221",
|
|
28
28
|
"@capacitor/android": "^8.3.0",
|
|
29
29
|
"@capacitor/cli": "^8.3.0",
|
|
30
30
|
"@capacitor/core": "^8.3.0",
|
|
@@ -73,6 +73,12 @@ export declare class ImapManager extends EventEmitter {
|
|
|
73
73
|
getAccountCount(): number;
|
|
74
74
|
/** Register an account */
|
|
75
75
|
addAccount(account: AccountConfig): Promise<void>;
|
|
76
|
+
/** Check if an account uses Gmail (should use API instead of IMAP) */
|
|
77
|
+
private isGmailAccount;
|
|
78
|
+
/** Get a Gmail API provider for an account (reuses tokenProvider from IMAP config) */
|
|
79
|
+
private getGmailProvider;
|
|
80
|
+
/** Convert ProviderMessage to the shape expected by storeMessages/upsertMessage */
|
|
81
|
+
private providerMsgToLocal;
|
|
76
82
|
/** Sync folder list for an account */
|
|
77
83
|
syncFolders(accountId: string, client?: any): Promise<Folder[]>;
|
|
78
84
|
/** Store a batch of messages to DB immediately — used by onChunk for incremental sync */
|
|
@@ -84,6 +90,12 @@ export declare class ImapManager extends EventEmitter {
|
|
|
84
90
|
private _syncAll;
|
|
85
91
|
/** Sync a single account — manages its own connection lifecycle */
|
|
86
92
|
private syncAccount;
|
|
93
|
+
/** Sync a Gmail account via REST API — no IMAP connections */
|
|
94
|
+
private syncAccountViaApi;
|
|
95
|
+
/** Sync a single folder via Gmail/Outlook API */
|
|
96
|
+
private syncFolderViaApi;
|
|
97
|
+
/** Store API-fetched messages to DB */
|
|
98
|
+
private storeApiMessages;
|
|
87
99
|
/** Kill and recreate the persistent ops connection */
|
|
88
100
|
private reconnectOps;
|
|
89
101
|
/** Handle sync errors — classify and emit appropriate UI events */
|
|
@@ -115,6 +127,8 @@ export declare class ImapManager extends EventEmitter {
|
|
|
115
127
|
private enqueueFetch;
|
|
116
128
|
/** Fetch a single message body on demand, caching in the store */
|
|
117
129
|
fetchMessageBody(accountId: string, folderId: number, uid: number): Promise<Buffer | null>;
|
|
130
|
+
/** Fetch message body via Gmail/Outlook API */
|
|
131
|
+
private fetchMessageBodyViaApi;
|
|
118
132
|
/** Get the body store for direct access */
|
|
119
133
|
getBodyStore(): FileMessageStore;
|
|
120
134
|
/** Bulk trash messages — local-first, single IMAP connection for all */
|