@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 +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 +219 -10
- 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/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 */
|
|
@@ -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.
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
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:
|
|
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) :
|
|
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
|