@bobfrankston/mailx 1.0.98 → 1.0.100
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/bin/mailx.js +22 -11
- package/killmail.cmd +8 -1
- package/package.json +1 -1
- package/packages/mailx-imap/index.d.ts +4 -0
- package/packages/mailx-imap/index.js +113 -53
- package/packages/mailx-server/index.js +7 -1
package/bin/mailx.js
CHANGED
|
@@ -49,6 +49,13 @@ if (hasFlag("kill")) {
|
|
|
49
49
|
const { execSync } = await import("node:child_process");
|
|
50
50
|
let killed = 0;
|
|
51
51
|
|
|
52
|
+
// Try graceful exit first
|
|
53
|
+
try {
|
|
54
|
+
execSync(`curl -s -m 2 http://localhost:${PORT}/api/exit`, { stdio: "pipe" });
|
|
55
|
+
log("Sent graceful exit");
|
|
56
|
+
execSync("timeout /t 1 /nobreak", { stdio: "pipe" });
|
|
57
|
+
} catch { /* server may not be responding */ }
|
|
58
|
+
|
|
52
59
|
if (process.platform === "win32") {
|
|
53
60
|
// Kill by port
|
|
54
61
|
try {
|
|
@@ -59,17 +66,14 @@ if (hasFlag("kill")) {
|
|
|
59
66
|
}
|
|
60
67
|
} catch { /* no process on port */ }
|
|
61
68
|
|
|
62
|
-
// Kill any node.exe
|
|
63
|
-
const installDir = path.join(import.meta.dirname, "..");
|
|
69
|
+
// Kill any node.exe running mailx-server (uses tasklist + powershell instead of wmic)
|
|
64
70
|
try {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
}
|
|
71
|
+
const ps = execSync(
|
|
72
|
+
`powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"name='node.exe'\\" | Where-Object { $_.CommandLine -match 'mailx-server|mailx\\\\packages' } | Select-Object -ExpandProperty ProcessId"`,
|
|
73
|
+
{ encoding: "utf-8" }
|
|
74
|
+
).trim();
|
|
75
|
+
for (const pid of ps.split("\n").map(s => s.trim()).filter(s => /^\d+$/.test(s))) {
|
|
76
|
+
try { execSync(`taskkill /F /PID ${pid}`, { stdio: "pipe" }); console.log(`Killed PID ${pid} (mailx node process)`); killed++; } catch { /* */ }
|
|
73
77
|
}
|
|
74
78
|
} catch { /* */ }
|
|
75
79
|
|
|
@@ -79,8 +83,15 @@ if (hasFlag("kill")) {
|
|
|
79
83
|
try { execSync(`fuser -k ${PORT}/tcp`, { stdio: "pipe" }); console.log(`Killed process on port ${PORT}`); killed++; } catch { /* */ }
|
|
80
84
|
}
|
|
81
85
|
|
|
86
|
+
// Clean up stale SQLite WAL/SHM files
|
|
87
|
+
const mailxDir = path.join(process.env.USERPROFILE || process.env.HOME || ".", ".mailx");
|
|
88
|
+
for (const ext of ["mailx.db-shm", "mailx.db-wal"]) {
|
|
89
|
+
const p = path.join(mailxDir, ext);
|
|
90
|
+
try { fs.unlinkSync(p); log(`Cleaned ${ext}`); } catch { /* */ }
|
|
91
|
+
}
|
|
92
|
+
|
|
82
93
|
// Remove lock file
|
|
83
|
-
const lockPath = path.join(
|
|
94
|
+
const lockPath = path.join(mailxDir, "mailx-app.lock");
|
|
84
95
|
try { fs.unlinkSync(lockPath); log("Removed lock file"); } catch { /* */ }
|
|
85
96
|
|
|
86
97
|
if (killed === 0) console.log("No mailx processes found");
|
package/killmail.cmd
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
@echo off
|
|
2
|
-
REM Kill mailx server (port 9333) and
|
|
2
|
+
REM Kill mailx server (port 9333), launcher, and clean up stale DB locks
|
|
3
3
|
REM Safe: only kills mailx processes, never all node.exe
|
|
4
4
|
|
|
5
|
+
REM Try graceful exit first, then force kill
|
|
6
|
+
curl -s -m 2 http://localhost:9333/api/exit >nul 2>&1
|
|
7
|
+
timeout /t 1 /nobreak >nul 2>&1
|
|
5
8
|
taskkill /F /IM mailx-app.exe >nul 2>&1 && echo Killed mailx-app.exe || echo mailx-app.exe not running
|
|
6
9
|
killport 9333
|
|
10
|
+
|
|
11
|
+
REM Clean up stale SQLite WAL/SHM files (left by zombie processes)
|
|
12
|
+
if exist "%USERPROFILE%\.mailx\mailx.db-shm" del "%USERPROFILE%\.mailx\mailx.db-shm" && echo Cleaned mailx.db-shm
|
|
13
|
+
if exist "%USERPROFILE%\.mailx\mailx.db-wal" del "%USERPROFILE%\.mailx\mailx.db-wal" && echo Cleaned mailx.db-wal
|
package/package.json
CHANGED
|
@@ -47,9 +47,13 @@ export declare class ImapManager extends EventEmitter {
|
|
|
47
47
|
searchOnServer(accountId: string, mailboxPath: string, criteria: any): Promise<number[]>;
|
|
48
48
|
/** Create a fresh IMAP client for an account (public access for API endpoints) */
|
|
49
49
|
createPublicClient(accountId: string): any;
|
|
50
|
+
/** Track active IMAP connections for diagnostics */
|
|
51
|
+
private activeConnections;
|
|
50
52
|
/** Create a fresh IMAP client for an account (disposable, single-use).
|
|
51
53
|
* Returns CompatImapClient (native) or ImapClient (imapflow) based on useNativeClient flag. */
|
|
52
54
|
private createClient;
|
|
55
|
+
/** Track client logout for connection counting */
|
|
56
|
+
private trackLogout;
|
|
53
57
|
/** Register an account */
|
|
54
58
|
addAccount(account: AccountConfig): Promise<void>;
|
|
55
59
|
/** Sync folder list for an account */
|
|
@@ -12,13 +12,26 @@ import * as path from "node:path";
|
|
|
12
12
|
import { simpleParser } from "mailparser";
|
|
13
13
|
import { createTransport } from "nodemailer";
|
|
14
14
|
import * as os from "node:os";
|
|
15
|
-
/** Extract full error detail
|
|
15
|
+
/** Extract full error detail with provenance */
|
|
16
16
|
function imapError(err) {
|
|
17
|
-
const
|
|
17
|
+
const msg = err.message || err.reason || err.code || (typeof err === "string" ? err : "");
|
|
18
|
+
const parts = [];
|
|
19
|
+
if (msg)
|
|
20
|
+
parts.push(msg);
|
|
18
21
|
if (err.responseText)
|
|
19
22
|
parts.push(err.responseText);
|
|
20
23
|
if (err.responseStatus)
|
|
21
24
|
parts.push(`[${err.responseStatus}]`);
|
|
25
|
+
if (err.code && err.code !== msg)
|
|
26
|
+
parts.push(`[${err.code}]`);
|
|
27
|
+
if (parts.length === 0)
|
|
28
|
+
parts.push(`Unexpected error: ${JSON.stringify(err).slice(0, 200)}`);
|
|
29
|
+
// Add source location if available
|
|
30
|
+
if (err.stack) {
|
|
31
|
+
const frame = err.stack.split("\n").find((l) => l.includes("imap") || l.includes("transport") || l.includes("compat"));
|
|
32
|
+
if (frame)
|
|
33
|
+
parts.push(`(${frame.trim().replace(/^at\s+/, "")})`);
|
|
34
|
+
}
|
|
22
35
|
return parts.join(" — ");
|
|
23
36
|
}
|
|
24
37
|
/** Convert iflow address objects to our EmailAddress */
|
|
@@ -166,6 +179,11 @@ export class ImapManager extends EventEmitter {
|
|
|
166
179
|
createPublicClient(accountId) {
|
|
167
180
|
return this.createClient(accountId);
|
|
168
181
|
}
|
|
182
|
+
// Legacy fallback disabled — was doubling connections without helping.
|
|
183
|
+
// To re-enable: uncomment legacyFallbacks logic in createClient and _syncAll.
|
|
184
|
+
// private legacyFallbacks = new Set<string>();
|
|
185
|
+
/** Track active IMAP connections for diagnostics */
|
|
186
|
+
activeConnections = new Map(); // accountId → count
|
|
169
187
|
/** Create a fresh IMAP client for an account (disposable, single-use).
|
|
170
188
|
* Returns CompatImapClient (native) or ImapClient (imapflow) based on useNativeClient flag. */
|
|
171
189
|
createClient(accountId) {
|
|
@@ -179,11 +197,21 @@ export class ImapManager extends EventEmitter {
|
|
|
179
197
|
const config = this.configs.get(accountId);
|
|
180
198
|
if (!config)
|
|
181
199
|
throw new Error(`No config for account ${accountId}`);
|
|
200
|
+
const count = (this.activeConnections.get(accountId) || 0) + 1;
|
|
201
|
+
this.activeConnections.set(accountId, count);
|
|
202
|
+
const clientType = this.useNativeClient ? "native" : "imapflow";
|
|
203
|
+
console.log(` [conn] ${accountId}: +1 ${clientType} (${count} active)`);
|
|
182
204
|
if (this.useNativeClient) {
|
|
183
205
|
return new CompatImapClient(config, () => new NodeTransport({ rejectUnauthorized: config.rejectUnauthorized !== false }));
|
|
184
206
|
}
|
|
185
207
|
return new ImapClient(config);
|
|
186
208
|
}
|
|
209
|
+
/** Track client logout for connection counting */
|
|
210
|
+
trackLogout(accountId) {
|
|
211
|
+
const count = Math.max(0, (this.activeConnections.get(accountId) || 1) - 1);
|
|
212
|
+
this.activeConnections.set(accountId, count);
|
|
213
|
+
console.log(` [conn] ${accountId}: -1 (${count} active)`);
|
|
214
|
+
}
|
|
187
215
|
/** Register an account */
|
|
188
216
|
async addAccount(account) {
|
|
189
217
|
if (this.configs.has(account.id))
|
|
@@ -220,7 +248,10 @@ export class ImapManager extends EventEmitter {
|
|
|
220
248
|
if (!client)
|
|
221
249
|
client = this.createClient(accountId);
|
|
222
250
|
this.emit("syncProgress", accountId, "folders", 0);
|
|
251
|
+
const t0 = Date.now();
|
|
252
|
+
console.log(` [diag] ${accountId}: getFolderList starting...`);
|
|
223
253
|
const folders = await client.getFolderList();
|
|
254
|
+
console.log(` [diag] ${accountId}: getFolderList done in ${Date.now() - t0}ms (${folders.length} folders)`);
|
|
224
255
|
const specialFolders = client.getSpecialFolders(folders);
|
|
225
256
|
for (const folder of folders) {
|
|
226
257
|
// Skip non-selectable folders (virtual parents like "Added", "Added2")
|
|
@@ -368,10 +399,9 @@ export class ImapManager extends EventEmitter {
|
|
|
368
399
|
// Remove messages deleted on the server (skip on first sync — nothing to reconcile)
|
|
369
400
|
let deletedCount = 0;
|
|
370
401
|
if (!firstSync) {
|
|
371
|
-
let delClient = null;
|
|
372
402
|
try {
|
|
373
|
-
|
|
374
|
-
const serverUids = new Set(await
|
|
403
|
+
// Reuse the passed-in client instead of opening a new connection
|
|
404
|
+
const serverUids = new Set(await client.getUids(folder.path));
|
|
375
405
|
const localUids = this.db.getUidsForFolder(accountId, folderId);
|
|
376
406
|
for (const uid of localUids) {
|
|
377
407
|
if (!serverUids.has(uid)) {
|
|
@@ -382,18 +412,10 @@ export class ImapManager extends EventEmitter {
|
|
|
382
412
|
}
|
|
383
413
|
if (deletedCount > 0)
|
|
384
414
|
console.log(` removed ${deletedCount} deleted messages`);
|
|
385
|
-
await delClient.logout();
|
|
386
415
|
}
|
|
387
416
|
catch (e) {
|
|
388
417
|
console.error(` deletion sync error: ${e.message}`);
|
|
389
418
|
}
|
|
390
|
-
finally {
|
|
391
|
-
if (delClient)
|
|
392
|
-
try {
|
|
393
|
-
await delClient.logout();
|
|
394
|
-
}
|
|
395
|
-
catch { /* ignore */ }
|
|
396
|
-
}
|
|
397
419
|
}
|
|
398
420
|
// Update folder counts from local DB (after deletions + additions)
|
|
399
421
|
const result = this.db.getMessages({ accountId, folderId, page: 1, pageSize: 1 });
|
|
@@ -430,12 +452,17 @@ export class ImapManager extends EventEmitter {
|
|
|
430
452
|
for (const [accountId] of this.configs) {
|
|
431
453
|
let client = null;
|
|
432
454
|
try {
|
|
455
|
+
const t0 = Date.now();
|
|
433
456
|
client = this.createClient(accountId);
|
|
434
457
|
const folders = await Promise.race([
|
|
435
458
|
this.syncFolders(accountId, client),
|
|
436
459
|
new Promise((_, reject) => setTimeout(() => reject(new Error("Folder list timeout (30s)")), 30000))
|
|
437
460
|
]);
|
|
461
|
+
console.log(` [timing] ${accountId}: folder list ${Date.now() - t0}ms (${folders.length} folders)`);
|
|
462
|
+
// Legacy fallback removed — was doubling connections.
|
|
463
|
+
// If native client has issues, set useNativeClient=false or use --legacy-imap flag.
|
|
438
464
|
await client.logout();
|
|
465
|
+
this.trackLogout(accountId);
|
|
439
466
|
client = null;
|
|
440
467
|
accountFolders.set(accountId, folders);
|
|
441
468
|
// Sync inbox immediately
|
|
@@ -448,6 +475,7 @@ export class ImapManager extends EventEmitter {
|
|
|
448
475
|
new Promise((_, reject) => setTimeout(() => reject(new Error("Sync timeout (60s)")), 60000))
|
|
449
476
|
]);
|
|
450
477
|
await client.logout();
|
|
478
|
+
this.trackLogout(accountId);
|
|
451
479
|
client = null;
|
|
452
480
|
}
|
|
453
481
|
catch (e) {
|
|
@@ -456,6 +484,7 @@ export class ImapManager extends EventEmitter {
|
|
|
456
484
|
await client.logout();
|
|
457
485
|
}
|
|
458
486
|
catch { /* ignore */ }
|
|
487
|
+
this.trackLogout(accountId);
|
|
459
488
|
client = null;
|
|
460
489
|
}
|
|
461
490
|
console.error(` Inbox sync error for ${accountId}: ${e.message}`);
|
|
@@ -466,28 +495,49 @@ export class ImapManager extends EventEmitter {
|
|
|
466
495
|
const errMsg = imapError(e);
|
|
467
496
|
this.emit("syncError", accountId, errMsg);
|
|
468
497
|
console.error(`Sync error for ${accountId}: ${errMsg}`);
|
|
498
|
+
const config = this.configs.get(accountId);
|
|
499
|
+
const isOAuth = !!config?.tokenProvider;
|
|
469
500
|
// Connection limit — back off for 60 seconds
|
|
470
501
|
if (errMsg.includes("max_userip_connections") || errMsg.includes("Too many simultaneous")) {
|
|
471
502
|
this.connectionBackoff.set(accountId, Date.now() + 60000);
|
|
472
503
|
console.log(` [backoff] ${accountId}: connection limit hit, backing off 60s`);
|
|
473
504
|
}
|
|
474
|
-
//
|
|
475
|
-
|
|
505
|
+
// Classify error: transient (timeout, connection) vs auth (credentials, token)
|
|
506
|
+
const isTransient = /timeout|ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENETUNREACH|Too many/i.test(errMsg);
|
|
507
|
+
const isAuth = /auth|login|credential|token|AUTHENTICATIONFAILED/i.test(errMsg);
|
|
508
|
+
if (isTransient) {
|
|
509
|
+
// Transient: just log, will auto-retry on next sync cycle
|
|
510
|
+
console.log(` [transient] ${accountId}: ${errMsg} — will retry next cycle`);
|
|
511
|
+
}
|
|
512
|
+
else if (isAuth && isOAuth) {
|
|
513
|
+
// OAuth auth error: auto-attempt re-auth
|
|
514
|
+
console.log(` [auth] ${accountId}: attempting automatic re-authentication...`);
|
|
515
|
+
this.reauthenticate(accountId).then(ok => {
|
|
516
|
+
if (!ok && !this.accountErrorShown.has(accountId)) {
|
|
517
|
+
this.accountErrorShown.add(accountId);
|
|
518
|
+
this.emit("accountError", accountId, errMsg, "Authentication expired — re-authenticate", true);
|
|
519
|
+
}
|
|
520
|
+
}).catch(() => {
|
|
521
|
+
if (!this.accountErrorShown.has(accountId)) {
|
|
522
|
+
this.accountErrorShown.add(accountId);
|
|
523
|
+
this.emit("accountError", accountId, errMsg, "Authentication expired — re-authenticate", true);
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
else if (!this.accountErrorShown.has(accountId)) {
|
|
528
|
+
// Non-transient, non-OAuth: show error banner
|
|
476
529
|
this.accountErrorShown.add(accountId);
|
|
477
|
-
|
|
478
|
-
const isOAuth = !!config?.tokenProvider;
|
|
479
|
-
const hint = errMsg.includes("max_userip_connections") || errMsg.includes("Too many")
|
|
480
|
-
? "Too many connections — backing off"
|
|
481
|
-
: isOAuth ? "Authentication may have expired" : "Check server connectivity";
|
|
482
|
-
this.emit("accountError", accountId, errMsg, hint, isOAuth);
|
|
530
|
+
this.emit("accountError", accountId, errMsg, isOAuth ? "Authentication may have expired" : "Check server connectivity", isOAuth);
|
|
483
531
|
}
|
|
484
532
|
}
|
|
485
533
|
finally {
|
|
486
|
-
if (client)
|
|
534
|
+
if (client) {
|
|
487
535
|
try {
|
|
488
536
|
await client.logout();
|
|
489
537
|
}
|
|
490
538
|
catch { /* ignore */ }
|
|
539
|
+
this.trackLogout(accountId);
|
|
540
|
+
}
|
|
491
541
|
}
|
|
492
542
|
}
|
|
493
543
|
// Phase 2: Sync remaining folders — priority order, skip Trash subfolders on first sync
|
|
@@ -500,41 +550,51 @@ export class ImapManager extends EventEmitter {
|
|
|
500
550
|
const pb = priorityOrder.indexOf(b.specialUse || "") >= 0 ? priorityOrder.indexOf(b.specialUse || "") : 5;
|
|
501
551
|
return pa - pb;
|
|
502
552
|
});
|
|
553
|
+
// Reuse one IMAP connection per account for all folders (avoid 87+ TLS handshakes)
|
|
503
554
|
let client = null;
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
const timeout = highestUid === 0 ? 180000 : 60000;
|
|
514
|
-
try {
|
|
515
|
-
client = this.createClient(accountId);
|
|
516
|
-
await Promise.race([
|
|
517
|
-
this.syncFolder(accountId, folder.id, client),
|
|
518
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error(`Sync timeout (${timeout / 1000}s)`)), timeout))
|
|
519
|
-
]);
|
|
520
|
-
await client.logout();
|
|
521
|
-
client = null;
|
|
522
|
-
}
|
|
523
|
-
catch (e) {
|
|
524
|
-
if (client) {
|
|
525
|
-
try {
|
|
526
|
-
await client.logout();
|
|
527
|
-
}
|
|
528
|
-
catch { /* ignore */ }
|
|
529
|
-
client = null;
|
|
555
|
+
try {
|
|
556
|
+
client = this.createClient(accountId);
|
|
557
|
+
for (const folder of remaining) {
|
|
558
|
+
// Skip Trash subfolders on first sync — they're large and low priority
|
|
559
|
+
const isTrashChild = folder.path.includes("/") && folder.path.toLowerCase().startsWith("trash");
|
|
560
|
+
const highestUid = this.db.getHighestUid(accountId, folder.id);
|
|
561
|
+
if (isTrashChild && highestUid === 0) {
|
|
562
|
+
console.log(` Deferring first sync of ${folder.path} (Trash subfolder)`);
|
|
563
|
+
continue;
|
|
530
564
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
565
|
+
// Longer timeout for folders we know are large (Trash, first sync)
|
|
566
|
+
const timeout = highestUid === 0 ? 180000 : 60000;
|
|
567
|
+
try {
|
|
568
|
+
await Promise.race([
|
|
569
|
+
this.syncFolder(accountId, folder.id, client),
|
|
570
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`Sync timeout (${timeout / 1000}s) on ${folder.path}`)), timeout))
|
|
571
|
+
]);
|
|
534
572
|
}
|
|
535
|
-
|
|
536
|
-
|
|
573
|
+
catch (e) {
|
|
574
|
+
if (e.responseText?.includes("doesn't exist")) {
|
|
575
|
+
console.log(` Removing non-existent folder: ${folder.path}`);
|
|
576
|
+
this.db.deleteFolder(folder.id);
|
|
577
|
+
}
|
|
578
|
+
else {
|
|
579
|
+
console.error(` Skipping folder ${folder.path}: ${e.message}`);
|
|
580
|
+
// Connection may be broken — reconnect
|
|
581
|
+
try {
|
|
582
|
+
await client.logout();
|
|
583
|
+
}
|
|
584
|
+
catch { /* */ }
|
|
585
|
+
this.trackLogout(accountId);
|
|
586
|
+
client = this.createClient(accountId);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
finally {
|
|
592
|
+
if (client) {
|
|
593
|
+
try {
|
|
594
|
+
await client.logout();
|
|
537
595
|
}
|
|
596
|
+
catch { /* */ }
|
|
597
|
+
this.trackLogout(accountId);
|
|
538
598
|
}
|
|
539
599
|
}
|
|
540
600
|
this.accountErrorShown.delete(accountId);
|
|
@@ -57,7 +57,10 @@ if (settings.accounts.length === 0) {
|
|
|
57
57
|
const dbDir = getConfigDir();
|
|
58
58
|
const db = new MailxDB(dbDir);
|
|
59
59
|
const imapManager = new ImapManager(db);
|
|
60
|
-
if (process.argv.includes("--
|
|
60
|
+
if (process.argv.includes("--legacy-imap") || process.argv.includes("-legacy-imap")) {
|
|
61
|
+
console.log(" Using legacy IMAP client (imapflow)");
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
61
64
|
imapManager.useNativeClient = true;
|
|
62
65
|
console.log(" Using native IMAP client (transport-agnostic)");
|
|
63
66
|
}
|
|
@@ -88,6 +91,9 @@ app.get("/api/version", (req, res) => {
|
|
|
88
91
|
const storage = getStorageInfo();
|
|
89
92
|
res.json({ version: SERVER_VERSION, theme: settings.ui?.theme || "system", storage });
|
|
90
93
|
});
|
|
94
|
+
app.all("/info", (req, res) => {
|
|
95
|
+
res.json({ version: SERVER_VERSION, uptime: Math.round(process.uptime()), port: PORT });
|
|
96
|
+
});
|
|
91
97
|
app.get("/status", (req, res) => {
|
|
92
98
|
const accounts = db.getAccounts();
|
|
93
99
|
const pendingSync = db.getTotalPendingSyncCount();
|