@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 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 holding handles on the mailx install directory
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 wmic = execSync(`wmic process where "name='node.exe'" get processid,commandline /format:csv`, { encoding: "utf-8" });
66
- for (const line of wmic.split("\n")) {
67
- if (line.includes("mailx-server") || line.includes("mailx\\packages")) {
68
- const pid = line.trim().split(",").pop();
69
- if (pid && /^\d+$/.test(pid)) {
70
- try { execSync(`taskkill /F /PID ${pid}`, { stdio: "pipe" }); console.log(`Killed PID ${pid} (mailx node process)`); killed++; } catch { /* */ }
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(process.env.USERPROFILE || process.env.HOME || ".", ".mailx", "mailx-app.lock");
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 launcher window
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.98",
3
+ "version": "1.0.100",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -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 from imapflow errors */
15
+ /** Extract full error detail with provenance */
16
16
  function imapError(err) {
17
- const parts = [err.message || "Unknown error"];
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
- delClient = this.createClient(accountId);
374
- const serverUids = new Set(await delClient.getUids(folder.path));
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
- // Emit user-facing error once per account per session
475
- if (!this.accountErrorShown.has(accountId)) {
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
- const config = this.configs.get(accountId);
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
- for (const folder of remaining) {
505
- // Skip Trash subfolders on first sync — they're large and low priority
506
- const isTrashChild = folder.path.includes("/") && folder.path.toLowerCase().startsWith("trash");
507
- const highestUid = this.db.getHighestUid(accountId, folder.id);
508
- if (isTrashChild && highestUid === 0) {
509
- console.log(` Deferring first sync of ${folder.path} (Trash subfolder)`);
510
- continue;
511
- }
512
- // Longer timeout for folders we know are large (Trash, first sync)
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
- if (e.responseText?.includes("doesn't exist")) {
532
- console.log(` Removing non-existent folder: ${folder.path}`);
533
- this.db.deleteFolder(folder.id);
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
- else {
536
- console.error(` Skipping folder ${folder.path}: ${e.message}`);
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("--native-imap") || process.argv.includes("-native-imap")) {
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();