@bobfrankston/mailx 1.0.171 → 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 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)
@@ -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.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.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.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 */
@@ -11,6 +11,7 @@ import { EventEmitter } from "node:events";
11
11
  import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
13
  import { simpleParser } from "mailparser";
14
+ import { GmailApiProvider } from "./providers/gmail-api.js";
14
15
  import { createTransport } from "nodemailer";
15
16
  import * as os from "node:os";
16
17
  /** Extract full error detail with provenance */
@@ -369,11 +370,46 @@ export class ImapManager extends EventEmitter {
369
370
  }
370
371
  else if (!this.accountErrorShown.has(account.id)) {
371
372
  this.accountErrorShown.add(account.id);
372
- this.emit("accountError", account.id, errMsg, "Authentication may have expired", true);
373
+ const config = this.configs.get(account.id);
374
+ this.emit("accountError", account.id, errMsg, errMsg, !!config?.tokenProvider);
373
375
  }
374
376
  }
375
377
  }
376
378
  }
379
+ /** Check if an account uses Gmail (should use API instead of IMAP) */
380
+ isGmailAccount(accountId) {
381
+ const settings = loadSettings();
382
+ const account = settings.accounts.find(a => a.id === accountId);
383
+ if (!account)
384
+ return false;
385
+ return account.imap?.host?.includes("gmail") || account.email?.endsWith("@gmail.com") || false;
386
+ }
387
+ /** Get a Gmail API provider for an account (reuses tokenProvider from IMAP config) */
388
+ getGmailProvider(accountId) {
389
+ const config = this.configs.get(accountId);
390
+ if (!config?.tokenProvider)
391
+ throw new Error(`No tokenProvider for ${accountId}`);
392
+ return new GmailApiProvider(config.tokenProvider);
393
+ }
394
+ /** Convert ProviderMessage to the shape expected by storeMessages/upsertMessage */
395
+ providerMsgToLocal(msg) {
396
+ return {
397
+ uid: msg.uid,
398
+ messageId: msg.messageId,
399
+ date: msg.date || new Date(),
400
+ subject: msg.subject,
401
+ from: msg.from,
402
+ to: msg.to,
403
+ cc: msg.cc,
404
+ seen: msg.seen,
405
+ flagged: msg.flagged,
406
+ answered: msg.answered,
407
+ draft: msg.draft,
408
+ size: msg.size,
409
+ source: msg.source,
410
+ providerId: msg.providerId,
411
+ };
412
+ }
377
413
  /** Sync folder list for an account */
378
414
  async syncFolders(accountId, client) {
379
415
  if (!client)
@@ -486,7 +522,7 @@ export class ImapManager extends EventEmitter {
486
522
  : new Date(0);
487
523
  if (highestUid > 0) {
488
524
  // Incremental: fetch new messages — prefetch bodies for offline access
489
- const fetched = await client.fetchMessagesSinceUid(folder.path, highestUid, { source: prefetch });
525
+ const fetched = await client.fetchMessagesSinceUid(folder.path, highestUid, { source: false });
490
526
  // Filter out the last known message (IMAP * always returns at least one)
491
527
  messages = fetched.filter((m) => m.uid > highestUid);
492
528
  // Gap detection: check for missing UIDs within the range we've already synced
@@ -507,7 +543,7 @@ export class ImapManager extends EventEmitter {
507
543
  for (let i = 0; i < missingUids.length; i += chunkSize) {
508
544
  const chunk = missingUids.slice(i, i + chunkSize);
509
545
  const range = chunk.join(",");
510
- const recovered = await client.fetchMessages(folder.path, range, { source: prefetch });
546
+ const recovered = await client.fetchMessages(folder.path, range, { source: false });
511
547
  messages.push(...recovered);
512
548
  }
513
549
  }
@@ -523,7 +559,7 @@ export class ImapManager extends EventEmitter {
523
559
  const oldestDate = this.db.getOldestDate(accountId, folderId);
524
560
  if (oldestDate > 0 && startDate.getTime() < oldestDate) {
525
561
  const existingUids = new Set(this.db.getUidsForFolder(accountId, folderId));
526
- const backfill = await client.fetchMessageByDate(folder.path, startDate, new Date(oldestDate), { source: prefetch });
562
+ const backfill = await client.fetchMessageByDate(folder.path, startDate, new Date(oldestDate), { source: false });
527
563
  const newBackfill = backfill.filter((m) => !existingUids.has(m.uid));
528
564
  if (newBackfill.length > 0) {
529
565
  console.log(` ${folder.path}: backfilling ${newBackfill.length} older messages`);
@@ -543,7 +579,8 @@ export class ImapManager extends EventEmitter {
543
579
  }
544
580
  };
545
581
  const tomorrow = new Date(Date.now() + 86400000); // IMAP BEFORE is exclusive
546
- messages = await client.fetchMessageByDate(folder.path, startDate, tomorrow, { source: prefetch }, onChunk);
582
+ // First sync: metadata only for fast UI bodies prefetched in background after
583
+ messages = await client.fetchMessageByDate(folder.path, startDate, tomorrow, { source: false }, onChunk);
547
584
  if (totalStored > 0) {
548
585
  console.log(` ${folder.path}: ${totalStored} messages (streamed)`);
549
586
  this.db.recalcFolderCounts(folderId);
@@ -697,6 +734,10 @@ export class ImapManager extends EventEmitter {
697
734
  }
698
735
  /** Sync a single account — manages its own connection lifecycle */
699
736
  async syncAccount(accountId, priorityOrder) {
737
+ // Gmail: use REST API instead of IMAP
738
+ if (this.isGmailAccount(accountId)) {
739
+ return this.syncAccountViaApi(accountId, priorityOrder);
740
+ }
700
741
  try {
701
742
  // Step 1: Get folder list (fast — <1s typically)
702
743
  let client = await this.getOpsClient(accountId);
@@ -781,6 +822,145 @@ export class ImapManager extends EventEmitter {
781
822
  this.handleSyncError(accountId, errMsg);
782
823
  }
783
824
  }
825
+ /** Sync a Gmail account via REST API — no IMAP connections */
826
+ async syncAccountViaApi(accountId, priorityOrder) {
827
+ try {
828
+ const api = this.getGmailProvider(accountId);
829
+ const t0 = Date.now();
830
+ // Step 1: Sync folder list via API
831
+ console.log(` [api] ${accountId}: listing labels...`);
832
+ const apiFolders = await api.listFolders();
833
+ console.log(` [api] ${accountId}: ${apiFolders.length} labels in ${Date.now() - t0}ms`);
834
+ // Store folders in DB (same as IMAP path)
835
+ for (const f of apiFolders) {
836
+ const specialUse = f.specialUse || "";
837
+ this.db.upsertFolder(accountId, f.path, f.name, specialUse, f.delimiter);
838
+ }
839
+ this.emit("folderCountsChanged", accountId, {});
840
+ const dbFolders = this.db.getFolders(accountId);
841
+ // Step 2: Sync folders — INBOX first, then by priority
842
+ const inbox = dbFolders.find(f => f.specialUse === "inbox");
843
+ const remaining = dbFolders.filter(f => f.specialUse !== "inbox");
844
+ remaining.sort((a, b) => {
845
+ const pa = priorityOrder.indexOf(a.specialUse || "") >= 0 ? priorityOrder.indexOf(a.specialUse || "") : 5;
846
+ const pb = priorityOrder.indexOf(b.specialUse || "") >= 0 ? priorityOrder.indexOf(b.specialUse || "") : 5;
847
+ return pa - pb;
848
+ });
849
+ const foldersToSync = inbox ? [inbox, ...remaining] : remaining;
850
+ for (const folder of foldersToSync) {
851
+ try {
852
+ await this.syncFolderViaApi(accountId, folder, api);
853
+ }
854
+ catch (e) {
855
+ console.error(` [api] ${accountId}/${folder.path}: ${e.message}`);
856
+ }
857
+ }
858
+ await api.close();
859
+ this.accountErrorShown.delete(accountId);
860
+ this.emit("syncComplete", accountId);
861
+ }
862
+ catch (e) {
863
+ const errMsg = e.message || String(e);
864
+ this.emit("syncError", accountId, errMsg);
865
+ console.error(` [api] Sync error for ${accountId}: ${errMsg}`);
866
+ this.handleSyncError(accountId, errMsg);
867
+ }
868
+ }
869
+ /** Sync a single folder via Gmail/Outlook API */
870
+ async syncFolderViaApi(accountId, folder, api) {
871
+ const highestUid = this.db.getHighestUid(accountId, folder.id);
872
+ const historyDays = getHistoryDays(accountId);
873
+ const effectiveDays = (historyDays === 0 && highestUid === 0) ? 30 : historyDays;
874
+ const startDate = effectiveDays > 0 ? new Date(Date.now() - effectiveDays * 86400000) : new Date(0);
875
+ const tomorrow = new Date(Date.now() + 86400000);
876
+ this.emit("syncProgress", accountId, `sync:${folder.path}`, 0);
877
+ console.log(` [api] ${accountId}/${folder.path}: syncing (highestUid=${highestUid})...`);
878
+ let messages;
879
+ if (highestUid > 0) {
880
+ // Incremental: fetch messages since last known UID
881
+ messages = await api.fetchSince(folder.path, highestUid, { source: false });
882
+ }
883
+ else {
884
+ // First sync: fetch by date range
885
+ messages = await api.fetchByDate(folder.path, startDate, tomorrow, { source: false }, (chunk) => {
886
+ // Stream chunks to DB for instant UI
887
+ const stored = this.storeApiMessages(accountId, folder.id, chunk, highestUid);
888
+ if (stored > 0) {
889
+ this.db.recalcFolderCounts(folder.id);
890
+ this.emit("folderCountsChanged", accountId, {});
891
+ }
892
+ });
893
+ // First sync chunks already stored via onChunk — just update counts
894
+ this.db.recalcFolderCounts(folder.id);
895
+ this.emit("folderCountsChanged", accountId, {});
896
+ this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
897
+ if (messages.length > 0)
898
+ console.log(` [api] ${accountId}/${folder.path}: ${messages.length} messages`);
899
+ return;
900
+ }
901
+ if (messages.length > 0) {
902
+ console.log(` [api] ${accountId}/${folder.path}: ${messages.length} new messages`);
903
+ this.storeApiMessages(accountId, folder.id, messages, highestUid);
904
+ }
905
+ // Reconcile deletions
906
+ try {
907
+ const serverUids = new Set(await api.getUids(folder.path));
908
+ const localUids = this.db.getUidsForFolder(accountId, folder.id);
909
+ let deleted = 0;
910
+ for (const uid of localUids) {
911
+ if (!serverUids.has(uid)) {
912
+ this.db.deleteMessage(accountId, uid);
913
+ this.bodyStore.deleteMessage(accountId, folder.id, uid).catch(() => { });
914
+ deleted++;
915
+ }
916
+ }
917
+ if (deleted > 0)
918
+ console.log(` [api] ${accountId}/${folder.path}: ${deleted} deleted`);
919
+ }
920
+ catch (e) {
921
+ console.error(` [api] ${accountId}/${folder.path}: reconciliation error: ${e.message}`);
922
+ }
923
+ this.db.recalcFolderCounts(folder.id);
924
+ this.emit("folderCountsChanged", accountId, {});
925
+ this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
926
+ }
927
+ /** Store API-fetched messages to DB */
928
+ storeApiMessages(accountId, folderId, msgs, highestUid) {
929
+ let stored = 0;
930
+ this.db.beginTransaction();
931
+ try {
932
+ for (const msg of msgs) {
933
+ if (msg.uid <= highestUid)
934
+ continue;
935
+ const flags = [];
936
+ if (msg.seen)
937
+ flags.push("\\Seen");
938
+ if (msg.flagged)
939
+ flags.push("\\Flagged");
940
+ if (msg.answered)
941
+ flags.push("\\Answered");
942
+ if (msg.draft)
943
+ flags.push("\\Draft");
944
+ this.db.upsertMessage({
945
+ accountId, folderId, uid: msg.uid,
946
+ messageId: msg.messageId || "", inReplyTo: "", references: [],
947
+ date: msg.date instanceof Date ? msg.date.getTime() : Date.now(),
948
+ subject: msg.subject || "",
949
+ from: toEmailAddress(msg.from?.[0] || {}),
950
+ to: toEmailAddresses(msg.to || []),
951
+ cc: toEmailAddresses(msg.cc || []),
952
+ flags, size: msg.size || 0, hasAttachments: false, preview: "", bodyPath: ""
953
+ });
954
+ stored++;
955
+ }
956
+ this.db.commitTransaction();
957
+ }
958
+ catch (e) {
959
+ this.db.rollbackTransaction();
960
+ console.error(` [api] storeApiMessages error: ${e.message}`);
961
+ }
962
+ return stored;
963
+ }
784
964
  /** Kill and recreate the persistent ops connection */
785
965
  async reconnectOps(accountId) {
786
966
  const old = this.opsClients.get(accountId);
@@ -818,7 +998,7 @@ export class ImapManager extends EventEmitter {
818
998
  }
819
999
  else if (!this.accountErrorShown.has(accountId)) {
820
1000
  this.accountErrorShown.add(accountId);
821
- this.emit("accountError", accountId, errMsg, isOAuth ? "Authentication may have expired" : "Check server connectivity", isOAuth);
1001
+ this.emit("accountError", accountId, errMsg, errMsg, isOAuth);
822
1002
  }
823
1003
  }
824
1004
  /** Sync just INBOX for each account (fast check for new mail) */
@@ -855,6 +1035,8 @@ export class ImapManager extends EventEmitter {
855
1035
  return;
856
1036
  if (this.reauthenticating.has(accountId))
857
1037
  return;
1038
+ if (this.isGmailAccount(accountId))
1039
+ return; // Gmail uses API sync, not IMAP polling
858
1040
  this.quickCheckRunning.add(accountId);
859
1041
  let client = null;
860
1042
  try {
@@ -995,13 +1177,16 @@ export class ImapManager extends EventEmitter {
995
1177
  const folder = this.db.getFolders(accountId).find(f => f.id === folderId);
996
1178
  if (!folder)
997
1179
  return null;
998
- // Serialize: only one body fetch per account at a time (IMAP can only handle one command)
1180
+ // Gmail: use API for body fetch (no IMAP connection needed)
1181
+ if (this.isGmailAccount(accountId)) {
1182
+ return this.fetchMessageBodyViaApi(accountId, folderId, uid, folder.path);
1183
+ }
1184
+ // IMAP: serialize — only one body fetch per account at a time
999
1185
  return this.enqueueFetch(accountId, async () => {
1000
1186
  // Re-check cache — may have been fetched while queued
1001
1187
  if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
1002
1188
  return this.bodyStore.getMessage(accountId, folderId, uid);
1003
1189
  }
1004
- // Body fetch uses a fresh connection — never waits behind background sync
1005
1190
  let client = null;
1006
1191
  try {
1007
1192
  client = this.newClient(accountId);
@@ -1027,6 +1212,24 @@ export class ImapManager extends EventEmitter {
1027
1212
  }
1028
1213
  });
1029
1214
  }
1215
+ /** Fetch message body via Gmail/Outlook API */
1216
+ async fetchMessageBodyViaApi(accountId, folderId, uid, folderPath) {
1217
+ try {
1218
+ const api = this.getGmailProvider(accountId);
1219
+ const msg = await api.fetchOne(folderPath, uid, { source: true });
1220
+ await api.close();
1221
+ if (!msg?.source)
1222
+ return null;
1223
+ const raw = Buffer.from(msg.source, "utf-8");
1224
+ const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
1225
+ this.db.updateBodyPath(accountId, uid, bodyPath);
1226
+ return raw;
1227
+ }
1228
+ catch (e) {
1229
+ console.error(` [api] Body fetch error (${accountId}/${uid}): ${e.message}`);
1230
+ return null;
1231
+ }
1232
+ }
1030
1233
  /** Get the body store for direct access */
1031
1234
  getBodyStore() {
1032
1235
  return this.bodyStore;
@@ -1472,6 +1675,12 @@ export class ImapManager extends EventEmitter {
1472
1675
  const outboxFolder = this.findFolder(accountId, "outbox");
1473
1676
  if (!outboxFolder)
1474
1677
  return;
1678
+ // Skip if this account's sync is failing — don't pile up connections
1679
+ if (this.connectionBackoff.has(accountId) && Date.now() < (this.connectionBackoff.get(accountId) || 0))
1680
+ return;
1681
+ // Gmail uses SMTP for sending (not IMAP outbox) — skip IMAP outbox check
1682
+ if (this.isGmailAccount(accountId))
1683
+ return;
1475
1684
  const settings = loadSettings();
1476
1685
  const account = settings.accounts.find(a => a.id === accountId);
1477
1686
  if (!account)
@@ -1621,9 +1830,9 @@ export class ImapManager extends EventEmitter {
1621
1830
  this.outboxBackoffDelay.delete(accountId);
1622
1831
  }
1623
1832
  catch (e) {
1624
- // Exponential backoff: 30s → 60s → 120s → 300s (max 5min)
1833
+ // Exponential backoff: 60s → 120s → 300s (max 5min)
1625
1834
  const prevDelay = this.outboxBackoffDelay.get(accountId) || 0;
1626
- const delay = prevDelay ? Math.min(prevDelay * 2, 300000) : 30000;
1835
+ const delay = prevDelay ? Math.min(prevDelay * 2, 300000) : 60000;
1627
1836
  this.outboxBackoffDelay.set(accountId, delay);
1628
1837
  this.outboxBackoff.set(accountId, now + delay);
1629
1838
  console.error(` [outbox] Error for ${accountId}: ${imapError(e)} (retry in ${Math.round(delay / 1000)}s)`);
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Gmail API provider — replaces IMAP for Gmail accounts.
3
+ * Uses REST API for fast, reliable sync without connection limit issues.
4
+ */
5
+ import type { MailProvider, ProviderFolder, ProviderMessage, FetchOptions } from "./types.js";
6
+ export declare class GmailApiProvider implements MailProvider {
7
+ private tokenProvider;
8
+ constructor(tokenProvider: () => Promise<string>);
9
+ private fetch;
10
+ listFolders(): Promise<ProviderFolder[]>;
11
+ /** List message IDs matching a query, handling pagination */
12
+ private listMessageIds;
13
+ /** Batch-fetch message metadata or full content */
14
+ private batchFetch;
15
+ /** Parse a Gmail API message response into ProviderMessage */
16
+ private parseMessage;
17
+ fetchSince(folder: string, sinceUid: number, options?: FetchOptions): Promise<ProviderMessage[]>;
18
+ fetchByDate(folder: string, since: Date, before: Date, options?: FetchOptions, onChunk?: (msgs: ProviderMessage[]) => void): Promise<ProviderMessage[]>;
19
+ fetchByUids(folder: string, uids: number[], options?: FetchOptions): Promise<ProviderMessage[]>;
20
+ fetchOne(folder: string, uid: number, options?: FetchOptions): Promise<ProviderMessage | null>;
21
+ getUids(folder: string): Promise<number[]>;
22
+ close(): Promise<void>;
23
+ /** Map folder path to Gmail label query term */
24
+ private folderToLabel;
25
+ /** Format date for Gmail query (YYYY/MM/DD) */
26
+ private formatDate;
27
+ }
28
+ //# sourceMappingURL=gmail-api.d.ts.map
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Gmail API provider — replaces IMAP for Gmail accounts.
3
+ * Uses REST API for fast, reliable sync without connection limit issues.
4
+ */
5
+ const API = "https://gmail.googleapis.com/gmail/v1/users/me";
6
+ /** Convert Gmail hex ID to integer UID (lower 48 bits — deterministic, stable) */
7
+ function idToUid(id) {
8
+ const hex = id.length > 12 ? id.slice(-12) : id;
9
+ return parseInt(hex, 16);
10
+ }
11
+ /** Map Gmail label to IMAP-style specialUse */
12
+ function labelSpecialUse(label) {
13
+ switch (label.id) {
14
+ case "INBOX": return "inbox";
15
+ case "SENT": return "sent";
16
+ case "DRAFT": return "drafts";
17
+ case "TRASH": return "trash";
18
+ case "SPAM": return "junk";
19
+ case "STARRED": return "";
20
+ case "IMPORTANT": return "";
21
+ default: return "";
22
+ }
23
+ }
24
+ /** Parse RFC 2822 headers from Gmail metadata payload */
25
+ function getHeader(headers, name) {
26
+ return headers.find(h => h.name.toLowerCase() === name.toLowerCase())?.value || "";
27
+ }
28
+ /** Parse "Name <addr>" or "addr" into { name, address } */
29
+ function parseAddress(raw) {
30
+ const match = raw.match(/^"?([^"<]*?)"?\s*<([^>]+)>/);
31
+ if (match)
32
+ return { name: match[1].trim(), address: match[2].trim() };
33
+ return { address: raw.trim() };
34
+ }
35
+ function parseAddressList(raw) {
36
+ if (!raw)
37
+ return [];
38
+ // Split on commas that aren't inside quotes
39
+ return raw.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/).map(s => parseAddress(s.trim())).filter(a => a.address);
40
+ }
41
+ export class GmailApiProvider {
42
+ tokenProvider;
43
+ constructor(tokenProvider) {
44
+ this.tokenProvider = tokenProvider;
45
+ }
46
+ async fetch(path, options = {}) {
47
+ const token = await this.tokenProvider();
48
+ const res = await globalThis.fetch(`${API}${path}`, {
49
+ ...options,
50
+ headers: {
51
+ "Authorization": `Bearer ${token}`,
52
+ "Content-Type": "application/json",
53
+ ...options.headers,
54
+ },
55
+ });
56
+ if (!res.ok) {
57
+ const err = await res.text().catch(() => "");
58
+ throw new Error(`Gmail API ${res.status}: ${err.substring(0, 200)}`);
59
+ }
60
+ return res.json();
61
+ }
62
+ async listFolders() {
63
+ const data = await this.fetch("/labels");
64
+ const labels = data.labels || [];
65
+ const folders = [];
66
+ for (const label of labels) {
67
+ // Skip system labels that aren't useful as folders
68
+ if (["UNREAD", "STARRED", "IMPORTANT", "CATEGORY_PERSONAL",
69
+ "CATEGORY_SOCIAL", "CATEGORY_PROMOTIONS", "CATEGORY_UPDATES",
70
+ "CATEGORY_FORUMS", "CHAT"].includes(label.id))
71
+ continue;
72
+ const specialUse = labelSpecialUse(label);
73
+ // Map Gmail path separators (/) to IMAP-style
74
+ const path = label.name || label.id;
75
+ const name = path.includes("/") ? path.split("/").pop() : path;
76
+ folders.push({
77
+ path,
78
+ name,
79
+ delimiter: "/",
80
+ specialUse,
81
+ flags: label.type === "system" ? ["\\Noselect"] : [],
82
+ });
83
+ }
84
+ return folders;
85
+ }
86
+ /** List message IDs matching a query, handling pagination */
87
+ async listMessageIds(query, maxResults = 500) {
88
+ const ids = [];
89
+ let pageToken = "";
90
+ while (true) {
91
+ const params = new URLSearchParams({ q: query, maxResults: String(Math.min(maxResults - ids.length, 500)) });
92
+ if (pageToken)
93
+ params.set("pageToken", pageToken);
94
+ const data = await this.fetch(`/messages?${params}`);
95
+ for (const msg of data.messages || []) {
96
+ ids.push(msg.id);
97
+ }
98
+ if (!data.nextPageToken || ids.length >= maxResults)
99
+ break;
100
+ pageToken = data.nextPageToken;
101
+ }
102
+ return ids;
103
+ }
104
+ /** Batch-fetch message metadata or full content */
105
+ async batchFetch(ids, options = {}, onChunk) {
106
+ const all = [];
107
+ const chunkSize = options.source ? 10 : 50; // Smaller chunks for full bodies
108
+ const format = options.source ? "raw" : "metadata";
109
+ const metadataHeaders = "From,To,Cc,Subject,Message-ID,Date";
110
+ for (let i = 0; i < ids.length; i += chunkSize) {
111
+ const chunk = ids.slice(i, i + chunkSize);
112
+ const messages = await Promise.all(chunk.map(id => {
113
+ const params = new URLSearchParams({ format });
114
+ if (format === "metadata")
115
+ params.set("metadataHeaders", metadataHeaders);
116
+ return this.fetch(`/messages/${id}?${params}`);
117
+ }));
118
+ const parsed = messages.map(msg => this.parseMessage(msg, options));
119
+ all.push(...parsed);
120
+ if (onChunk)
121
+ onChunk(parsed);
122
+ }
123
+ return all;
124
+ }
125
+ /** Parse a Gmail API message response into ProviderMessage */
126
+ parseMessage(msg, options = {}) {
127
+ const labels = msg.labelIds || [];
128
+ const headers = msg.payload?.headers || [];
129
+ let source = "";
130
+ if (options.source && msg.raw) {
131
+ // Gmail returns URL-safe base64 — convert to standard base64 then decode
132
+ const base64 = msg.raw.replace(/-/g, "+").replace(/_/g, "/");
133
+ source = Buffer.from(base64, "base64").toString("utf-8");
134
+ }
135
+ const fromRaw = getHeader(headers, "From");
136
+ const toRaw = getHeader(headers, "To");
137
+ const ccRaw = getHeader(headers, "Cc");
138
+ const dateRaw = getHeader(headers, "Date") || "";
139
+ const subject = getHeader(headers, "Subject") || msg.snippet || "";
140
+ const messageId = getHeader(headers, "Message-ID") || "";
141
+ return {
142
+ uid: idToUid(msg.id),
143
+ messageId,
144
+ providerId: msg.id,
145
+ date: dateRaw ? new Date(dateRaw) : (msg.internalDate ? new Date(Number(msg.internalDate)) : null),
146
+ subject,
147
+ from: parseAddressList(fromRaw),
148
+ to: parseAddressList(toRaw),
149
+ cc: parseAddressList(ccRaw),
150
+ seen: !labels.includes("UNREAD"),
151
+ flagged: labels.includes("STARRED"),
152
+ answered: false, // Gmail API doesn't expose this directly
153
+ draft: labels.includes("DRAFT"),
154
+ size: msg.sizeEstimate || 0,
155
+ source,
156
+ };
157
+ }
158
+ async fetchSince(folder, sinceUid, options = {}) {
159
+ // Gmail doesn't have UIDs — use date-based query for incremental
160
+ // For now, fetch recent messages and let the caller filter by UID
161
+ const query = `in:${this.folderToLabel(folder)}`;
162
+ const ids = await this.listMessageIds(query, 200);
163
+ const messages = await this.batchFetch(ids, options);
164
+ return messages.filter(m => m.uid > sinceUid);
165
+ }
166
+ async fetchByDate(folder, since, before, options = {}, onChunk) {
167
+ const afterDate = this.formatDate(since);
168
+ const beforeDate = this.formatDate(before);
169
+ const query = `in:${this.folderToLabel(folder)} after:${afterDate} before:${beforeDate}`;
170
+ const ids = await this.listMessageIds(query);
171
+ return this.batchFetch(ids, options, onChunk);
172
+ }
173
+ async fetchByUids(folder, uids, options = {}) {
174
+ // UIDs are derived from Gmail IDs — we'd need a reverse lookup
175
+ // For now, fetch all messages in folder and filter
176
+ const query = `in:${this.folderToLabel(folder)}`;
177
+ const ids = await this.listMessageIds(query);
178
+ const uidSet = new Set(uids);
179
+ const matchingIds = ids.filter(id => uidSet.has(idToUid(id)));
180
+ return this.batchFetch(matchingIds, options);
181
+ }
182
+ async fetchOne(folder, uid, options = {}) {
183
+ // Need to find the Gmail ID from the UID — search all messages in folder
184
+ const query = `in:${this.folderToLabel(folder)}`;
185
+ const ids = await this.listMessageIds(query, 1000);
186
+ const id = ids.find(id => idToUid(id) === uid);
187
+ if (!id)
188
+ return null;
189
+ const format = options.source ? "raw" : "metadata";
190
+ const params = new URLSearchParams({ format });
191
+ if (format === "metadata")
192
+ params.set("metadataHeaders", "From,To,Cc,Subject,Message-ID,Date");
193
+ const msg = await this.fetch(`/messages/${id}?${params}`);
194
+ return this.parseMessage(msg, options);
195
+ }
196
+ async getUids(folder) {
197
+ const query = `in:${this.folderToLabel(folder)}`;
198
+ const ids = await this.listMessageIds(query, 10000);
199
+ return ids.map(idToUid);
200
+ }
201
+ async close() {
202
+ // No persistent connection to close
203
+ }
204
+ /** Map folder path to Gmail label query term */
205
+ folderToLabel(path) {
206
+ const lower = path.toLowerCase();
207
+ if (lower === "inbox")
208
+ return "inbox";
209
+ if (lower === "sent" || lower === "[gmail]/sent mail")
210
+ return "sent";
211
+ if (lower === "drafts" || lower === "[gmail]/drafts")
212
+ return "drafts";
213
+ if (lower === "trash" || lower === "[gmail]/trash")
214
+ return "trash";
215
+ if (lower === "spam" || lower === "[gmail]/spam" || lower === "junk email")
216
+ return "spam";
217
+ if (lower === "archive" || lower === "[gmail]/all mail")
218
+ return "all";
219
+ // Custom label — use exact name
220
+ return `"${path}"`;
221
+ }
222
+ /** Format date for Gmail query (YYYY/MM/DD) */
223
+ formatDate(d) {
224
+ return `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, "0")}/${String(d.getDate()).padStart(2, "0")}`;
225
+ }
226
+ }
227
+ //# sourceMappingURL=gmail-api.js.map
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Mail provider interface — abstraction over IMAP, Gmail API, and Microsoft Graph.
3
+ * Sync code uses this interface; never calls IMAP/REST directly.
4
+ */
5
+ export interface ProviderFolder {
6
+ path: string;
7
+ name: string;
8
+ delimiter: string;
9
+ specialUse: string;
10
+ flags: string[];
11
+ }
12
+ export interface ProviderMessage {
13
+ uid: number;
14
+ messageId: string;
15
+ providerId: string;
16
+ date: Date | null;
17
+ subject: string;
18
+ from: {
19
+ name?: string;
20
+ address?: string;
21
+ }[];
22
+ to: {
23
+ name?: string;
24
+ address?: string;
25
+ }[];
26
+ cc: {
27
+ name?: string;
28
+ address?: string;
29
+ }[];
30
+ seen: boolean;
31
+ flagged: boolean;
32
+ answered: boolean;
33
+ draft: boolean;
34
+ size: number;
35
+ source: string;
36
+ }
37
+ export interface FetchOptions {
38
+ source?: boolean;
39
+ }
40
+ /**
41
+ * A mail provider that can list folders, fetch messages, and perform actions.
42
+ * Implementations: ImapProvider (existing iflow), GmailApiProvider, GraphApiProvider.
43
+ */
44
+ export interface MailProvider {
45
+ /** List all folders/labels */
46
+ listFolders(): Promise<ProviderFolder[]>;
47
+ /** Fetch messages newer than sinceUid (incremental sync) */
48
+ fetchSince(folder: string, sinceUid: number, options?: FetchOptions): Promise<ProviderMessage[]>;
49
+ /** Fetch messages by date range (first sync) */
50
+ fetchByDate(folder: string, since: Date, before: Date, options?: FetchOptions, onChunk?: (msgs: ProviderMessage[]) => void): Promise<ProviderMessage[]>;
51
+ /** Fetch specific messages by UID */
52
+ fetchByUids(folder: string, uids: number[], options?: FetchOptions): Promise<ProviderMessage[]>;
53
+ /** Fetch a single message by UID */
54
+ fetchOne(folder: string, uid: number, options?: FetchOptions): Promise<ProviderMessage | null>;
55
+ /** Get all UIDs in a folder (for reconciliation) */
56
+ getUids(folder: string): Promise<number[]>;
57
+ /** Close/cleanup */
58
+ close(): Promise<void>;
59
+ }
60
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Mail provider interface — abstraction over IMAP, Gmail API, and Microsoft Graph.
3
+ * Sync code uses this interface; never calls IMAP/REST directly.
4
+ */
5
+ export {};
6
+ //# sourceMappingURL=types.js.map