@bobfrankston/mailx 1.0.121 → 1.0.123
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
|
@@ -546,18 +546,25 @@ onWsEvent((event) => {
|
|
|
546
546
|
statusSync.textContent = `Syncing ${event.accountId}: ${event.phase} ${event.progress || 0}%`;
|
|
547
547
|
if (startupStatus)
|
|
548
548
|
startupStatus.textContent = `Syncing ${event.accountId}: ${event.phase}`;
|
|
549
|
-
// Mark syncing folder in tree
|
|
549
|
+
// Mark syncing folder in tree — bubble up to visible parent if collapsed
|
|
550
550
|
const syncPath = event.phase?.startsWith("sync:") ? event.phase.slice(5) : null;
|
|
551
551
|
// Clear previous syncing markers for this account
|
|
552
552
|
document.querySelectorAll(`.ft-folder.ft-syncing[data-account-id="${event.accountId}"]`).forEach(el => el.classList.remove("ft-syncing"));
|
|
553
|
-
if (syncPath) {
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
553
|
+
if (syncPath && event.progress < 100) {
|
|
554
|
+
// Try exact match first
|
|
555
|
+
let folderEl = document.querySelector(`.ft-folder[data-account-id="${event.accountId}"][data-folder-path="${CSS.escape(syncPath)}"]`);
|
|
556
|
+
if (!folderEl) {
|
|
557
|
+
// Folder not visible (parent collapsed) — find nearest visible ancestor
|
|
558
|
+
const parts = syncPath.split(/[./]/);
|
|
559
|
+
for (let i = parts.length - 1; i >= 1; i--) {
|
|
560
|
+
const parentPath = parts.slice(0, i).join(".");
|
|
561
|
+
folderEl = document.querySelector(`.ft-folder[data-account-id="${event.accountId}"][data-folder-path="${CSS.escape(parentPath)}"]`);
|
|
562
|
+
if (folderEl)
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
560
565
|
}
|
|
566
|
+
if (folderEl)
|
|
567
|
+
folderEl.classList.add("ft-syncing");
|
|
561
568
|
}
|
|
562
569
|
break;
|
|
563
570
|
}
|
|
@@ -74,6 +74,19 @@ function buildTree(folders, delimiter, accountId) {
|
|
|
74
74
|
root.push(node);
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
|
+
// Aggregate counts from children to parents (so collapsed parents show totals)
|
|
78
|
+
function aggregateCounts(nodes) {
|
|
79
|
+
let unread = 0, total = 0;
|
|
80
|
+
for (const n of nodes) {
|
|
81
|
+
const child = aggregateCounts(n.children);
|
|
82
|
+
n.unreadCount += child.unread;
|
|
83
|
+
n.totalCount += child.total;
|
|
84
|
+
unread += n.unreadCount;
|
|
85
|
+
total += n.totalCount;
|
|
86
|
+
}
|
|
87
|
+
return { unread, total };
|
|
88
|
+
}
|
|
89
|
+
aggregateCounts(root);
|
|
77
90
|
return root;
|
|
78
91
|
}
|
|
79
92
|
/** Sort: INBOX first, then special folders, then alphabetical */
|
package/package.json
CHANGED
|
@@ -52,9 +52,10 @@ export declare class ImapManager extends EventEmitter {
|
|
|
52
52
|
/** Track active IMAP connections for diagnostics */
|
|
53
53
|
private activeConnections;
|
|
54
54
|
/** Create a fresh IMAP client for an account (disposable, single-use).
|
|
55
|
-
* Returns CompatImapClient (native) or ImapClient (imapflow) based on useNativeClient flag.
|
|
55
|
+
* Returns CompatImapClient (native) or ImapClient (imapflow) based on useNativeClient flag.
|
|
56
|
+
* The client's logout() is wrapped to auto-decrement the connection counter. */
|
|
56
57
|
private createClient;
|
|
57
|
-
/** Track client logout for connection counting */
|
|
58
|
+
/** Track client logout for connection counting (called automatically by wrapped logout) */
|
|
58
59
|
private trackLogout;
|
|
59
60
|
/** Number of registered IMAP accounts */
|
|
60
61
|
getAccountCount(): number;
|
|
@@ -187,7 +187,8 @@ export class ImapManager extends EventEmitter {
|
|
|
187
187
|
/** Track active IMAP connections for diagnostics */
|
|
188
188
|
activeConnections = new Map(); // accountId → count
|
|
189
189
|
/** Create a fresh IMAP client for an account (disposable, single-use).
|
|
190
|
-
* Returns CompatImapClient (native) or ImapClient (imapflow) based on useNativeClient flag.
|
|
190
|
+
* Returns CompatImapClient (native) or ImapClient (imapflow) based on useNativeClient flag.
|
|
191
|
+
* The client's logout() is wrapped to auto-decrement the connection counter. */
|
|
191
192
|
createClient(accountId) {
|
|
192
193
|
if (this.reauthenticating.has(accountId))
|
|
193
194
|
throw new Error(`Account ${accountId} is re-authenticating`);
|
|
@@ -203,12 +204,26 @@ export class ImapManager extends EventEmitter {
|
|
|
203
204
|
this.activeConnections.set(accountId, count);
|
|
204
205
|
const clientType = this.useNativeClient ? "native" : "imapflow";
|
|
205
206
|
console.log(` [conn] ${accountId}: +1 ${clientType} (${count} active)`);
|
|
207
|
+
let client;
|
|
206
208
|
if (this.useNativeClient) {
|
|
207
|
-
|
|
209
|
+
client = new CompatImapClient(config, () => new NodeTransport({ rejectUnauthorized: config.rejectUnauthorized !== false }));
|
|
208
210
|
}
|
|
209
|
-
|
|
211
|
+
else {
|
|
212
|
+
client = new ImapClient(config);
|
|
213
|
+
}
|
|
214
|
+
// Wrap logout to auto-decrement connection counter (prevents leaks from missed trackLogout calls)
|
|
215
|
+
const originalLogout = client.logout.bind(client);
|
|
216
|
+
let loggedOut = false;
|
|
217
|
+
client.logout = async () => {
|
|
218
|
+
await originalLogout();
|
|
219
|
+
if (!loggedOut) {
|
|
220
|
+
loggedOut = true;
|
|
221
|
+
this.trackLogout(accountId);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
return client;
|
|
210
225
|
}
|
|
211
|
-
/** Track client logout for connection counting */
|
|
226
|
+
/** Track client logout for connection counting (called automatically by wrapped logout) */
|
|
212
227
|
trackLogout(accountId) {
|
|
213
228
|
const count = Math.max(0, (this.activeConnections.get(accountId) || 1) - 1);
|
|
214
229
|
this.activeConnections.set(accountId, count);
|
|
@@ -299,26 +314,37 @@ export class ImapManager extends EventEmitter {
|
|
|
299
314
|
const highestUid = this.db.getHighestUid(accountId, folderId);
|
|
300
315
|
let messages;
|
|
301
316
|
const firstSync = highestUid === 0;
|
|
317
|
+
const historyDays = getHistoryDays(accountId);
|
|
318
|
+
const startDate = historyDays > 0
|
|
319
|
+
? new Date(Date.now() - historyDays * 86400000)
|
|
320
|
+
: new Date(0);
|
|
302
321
|
if (highestUid > 0) {
|
|
303
|
-
// Incremental:
|
|
304
|
-
const fetched = await client.fetchMessagesSinceUid(folder.path, highestUid, { source:
|
|
322
|
+
// Incremental: fetch new messages — metadata only for speed, bodies on demand
|
|
323
|
+
const fetched = await client.fetchMessagesSinceUid(folder.path, highestUid, { source: false });
|
|
305
324
|
// Filter out the last known message (IMAP * always returns at least one)
|
|
306
325
|
messages = fetched.filter(m => m.uid > highestUid);
|
|
326
|
+
// Backfill: if historyDays extends further back than our oldest message, fetch the gap
|
|
327
|
+
const oldestDate = this.db.getOldestDate(accountId, folderId);
|
|
328
|
+
if (oldestDate > 0 && startDate.getTime() < oldestDate) {
|
|
329
|
+
const existingUids = new Set(this.db.getUidsForFolder(accountId, folderId));
|
|
330
|
+
const backfill = await client.fetchMessageByDate(folder.path, startDate, new Date(oldestDate), { source: false });
|
|
331
|
+
const newBackfill = backfill.filter(m => !existingUids.has(m.uid));
|
|
332
|
+
if (newBackfill.length > 0) {
|
|
333
|
+
console.log(` ${folder.path}: backfilling ${newBackfill.length} older messages`);
|
|
334
|
+
messages.push(...newBackfill);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
307
337
|
}
|
|
308
338
|
else {
|
|
309
|
-
// First sync:
|
|
310
|
-
|
|
311
|
-
const startDate = historyDays > 0
|
|
312
|
-
? new Date(Date.now() - historyDays * 86400000)
|
|
313
|
-
: new Date(0);
|
|
314
|
-
messages = await client.fetchMessageByDate(folder.path, startDate, new Date(), { source: true });
|
|
315
|
-
// Sort newest first so most recent messages appear in the UI immediately
|
|
316
|
-
messages.sort((a, b) => {
|
|
317
|
-
const da = a.date instanceof Date ? a.date.getTime() : (typeof a.date === "number" ? a.date : 0);
|
|
318
|
-
const db = b.date instanceof Date ? b.date.getTime() : (typeof b.date === "number" ? b.date : 0);
|
|
319
|
-
return db - da;
|
|
320
|
-
});
|
|
339
|
+
// First sync: metadata only — bodies fetched on demand when user clicks a message
|
|
340
|
+
messages = await client.fetchMessageByDate(folder.path, startDate, new Date(), { source: false });
|
|
321
341
|
}
|
|
342
|
+
// Sort newest first so most recent messages appear in the UI immediately
|
|
343
|
+
messages.sort((a, b) => {
|
|
344
|
+
const da = a.date instanceof Date ? a.date.getTime() : (typeof a.date === "number" ? a.date : 0);
|
|
345
|
+
const db = b.date instanceof Date ? b.date.getTime() : (typeof b.date === "number" ? b.date : 0);
|
|
346
|
+
return db - da;
|
|
347
|
+
});
|
|
322
348
|
if (messages.length > 0)
|
|
323
349
|
console.log(` ${folder.path}: ${messages.length} new messages`);
|
|
324
350
|
let newCount = 0;
|
|
@@ -1236,8 +1262,14 @@ export class ImapManager extends EventEmitter {
|
|
|
1236
1262
|
try {
|
|
1237
1263
|
// Get all UIDs in Outbox
|
|
1238
1264
|
const uids = await client.getUids(outboxFolder.path);
|
|
1239
|
-
if (uids.length === 0)
|
|
1265
|
+
if (uids.length === 0) {
|
|
1266
|
+
try {
|
|
1267
|
+
await client.logout();
|
|
1268
|
+
}
|
|
1269
|
+
catch { }
|
|
1270
|
+
this.trackLogout(accountId);
|
|
1240
1271
|
return;
|
|
1272
|
+
}
|
|
1241
1273
|
const sendingFlag = `$Sending-${this.hostname}`;
|
|
1242
1274
|
for (const uid of uids) {
|
|
1243
1275
|
// Check flags — skip if already being sent or permanently failed
|
|
@@ -1341,6 +1373,7 @@ export class ImapManager extends EventEmitter {
|
|
|
1341
1373
|
await client.logout();
|
|
1342
1374
|
}
|
|
1343
1375
|
catch { /* ignore */ }
|
|
1376
|
+
this.trackLogout(accountId);
|
|
1344
1377
|
}
|
|
1345
1378
|
}
|
|
1346
1379
|
/** Start background Outbox worker — runs immediately then every 10 seconds */
|
|
@@ -59,6 +59,7 @@ export declare class MailxDB {
|
|
|
59
59
|
updateMessageFlags(accountId: string, uid: number, flags: string[]): void;
|
|
60
60
|
updateBodyPath(accountId: string, uid: number, bodyPath: string): void;
|
|
61
61
|
getHighestUid(accountId: string, folderId: number): number;
|
|
62
|
+
getOldestDate(accountId: string, folderId: number): number;
|
|
62
63
|
/** Get all UIDs for a folder */
|
|
63
64
|
getUidsForFolder(accountId: string, folderId: number): number[];
|
|
64
65
|
/** Delete a message by account + UID */
|
|
@@ -324,6 +324,10 @@ export class MailxDB {
|
|
|
324
324
|
const r = this.db.prepare("SELECT MAX(uid) as maxUid FROM messages WHERE account_id = ? AND folder_id = ?").get(accountId, folderId);
|
|
325
325
|
return r?.maxUid || 0;
|
|
326
326
|
}
|
|
327
|
+
getOldestDate(accountId, folderId) {
|
|
328
|
+
const r = this.db.prepare("SELECT MIN(date) as minDate FROM messages WHERE account_id = ? AND folder_id = ?").get(accountId, folderId);
|
|
329
|
+
return r?.minDate || 0;
|
|
330
|
+
}
|
|
327
331
|
/** Get all UIDs for a folder */
|
|
328
332
|
getUidsForFolder(accountId, folderId) {
|
|
329
333
|
const rows = this.db.prepare("SELECT uid FROM messages WHERE account_id = ? AND folder_id = ?").all(accountId, folderId);
|