@bobfrankston/mailx 1.0.97 → 1.0.99

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.97",
3
+ "version": "1.0.99",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -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 */
@@ -466,20 +479,39 @@ export class ImapManager extends EventEmitter {
466
479
  const errMsg = imapError(e);
467
480
  this.emit("syncError", accountId, errMsg);
468
481
  console.error(`Sync error for ${accountId}: ${errMsg}`);
482
+ const config = this.configs.get(accountId);
483
+ const isOAuth = !!config?.tokenProvider;
469
484
  // Connection limit — back off for 60 seconds
470
485
  if (errMsg.includes("max_userip_connections") || errMsg.includes("Too many simultaneous")) {
471
486
  this.connectionBackoff.set(accountId, Date.now() + 60000);
472
487
  console.log(` [backoff] ${accountId}: connection limit hit, backing off 60s`);
473
488
  }
474
- // Emit user-facing error once per account per session
475
- if (!this.accountErrorShown.has(accountId)) {
489
+ // Classify error: transient (timeout, connection) vs auth (credentials, token)
490
+ const isTransient = /timeout|ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENETUNREACH|Too many/i.test(errMsg);
491
+ const isAuth = /auth|login|credential|token|AUTHENTICATIONFAILED/i.test(errMsg);
492
+ if (isTransient) {
493
+ // Transient: just log, will auto-retry on next sync cycle
494
+ console.log(` [transient] ${accountId}: ${errMsg} — will retry next cycle`);
495
+ }
496
+ else if (isAuth && isOAuth) {
497
+ // OAuth auth error: auto-attempt re-auth
498
+ console.log(` [auth] ${accountId}: attempting automatic re-authentication...`);
499
+ this.reauthenticate(accountId).then(ok => {
500
+ if (!ok && !this.accountErrorShown.has(accountId)) {
501
+ this.accountErrorShown.add(accountId);
502
+ this.emit("accountError", accountId, errMsg, "Authentication expired — re-authenticate", true);
503
+ }
504
+ }).catch(() => {
505
+ if (!this.accountErrorShown.has(accountId)) {
506
+ this.accountErrorShown.add(accountId);
507
+ this.emit("accountError", accountId, errMsg, "Authentication expired — re-authenticate", true);
508
+ }
509
+ });
510
+ }
511
+ else if (!this.accountErrorShown.has(accountId)) {
512
+ // Non-transient, non-OAuth: show error banner
476
513
  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);
514
+ this.emit("accountError", accountId, errMsg, isOAuth ? "Authentication may have expired" : "Check server connectivity", isOAuth);
483
515
  }
484
516
  }
485
517
  finally {
@@ -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();