@bobfrankston/mailx 1.0.171 → 1.0.173

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
@@ -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
- const body = document.getElementById("mv-body");
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
- // Find the row after the last selected for re-selection
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; // Multi-delete undo not supported yet
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
- // Select next row or clear viewer
367
- if (nextRow?.classList.contains("ml-row")) {
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
- // ── Reload message list after drag-move ──
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 -- renders paginated message rows.
3
- * Loads more messages on scroll.
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
- /** Clear the message viewer when no message is selected */
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 getUnifiedInbox(1);
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
- // Build new rows into a fragment, then swap atomically (no flash)
111
- const fragment = document.createDocumentFragment();
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
- const firstRow = body.querySelector(".ml-row");
119
- if (firstRow)
120
- firstRow.click();
145
+ selectFirst(body);
121
146
  }
122
147
  else {
123
148
  body.scrollTop = savedScroll;
124
- if (savedUid) {
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 getMessages(accountId, folderId, 1, 10000)
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
- const firstRow = body.querySelector(".ml-row");
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 getMessages(accountId, folderId, 1);
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
- // Build new rows into a fragment, then swap atomically (no flash)
225
- const fragment = document.createDocumentFragment();
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
- // Explicit folder navigation — select first message
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
- if (savedUid) {
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; // Superseded by newer request
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 getUnifiedInbox(currentPage)
266
- : await getMessages(currentAccountId, currentFolderId, currentPage);
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"; // ★ or ☆
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
- const label = msgAccountId;
307
- tag.textContent = label.charAt(0).toUpperCase();
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)
@@ -51,6 +82,8 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
51
82
  if (unsubUrl) {
52
83
  unsubBtn.hidden = false;
53
84
  unsubBtn.href = unsubUrl;
85
+ unsubBtn.textContent = "Unsubscribe";
86
+ unsubBtn.title = unsubUrl;
54
87
  unsubBtn.target = "_blank";
55
88
  unsubBtn.rel = "noopener noreferrer";
56
89
  }
@@ -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.171",
3
+ "version": "1.0.173",
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.3",
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.220",
27
+ "@bobfrankston/msger": "^0.1.222",
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,10 @@ 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;
132
+ /** Background body prefetch — download bodies for messages that don't have them */
133
+ private prefetchBodies;
118
134
  /** Get the body store for direct access */
119
135
  getBodyStore(): FileMessageStore;
120
136
  /** Bulk trash messages — local-first, single IMAP connection for all */