@bobfrankston/mailx 1.0.117 → 1.0.119

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
@@ -10,26 +10,20 @@
10
10
  * mailx --verbose Show detailed startup info
11
11
  * mailx -v / --version Show version and exit
12
12
  */
13
-
14
13
  import fs from "node:fs";
15
14
  import path from "node:path";
16
15
  import net from "node:net";
17
-
18
16
  const PORT = 9333;
19
17
  const args = process.argv.slice(2);
20
-
21
18
  // Normalize: accept both -flag and --flag
22
19
  function hasFlag(name) { return args.includes(`-${name}`) || args.includes(`--${name}`); }
23
-
24
20
  const serverMode = hasFlag("server");
25
21
  const noBrowser = hasFlag("no-browser");
26
22
  const verbose = hasFlag("verbose");
27
-
28
23
  const setupMode = hasFlag("setup");
29
24
  const addMode = hasFlag("add");
30
25
  const testMode = hasFlag("test");
31
26
  const rebuildMode = hasFlag("rebuild");
32
-
33
27
  // Validate arguments
34
28
  const knownFlags = ["server", "no-browser", "verbose", "external", "kill", "v", "version", "setup", "add", "test", "rebuild", "native-imap"];
35
29
  for (const arg of args) {
@@ -40,105 +34,133 @@ for (const arg of args) {
40
34
  process.exit(1);
41
35
  }
42
36
  }
43
-
44
- function log(...msg) { if (verbose) console.log("[mailx]", ...msg); }
45
-
37
+ function log(...msg) { if (verbose)
38
+ console.log("[mailx]", ...msg); }
46
39
  // Kill any running mailx server
47
40
  if (hasFlag("kill")) {
48
41
  log("Killing mailx processes...");
49
42
  const { execSync } = await import("node:child_process");
50
43
  let killed = 0;
51
-
52
44
  // Try graceful exit first
53
45
  try {
54
46
  execSync(`curl -s -m 2 http://localhost:${PORT}/api/exit`, { stdio: "pipe" });
55
47
  log("Sent graceful exit");
56
48
  execSync("timeout /t 1 /nobreak", { stdio: "pipe" });
57
- } catch { /* server may not be responding */ }
58
-
49
+ }
50
+ catch { /* server may not be responding */ }
59
51
  if (process.platform === "win32") {
60
52
  // Kill by port
61
53
  try {
62
54
  const out = execSync(`netstat -ano | findstr :${PORT} | findstr LISTENING`, { encoding: "utf-8" }).trim();
63
55
  const pids = [...new Set(out.split("\n").map(l => l.trim().split(/\s+/).pop()).filter(Boolean))];
64
56
  for (const pid of pids) {
65
- try { execSync(`taskkill /F /PID ${pid}`, { stdio: "pipe" }); console.log(`Killed PID ${pid} (port ${PORT})`); killed++; } catch { /* */ }
57
+ try {
58
+ execSync(`taskkill /F /PID ${pid}`, { stdio: "pipe" });
59
+ console.log(`Killed PID ${pid} (port ${PORT})`);
60
+ killed++;
61
+ }
62
+ catch { /* */ }
66
63
  }
67
- } catch { /* no process on port */ }
68
-
64
+ }
65
+ catch { /* no process on port */ }
69
66
  // Kill any node.exe running mailx-server (uses tasklist + powershell instead of wmic)
70
67
  try {
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();
68
+ const ps = execSync(`powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"name='node.exe'\\" | Where-Object { $_.CommandLine -match 'mailx-server|mailx\\\\packages' } | Select-Object -ExpandProperty ProcessId"`, { encoding: "utf-8" }).trim();
75
69
  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 { /* */ }
70
+ try {
71
+ execSync(`taskkill /F /PID ${pid}`, { stdio: "pipe" });
72
+ console.log(`Killed PID ${pid} (mailx node process)`);
73
+ killed++;
74
+ }
75
+ catch { /* */ }
77
76
  }
78
- } catch { /* */ }
79
-
77
+ }
78
+ catch { /* */ }
80
79
  // Kill mailx-app.exe
81
- try { execSync("taskkill /F /IM mailx-app.exe", { stdio: "pipe" }); console.log("Killed mailx-app.exe"); killed++; } catch { /* */ }
82
- } else {
83
- try { execSync(`fuser -k ${PORT}/tcp`, { stdio: "pipe" }); console.log(`Killed process on port ${PORT}`); killed++; } catch { /* */ }
80
+ try {
81
+ execSync("taskkill /F /IM mailx-app.exe", { stdio: "pipe" });
82
+ console.log("Killed mailx-app.exe");
83
+ killed++;
84
+ }
85
+ catch { /* */ }
86
+ }
87
+ else {
88
+ try {
89
+ execSync(`fuser -k ${PORT}/tcp`, { stdio: "pipe" });
90
+ console.log(`Killed process on port ${PORT}`);
91
+ killed++;
92
+ }
93
+ catch { /* */ }
84
94
  }
85
-
86
95
  // Clean up stale SQLite WAL/SHM files
87
96
  const mailxDir = path.join(process.env.USERPROFILE || process.env.HOME || ".", ".mailx");
88
97
  for (const ext of ["mailx.db-shm", "mailx.db-wal"]) {
89
98
  const p = path.join(mailxDir, ext);
90
- try { fs.unlinkSync(p); log(`Cleaned ${ext}`); } catch { /* */ }
99
+ try {
100
+ fs.unlinkSync(p);
101
+ log(`Cleaned ${ext}`);
102
+ }
103
+ catch { /* */ }
91
104
  }
92
-
93
105
  // Remove lock file
94
106
  const lockPath = path.join(mailxDir, "mailx-app.lock");
95
- try { fs.unlinkSync(lockPath); log("Removed lock file"); } catch { /* */ }
96
-
97
- if (killed === 0) console.log("No mailx processes found");
107
+ try {
108
+ fs.unlinkSync(lockPath);
109
+ log("Removed lock file");
110
+ }
111
+ catch { /* */ }
112
+ if (killed === 0)
113
+ console.log("No mailx processes found");
98
114
  process.exit(0);
99
115
  }
100
-
101
116
  // Rebuild: wipe DB + message store, keep accounts/settings
102
117
  if (rebuildMode) {
103
118
  const { getConfigDir, getStorePath } = await import("@bobfrankston/mailx-settings");
104
119
  const dbDir = getConfigDir();
105
120
  const storePath = getStorePath();
106
-
107
121
  console.log("Rebuilding mailx local cache...");
108
122
  console.log(" Accounts and settings will be preserved.");
109
-
110
123
  // Remove DB files
111
124
  for (const f of ["mailx.db", "mailx.db-wal", "mailx.db-shm"]) {
112
125
  const p = path.join(dbDir, f);
113
- if (fs.existsSync(p)) { fs.unlinkSync(p); console.log(` Deleted ${f}`); }
126
+ if (fs.existsSync(p)) {
127
+ fs.unlinkSync(p);
128
+ console.log(` Deleted ${f}`);
129
+ }
114
130
  }
115
-
116
131
  // Remove message store
117
132
  if (fs.existsSync(storePath)) {
118
133
  fs.rmSync(storePath, { recursive: true });
119
134
  console.log(` Deleted message store`);
120
135
  }
121
-
122
136
  console.log(" Rebuild complete. Run 'mailx' to start fresh.");
123
137
  process.exit(0);
124
138
  }
125
-
126
139
  // Version
127
140
  if (hasFlag("v") || hasFlag("version")) {
128
141
  const root = path.join(import.meta.dirname, "..");
129
142
  const ver = (pkg) => {
130
- for (const dir of [`${root}/node_modules/${pkg}`, `${root}/node_modules/@bobfrankston/${pkg.replace("@bobfrankston/","")}`]) {
131
- try { return JSON.parse(fs.readFileSync(`${dir}/package.json`, "utf-8")).version; } catch { /* */ }
143
+ for (const dir of [`${root}/node_modules/${pkg}`, `${root}/node_modules/@bobfrankston/${pkg.replace("@bobfrankston/", "")}`]) {
144
+ try {
145
+ return JSON.parse(fs.readFileSync(`${dir}/package.json`, "utf-8")).version;
146
+ }
147
+ catch { /* */ }
132
148
  }
133
149
  // Check workspace packages
134
- const short = pkg.replace("@bobfrankston/","");
135
- try { return JSON.parse(fs.readFileSync(`${root}/packages/${short}/package.json`, "utf-8")).version; } catch { /* */ }
150
+ const short = pkg.replace("@bobfrankston/", "");
151
+ try {
152
+ return JSON.parse(fs.readFileSync(`${root}/packages/${short}/package.json`, "utf-8")).version;
153
+ }
154
+ catch { /* */ }
136
155
  return "not found";
137
156
  };
138
157
  try {
139
158
  const pkg = JSON.parse(fs.readFileSync(`${root}/package.json`, "utf-8"));
140
159
  console.log(`mailx v${pkg.version}`);
141
- } catch { console.log("mailx (version unknown)"); }
160
+ }
161
+ catch {
162
+ console.log("mailx (version unknown)");
163
+ }
142
164
  console.log(` node ${process.version}`);
143
165
  console.log(` iflow ${ver("@bobfrankston/iflow")}`);
144
166
  console.log(` miscinfo ${ver("@bobfrankston/miscinfo")}`);
@@ -149,7 +171,6 @@ if (hasFlag("v") || hasFlag("version")) {
149
171
  console.log(` platform ${process.platform} ${process.arch}`);
150
172
  process.exit(0);
151
173
  }
152
-
153
174
  function isPortInUse(port) {
154
175
  return new Promise((resolve) => {
155
176
  const socket = net.createConnection({ port, host: "127.0.0.1" });
@@ -157,27 +178,27 @@ function isPortInUse(port) {
157
178
  socket.once("error", () => resolve(false));
158
179
  });
159
180
  }
160
-
161
181
  function openBrowser(url) {
162
182
  import("node:child_process").then(({ exec }) => {
163
- if (process.platform === "win32") exec(`start "" "${url}"`);
164
- else if (process.platform === "darwin") exec(`open "${url}"`);
165
- else exec(`xdg-open "${url}"`);
183
+ if (process.platform === "win32")
184
+ exec(`start "" "${url}"`);
185
+ else if (process.platform === "darwin")
186
+ exec(`open "${url}"`);
187
+ else
188
+ exec(`xdg-open "${url}"`);
166
189
  });
167
190
  }
168
-
169
- function prompt(question) {
170
- const readline = require("readline");
191
+ async function prompt(question) {
192
+ const readline = await import("readline");
171
193
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
172
- return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans.trim()); }));
194
+ return new Promise(resolve => rl.question(question, (ans) => { rl.close(); resolve(ans.trim()); }));
173
195
  }
174
-
175
196
  /** Detect mounted cloud drives that might have settings */
176
197
  function findCloudSettings() {
177
198
  const home = process.env.USERPROFILE || process.env.HOME || "";
178
199
  const checks = [
179
200
  // OneDrive
180
- { provider: "onedrive", dir: process.env.OneDrive && path.join(process.env.OneDrive, "home", ".mailx") },
201
+ { provider: "onedrive", dir: process.env.OneDrive ? path.join(process.env.OneDrive, "home", ".mailx") : "" },
181
202
  { provider: "onedrive", dir: path.join(home, "OneDrive", "home", ".mailx") },
182
203
  { provider: "onedrive", dir: path.join(home, "onedrive", "home", ".mailx") },
183
204
  // Google Drive
@@ -199,19 +220,18 @@ function findCloudSettings() {
199
220
  }
200
221
  return null;
201
222
  }
202
-
203
223
  /** Check if mailx is configured (has accounts) */
204
224
  function hasConfig() {
205
225
  const home = process.env.USERPROFILE || process.env.HOME || "";
206
226
  const mailxDir = path.join(home, ".mailx");
207
227
  // Check local settings
208
228
  for (const f of ["settings.jsonc", "accounts.jsonc", "config.jsonc"]) {
209
- if (fs.existsSync(path.join(mailxDir, f))) return true;
229
+ if (fs.existsSync(path.join(mailxDir, f)))
230
+ return true;
210
231
  }
211
232
  // Check cloud drives
212
233
  return findCloudSettings() !== null;
213
234
  }
214
-
215
235
  /** Try to auto-discover mail server settings from email domain */
216
236
  async function autoDiscover(domain) {
217
237
  // 1. Try Thunderbird ISPDB
@@ -229,8 +249,8 @@ async function autoDiscover(domain) {
229
249
  };
230
250
  }
231
251
  }
232
- } catch { /* timeout or not found */ }
233
-
252
+ }
253
+ catch { /* timeout or not found */ }
234
254
  // 2. Try DNS SRV records
235
255
  try {
236
256
  const dns = await import("node:dns/promises");
@@ -243,29 +263,26 @@ async function autoDiscover(domain) {
243
263
  source: "DNS SRV",
244
264
  };
245
265
  }
246
- } catch { /* DNS failed */ }
247
-
266
+ }
267
+ catch { /* DNS failed */ }
248
268
  return null;
249
269
  }
250
-
251
270
  /** Prompt user for account details, auto-discover where possible */
252
271
  async function promptForAccount(intro) {
253
- if (intro) console.log(intro);
272
+ if (intro)
273
+ console.log(intro);
254
274
  const email = await prompt("Email address (or 'skip'): ");
255
- if (!email || email.toLowerCase() === "skip") return null;
256
-
275
+ if (!email || email.toLowerCase() === "skip")
276
+ return null;
257
277
  const domain = email.split("@")[1]?.toLowerCase() || "";
258
278
  const knownOAuth = ["gmail.com", "googlemail.com", "outlook.com", "hotmail.com"];
259
-
260
279
  if (knownOAuth.includes(domain)) {
261
280
  console.log(` ${domain}: OAuth2 — no password needed. Browser will prompt for authorization.`);
262
281
  return { email };
263
282
  }
264
-
265
283
  // Try auto-discovery
266
284
  console.log(` Looking up mail servers for ${domain}...`);
267
285
  const discovered = await autoDiscover(domain);
268
-
269
286
  if (discovered) {
270
287
  console.log(` Found via ${discovered.source}: IMAP ${discovered.imap.host}:${discovered.imap.port}, SMTP ${discovered.smtp.host}:${discovered.smtp.port}`);
271
288
  if (discovered.imap.auth === "oauth2") {
@@ -279,7 +296,6 @@ async function promptForAccount(intro) {
279
296
  smtp: { host: discovered.smtp.host, port: discovered.smtp.port },
280
297
  };
281
298
  }
282
-
283
299
  // Manual fallback
284
300
  console.log(" Could not auto-detect servers. Enter manually:");
285
301
  const password = await prompt("Password: ");
@@ -291,14 +307,28 @@ async function promptForAccount(intro) {
291
307
  smtp: { host: smtpHost },
292
308
  };
293
309
  }
294
-
310
+ /** Find a mounted cloud drive (for saving new settings) */
311
+ function findMountedDrive() {
312
+ const home = process.env.USERPROFILE || process.env.HOME || "";
313
+ const checks = [
314
+ { provider: "onedrive", base: process.env.OneDrive || "", dir: process.env.OneDrive ? path.join(process.env.OneDrive, "home", ".mailx") : "" },
315
+ { provider: "onedrive", base: path.join(home, "OneDrive"), dir: path.join(home, "OneDrive", "home", ".mailx") },
316
+ { provider: "onedrive", base: path.join(home, "onedrive"), dir: path.join(home, "onedrive", "home", ".mailx") },
317
+ { provider: "gdrive", base: path.join(home, "Google Drive", "My Drive"), dir: path.join(home, "Google Drive", "My Drive", "home", ".mailx") },
318
+ { provider: "gdrive", base: path.join(home, "Google Drive Streaming", "My Drive"), dir: path.join(home, "Google Drive Streaming", "My Drive", "home", ".mailx") },
319
+ { provider: "dropbox", base: path.join(home, "Dropbox"), dir: path.join(home, "Dropbox", "home", ".mailx") },
320
+ ];
321
+ for (const c of checks) {
322
+ if (c.base && fs.existsSync(c.base))
323
+ return c;
324
+ }
325
+ return null;
326
+ }
295
327
  /** Interactive first-time setup */
296
328
  async function runSetup() {
297
329
  console.log("\nmailx — first-time setup\n");
298
-
299
330
  const home = process.env.USERPROFILE || process.env.HOME || "";
300
331
  const mailxDir = path.join(home, ".mailx");
301
-
302
332
  // Check for existing cloud settings
303
333
  const cloud = findCloudSettings();
304
334
  if (cloud) {
@@ -310,7 +340,6 @@ async function runSetup() {
310
340
  console.log("Setup complete. Starting mailx...\n");
311
341
  return true;
312
342
  }
313
-
314
343
  // No cloud settings found — prompt for email
315
344
  console.log("No existing settings found on OneDrive, Google Drive, or Dropbox.\n");
316
345
  const account = await promptForAccount("Gmail is recommended as the first account (provides contacts + cloud sync).\n");
@@ -319,33 +348,29 @@ async function runSetup() {
319
348
  fs.mkdirSync(mailxDir, { recursive: true });
320
349
  return false;
321
350
  }
322
-
323
351
  const name = await prompt(`Your name (for From: header) [${account.email.split("@")[0]}]: `) || account.email.split("@")[0];
324
-
325
352
  const settings = {
326
353
  name,
327
354
  accounts: [account],
328
355
  ui: { theme: "system" },
329
356
  sync: { intervalMinutes: 5, historyDays: 0 },
330
357
  };
331
-
332
358
  const domain = account.email.split("@")[1]?.toLowerCase() || "";
333
359
  const isGmail = domain === "gmail.com" || domain === "googlemail.com";
334
-
335
360
  // Ask where to store settings
336
361
  const mountedDrive = findMountedDrive();
337
362
  let storageChoice = "local";
338
-
339
363
  if (mountedDrive) {
340
364
  const useCloud = await prompt(`Store settings on ${mountedDrive.provider} (syncs across machines)? [Y/n]: `);
341
- if (!useCloud || useCloud.toLowerCase() !== "n") storageChoice = mountedDrive.provider;
342
- } else if (isGmail) {
365
+ if (!useCloud || useCloud.toLowerCase() !== "n")
366
+ storageChoice = mountedDrive.provider;
367
+ }
368
+ else if (isGmail) {
343
369
  const useGDrive = await prompt("Store settings on Google Drive (syncs across machines, editable via drive.google.com)? [Y/n]: ");
344
- if (!useGDrive || useGDrive.toLowerCase() !== "n") storageChoice = "gdrive-api";
370
+ if (!useGDrive || useGDrive.toLowerCase() !== "n")
371
+ storageChoice = "gdrive-api";
345
372
  }
346
-
347
373
  fs.mkdirSync(mailxDir, { recursive: true });
348
-
349
374
  if (storageChoice === "gdrive-api") {
350
375
  // Save to Google Drive via API (not mounted)
351
376
  console.log("\nSaving settings to Google Drive via API...");
@@ -360,19 +385,23 @@ async function runSetup() {
360
385
  const config = { sharedDir: { provider: "gdrive", path: "home/.mailx" } };
361
386
  fs.writeFileSync(path.join(mailxDir, "config.jsonc"), JSON.stringify(config, null, 2));
362
387
  console.log("Local config created pointing to Google Drive.");
363
- } else {
388
+ }
389
+ else {
364
390
  console.log("Google Drive write failed — saving locally instead.");
365
391
  fs.writeFileSync(path.join(mailxDir, "settings.jsonc"), JSON.stringify(settings, null, 2));
366
392
  }
367
- } else {
393
+ }
394
+ else {
368
395
  console.log("Google Drive API not available — saving locally.");
369
396
  fs.writeFileSync(path.join(mailxDir, "settings.jsonc"), JSON.stringify(settings, null, 2));
370
397
  }
371
- } catch (e) {
398
+ }
399
+ catch (e) {
372
400
  console.log(`Google Drive error: ${e.message} — saving locally.`);
373
401
  fs.writeFileSync(path.join(mailxDir, "settings.jsonc"), JSON.stringify(settings, null, 2));
374
402
  }
375
- } else if (storageChoice !== "local" && mountedDrive) {
403
+ }
404
+ else if (storageChoice !== "local" && mountedDrive) {
376
405
  // Save to mounted cloud drive
377
406
  console.log(`\nSaving settings to ${mountedDrive.provider} at ${mountedDrive.dir}...`);
378
407
  fs.mkdirSync(mountedDrive.dir, { recursive: true });
@@ -380,57 +409,36 @@ async function runSetup() {
380
409
  const config = { sharedDir: { provider: mountedDrive.provider, path: "home/.mailx" } };
381
410
  fs.writeFileSync(path.join(mailxDir, "config.jsonc"), JSON.stringify(config, null, 2));
382
411
  console.log("Settings saved to cloud drive + local config created.");
383
- } else {
412
+ }
413
+ else {
384
414
  // Save locally
385
415
  console.log(`\nSaving settings to ${mailxDir}...`);
386
416
  fs.writeFileSync(path.join(mailxDir, "settings.jsonc"), JSON.stringify(settings, null, 2));
387
417
  }
388
-
389
418
  console.log("Setup complete. Starting mailx...\n");
390
419
  return true;
391
420
  }
392
-
393
- /** Find a mounted cloud drive (for saving new settings) */
394
- function findMountedDrive() {
395
- const home = process.env.USERPROFILE || process.env.HOME || "";
396
- const checks = [
397
- { provider: "onedrive", base: process.env.OneDrive, dir: process.env.OneDrive && path.join(process.env.OneDrive, "home", ".mailx") },
398
- { provider: "onedrive", base: path.join(home, "OneDrive"), dir: path.join(home, "OneDrive", "home", ".mailx") },
399
- { provider: "onedrive", base: path.join(home, "onedrive"), dir: path.join(home, "onedrive", "home", ".mailx") },
400
- { provider: "gdrive", base: path.join(home, "Google Drive", "My Drive"), dir: path.join(home, "Google Drive", "My Drive", "home", ".mailx") },
401
- { provider: "gdrive", base: path.join(home, "Google Drive Streaming", "My Drive"), dir: path.join(home, "Google Drive Streaming", "My Drive", "home", ".mailx") },
402
- { provider: "dropbox", base: path.join(home, "Dropbox"), dir: path.join(home, "Dropbox", "home", ".mailx") },
403
- ];
404
- for (const c of checks) {
405
- if (c.base && fs.existsSync(c.base)) return c;
406
- }
407
- return null;
408
- }
409
-
410
421
  /** Test account connectivity — IMAP connect, SMTP send, sync round-trip */
411
422
  async function runTest() {
412
423
  console.log("\nmailx — connection test\n");
413
-
414
424
  // Start server in-process to access settings
415
425
  console.log("Loading settings...");
416
426
  const { loadSettings, getSharedDir } = await import("../packages/mailx-settings/index.js");
417
427
  const { initLocalConfig } = await import("../packages/mailx-settings/index.js");
418
428
  initLocalConfig();
419
429
  const settings = loadSettings();
420
-
421
430
  if (settings.accounts.length === 0) {
422
431
  console.log("No accounts configured. Run: mailx -setup");
423
432
  process.exit(1);
424
433
  }
425
-
426
434
  console.log(`Shared dir: ${getSharedDir()}`);
427
- console.log(`Accounts: ${settings.accounts.map(a => `${a.label || a.name} <${a.email}>`).join(", ")}\n`);
428
-
435
+ console.log(`Accounts: ${settings.accounts.map((a) => `${a.label || a.name} <${a.email}>`).join(", ")}\n`);
429
436
  for (const account of settings.accounts) {
430
- if (!account.enabled) { console.log(` ${account.label || account.id}: SKIPPED (disabled)\n`); continue; }
431
-
437
+ if (!account.enabled) {
438
+ console.log(` ${account.label || account.id}: SKIPPED (disabled)\n`);
439
+ continue;
440
+ }
432
441
  console.log(`Testing ${account.label || account.id} (${account.email}):`);
433
-
434
442
  // Test IMAP
435
443
  try {
436
444
  const { ImapClient, createAutoImapConfig } = await import("@bobfrankston/iflow");
@@ -444,19 +452,20 @@ async function runTest() {
444
452
  const folders = await client.getFolderList();
445
453
  await client.logout();
446
454
  console.log(` IMAP: OK (${folders.length} folders)`);
447
- } catch (e) {
455
+ }
456
+ catch (e) {
448
457
  console.log(` IMAP: FAILED — ${e.message}`);
449
458
  }
450
-
451
459
  // Test SMTP
452
460
  try {
453
461
  const { createTransport } = await import("nodemailer");
454
462
  let smtpAuth;
455
463
  if (account.smtp.auth === "password") {
456
464
  smtpAuth = { user: account.smtp.user, pass: account.smtp.password };
457
- } else if (account.smtp.auth === "oauth2") {
465
+ }
466
+ else if (account.smtp.auth === "oauth2") {
458
467
  // Try to get OAuth token
459
- const { ImapClient, createAutoImapConfig } = await import("@bobfrankston/iflow");
468
+ const { createAutoImapConfig } = await import("@bobfrankston/iflow");
460
469
  const config = createAutoImapConfig({
461
470
  server: account.imap.host,
462
471
  port: account.imap.port,
@@ -476,7 +485,6 @@ async function runTest() {
476
485
  });
477
486
  await transport.verify();
478
487
  console.log(` SMTP: OK`);
479
-
480
488
  // Send test message to self
481
489
  const testSubject = `mailx test — ${new Date().toLocaleString()}`;
482
490
  await transport.sendMail({
@@ -487,28 +495,24 @@ async function runTest() {
487
495
  });
488
496
  console.log(` SEND: OK — test message sent to ${account.email}`);
489
497
  console.log(` Subject: "${testSubject}"`);
490
- } catch (e) {
498
+ }
499
+ catch (e) {
491
500
  console.log(` SMTP: FAILED — ${e.message}`);
492
501
  }
493
-
494
502
  console.log();
495
503
  }
496
-
497
504
  console.log("Test complete. Check your inbox for the test message(s).");
498
505
  process.exit(0);
499
506
  }
500
-
501
507
  async function main() {
502
508
  log(`Platform: ${process.platform} ${process.arch}`);
503
509
  log(`Node: ${process.version}`);
504
510
  log(`Mode: ${serverMode ? "server" : setupMode ? "setup" : "auto-detect"}`);
505
-
506
511
  // Test connectivity
507
512
  if (testMode) {
508
513
  await runTest();
509
514
  return;
510
515
  }
511
-
512
516
  // Add account to existing config
513
517
  if (addMode) {
514
518
  const account = await promptForAccount();
@@ -517,8 +521,14 @@ async function main() {
517
521
  const mailxDir = path.join(home, ".mailx");
518
522
  const settingsPath = path.join(mailxDir, "settings.jsonc");
519
523
  let settings;
520
- try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8").replace(/\r/g, "").replace(/\/\/.*/g, "")); } catch { settings = { accounts: [] }; }
521
- if (!settings.accounts) settings.accounts = [];
524
+ try {
525
+ settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8").replace(/\r/g, "").replace(/\/\/.*/g, ""));
526
+ }
527
+ catch {
528
+ settings = { accounts: [] };
529
+ }
530
+ if (!settings.accounts)
531
+ settings.accounts = [];
522
532
  settings.accounts.push(account);
523
533
  fs.mkdirSync(mailxDir, { recursive: true });
524
534
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
@@ -526,61 +536,60 @@ async function main() {
526
536
  }
527
537
  process.exit(0);
528
538
  }
529
-
530
539
  // Auto-detect first run — enter setup if no config exists
531
540
  if (setupMode || (!serverMode && !hasConfig())) {
532
- if (!setupMode) console.log("No mailx configuration found.");
541
+ if (!setupMode)
542
+ console.log("No mailx configuration found.");
533
543
  await runSetup();
534
544
  }
535
-
536
545
  if (serverMode) {
537
546
  // Server mode — Express + WebSocket, open browser
538
547
  const inUse = await isPortInUse(PORT);
539
548
  if (inUse) {
540
549
  console.log(`mailx server already running on port ${PORT}`);
541
- if (!noBrowser) openBrowser(`http://127.0.0.1:${PORT}`);
550
+ if (!noBrowser)
551
+ openBrowser(`http://127.0.0.1:${PORT}`);
542
552
  return;
543
553
  }
544
-
545
554
  console.log("Starting mailx server...");
546
555
  log(`Loading server from: ${path.join(import.meta.dirname, "..", "packages", "mailx-server", "index.js")}`);
547
- if (hasFlag("external")) process.argv.push("--external");
556
+ if (hasFlag("external"))
557
+ process.argv.push("--external");
548
558
  await import("../packages/mailx-server/index.js");
549
-
550
559
  if (!noBrowser) {
551
560
  for (let i = 0; i < 30; i++) {
552
561
  await new Promise(r => setTimeout(r, 200));
553
- if (await isPortInUse(PORT)) break;
562
+ if (await isPortInUse(PORT))
563
+ break;
554
564
  }
555
565
  openBrowser(`http://127.0.0.1:${PORT}`);
556
566
  console.log("mailx opened (browser)");
557
567
  }
558
-
559
568
  // Keep process alive — server is running
560
- await new Promise(() => {});
561
- } else {
569
+ await new Promise(() => { });
570
+ }
571
+ else {
562
572
  // Default: launch native WebView app
563
573
  let binaryName;
564
- if (process.platform === "win32") binaryName = "mailx-app.exe";
565
- else if (process.platform === "darwin") binaryName = process.arch === "arm64" ? "mailx-app-arm64" : "mailx-app";
566
- else binaryName = process.arch === "arm64" ? "mailx-app-linux-aarch64" : "mailx-app-linux";
567
-
574
+ if (process.platform === "win32")
575
+ binaryName = "mailx-app.exe";
576
+ else if (process.platform === "darwin")
577
+ binaryName = process.arch === "arm64" ? "mailx-app-arm64" : "mailx-app";
578
+ else
579
+ binaryName = process.arch === "arm64" ? "mailx-app-linux-aarch64" : "mailx-app-linux";
568
580
  const launcherPaths = [
569
581
  path.join(import.meta.dirname, "..", "launcher", "bin", binaryName),
570
582
  path.join(import.meta.dirname, "..", "launcher", "target", "release", binaryName),
571
583
  ];
572
-
573
584
  log(`Looking for native launcher: ${binaryName}`);
574
- for (const p of launcherPaths) log(` ${fs.existsSync(p) ? "FOUND" : "not found"}: ${p}`);
575
-
585
+ for (const p of launcherPaths)
586
+ log(` ${fs.existsSync(p) ? "FOUND" : "not found"}: ${p}`);
576
587
  let launcherPath = launcherPaths.find(p => fs.existsSync(p));
577
-
578
588
  // On Linux, skip native launcher if no display server available
579
589
  if (launcherPath && process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
580
590
  log("No display server (DISPLAY/WAYLAND_DISPLAY not set) — skipping native launcher");
581
591
  launcherPath = undefined;
582
592
  }
583
-
584
593
  if (launcherPath) {
585
594
  console.log("Starting mailx...");
586
595
  log(`Launching: ${launcherPath}`);
@@ -594,13 +603,15 @@ async function main() {
594
603
  });
595
604
  child.unref();
596
605
  console.log("mailx launched");
597
- } catch (e) {
606
+ }
607
+ catch (e) {
598
608
  console.log(`Native launcher failed: ${e.message}`);
599
609
  console.log("Starting in browser mode...");
600
610
  process.argv.push("--server");
601
611
  await main();
602
612
  }
603
- } else {
613
+ }
614
+ else {
604
615
  console.log("Starting in browser mode...");
605
616
  log("No native launcher — falling back to --server mode");
606
617
  process.argv.push("--server");
@@ -608,5 +619,5 @@ async function main() {
608
619
  }
609
620
  }
610
621
  }
611
-
612
622
  main().catch(console.error);
623
+ //# sourceMappingURL=mailx.js.map
@@ -3,39 +3,37 @@
3
3
  * Post-install script: creates symlinks for workspace packages
4
4
  * so they resolve as @bobfrankston/mailx-* in node_modules.
5
5
  */
6
-
7
6
  import fs from "node:fs";
8
7
  import path from "node:path";
9
-
10
8
  const root = path.resolve(import.meta.dirname, "..");
11
9
  const packagesDir = path.join(root, "packages");
12
10
  const nmDir = path.join(root, "node_modules", "@bobfrankston");
13
-
14
- if (!fs.existsSync(packagesDir)) process.exit(0); // not in workspace layout
15
-
11
+ if (!fs.existsSync(packagesDir))
12
+ process.exit(0); // not in workspace layout
16
13
  fs.mkdirSync(nmDir, { recursive: true });
17
-
18
14
  for (const dir of fs.readdirSync(packagesDir)) {
19
15
  const pkgPath = path.join(packagesDir, dir, "package.json");
20
- if (!fs.existsSync(pkgPath)) continue;
21
-
16
+ if (!fs.existsSync(pkgPath))
17
+ continue;
22
18
  const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
23
19
  const name = pkg.name?.split("/")[1]; // e.g., "mailx-store" from "@bobfrankston/mailx-store"
24
- if (!name) continue;
25
-
20
+ if (!name)
21
+ continue;
26
22
  const linkPath = path.join(nmDir, name);
27
23
  const targetPath = path.join(packagesDir, dir);
28
-
29
- if (fs.existsSync(linkPath)) continue; // already linked
30
-
24
+ if (fs.existsSync(linkPath))
25
+ continue; // already linked
31
26
  try {
32
27
  // Use junction on Windows (no admin needed), symlink on Unix
33
28
  if (process.platform === "win32") {
34
29
  fs.symlinkSync(targetPath, linkPath, "junction");
35
- } else {
30
+ }
31
+ else {
36
32
  fs.symlinkSync(targetPath, linkPath, "dir");
37
33
  }
38
- } catch (e) {
34
+ }
35
+ catch (e) {
39
36
  console.error(`Failed to link ${name}: ${e.message}`);
40
37
  }
41
38
  }
39
+ //# sourceMappingURL=postinstall.js.map
package/client/app.js CHANGED
@@ -844,7 +844,37 @@ fetch("/api/version").then(r => r.json()).then(d => {
844
844
  : "";
845
845
  if (el)
846
846
  el.textContent = `mailx v${d.version}${storageLabel}${isApp ? "" : " [browser]"}`;
847
- if (storage.cloudError) {
847
+ if (d.settingsError) {
848
+ showAlert(d.settingsError, "settings-error");
849
+ // Add repair button to the banner
850
+ const banner = document.getElementById("alert-banner");
851
+ if (banner && !banner.querySelector(".repair-btn")) {
852
+ const btn = document.createElement("button");
853
+ btn.className = "repair-btn status-action";
854
+ btn.textContent = "Repair: restore accounts from cache";
855
+ btn.style.cssText = "margin-left:1rem;padding:0.25rem 0.75rem;background:#a6e3a1;color:#1e1e2e;border:none;border-radius:4px;cursor:pointer;font-weight:bold";
856
+ btn.onclick = async () => {
857
+ btn.textContent = "Restoring...";
858
+ btn.disabled = true;
859
+ try {
860
+ const r = await fetch("/api/repair-accounts", { method: "POST" });
861
+ const data = await r.json();
862
+ if (data.ok) {
863
+ hideAlert();
864
+ setTimeout(() => location.reload(), 1000);
865
+ }
866
+ else {
867
+ btn.textContent = `Failed: ${data.error}`;
868
+ }
869
+ }
870
+ catch (e) {
871
+ btn.textContent = `Error: ${e.message}`;
872
+ }
873
+ };
874
+ banner.querySelector("#alert-text")?.after(btn);
875
+ }
876
+ }
877
+ else if (storage.cloudError) {
848
878
  showAlert(`Cloud storage error: ${storage.cloudError}`, "cloud-error");
849
879
  }
850
880
  }).catch(async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.117",
3
+ "version": "1.0.119",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -158,6 +158,48 @@ export function createApiRouter(db, imapManager) {
158
158
  res.status(500).json({ error: e.message });
159
159
  }
160
160
  });
161
+ // ── Repair: restore accounts from DB cache to settings, re-register IMAP ──
162
+ router.post("/repair-accounts", async (req, res) => {
163
+ try {
164
+ // Get accounts from DB (stale but present)
165
+ const dbAccounts = db.getAccountConfigs();
166
+ if (dbAccounts.length === 0) {
167
+ res.json({ ok: false, error: "No cached accounts in database" });
168
+ return;
169
+ }
170
+ // Rebuild account configs from DB's stored config_json
171
+ const restored = [];
172
+ for (const a of dbAccounts) {
173
+ try {
174
+ const cfg = JSON.parse(a.configJson);
175
+ restored.push(cfg);
176
+ }
177
+ catch { /* skip corrupt entries */ }
178
+ }
179
+ if (restored.length === 0) {
180
+ res.json({ ok: false, error: "Could not parse cached account configs" });
181
+ return;
182
+ }
183
+ // Save back to shared dir (and cloud API if active)
184
+ saveAccounts(restored);
185
+ // Re-register in IMAP manager
186
+ for (const acct of restored) {
187
+ try {
188
+ await imapManager.addAccount(acct);
189
+ console.log(` [repair] Re-registered account: ${acct.name} (${acct.id})`);
190
+ }
191
+ catch (e) {
192
+ console.error(` [repair] Failed to register ${acct.id}: ${e.message}`);
193
+ }
194
+ }
195
+ // Start sync
196
+ imapManager.syncAll().catch(() => { });
197
+ res.json({ ok: true, message: `Restored ${restored.length} account(s) and started sync.` });
198
+ }
199
+ catch (e) {
200
+ res.status(500).json({ error: e.message });
201
+ }
202
+ });
161
203
  // ── Send ──
162
204
  router.post("/send", async (req, res) => {
163
205
  try {
@@ -56,6 +56,8 @@ export declare class ImapManager extends EventEmitter {
56
56
  private createClient;
57
57
  /** Track client logout for connection counting */
58
58
  private trackLogout;
59
+ /** Number of registered IMAP accounts */
60
+ getAccountCount(): number;
59
61
  /** Register an account */
60
62
  addAccount(account: AccountConfig): Promise<void>;
61
63
  /** Sync folder list for an account */
@@ -214,6 +214,8 @@ export class ImapManager extends EventEmitter {
214
214
  this.activeConnections.set(accountId, count);
215
215
  console.log(` [conn] ${accountId}: -1 (${count} active)`);
216
216
  }
217
+ /** Number of registered IMAP accounts */
218
+ getAccountCount() { return this.configs.size; }
217
219
  /** Register an account */
218
220
  async addAccount(account) {
219
221
  if (this.configs.has(account.id))
@@ -94,7 +94,13 @@ const apiRouter = createApiRouter(db, imapManager);
94
94
  app.use("/api", apiRouter);
95
95
  app.get("/api/version", (req, res) => {
96
96
  const storage = getStorageInfo();
97
- res.json({ version: SERVER_VERSION, theme: settings.ui?.theme || "system", storage });
97
+ const imapAccounts = imapManager.getAccountCount();
98
+ const dbAccounts = db.getAccounts().length;
99
+ // Warn if DB has accounts but IMAP has none — stale DB, settings missing
100
+ const settingsError = (dbAccounts > 0 && imapAccounts === 0)
101
+ ? "No accounts loaded from settings — showing stale data. Check accounts.jsonc on your cloud drive."
102
+ : undefined;
103
+ res.json({ version: SERVER_VERSION, theme: settings.ui?.theme || "system", storage, imapAccounts, settingsError });
98
104
  });
99
105
  app.all("/info", (req, res) => {
100
106
  res.json({ version: SERVER_VERSION, uptime: Math.round(process.uptime()), port: PORT, imap: imapManager.useNativeClient ? "native" : "imapflow" });
@@ -15,6 +15,12 @@ export declare class MailxDB {
15
15
  email: string;
16
16
  lastSync: number;
17
17
  }[];
18
+ getAccountConfigs(): {
19
+ id: string;
20
+ name: string;
21
+ email: string;
22
+ configJson: string;
23
+ }[];
18
24
  updateLastSync(accountId: string, timestamp: number): void;
19
25
  upsertFolder(accountId: string, folderPath: string, name: string, specialUse: string, delimiter: string): number;
20
26
  getFolders(accountId: string): Folder[];
@@ -137,6 +137,9 @@ export class MailxDB {
137
137
  getAccounts() {
138
138
  return this.db.prepare("SELECT id, name, email, last_sync as lastSync FROM accounts").all();
139
139
  }
140
+ getAccountConfigs() {
141
+ return this.db.prepare("SELECT id, name, email, config_json as configJson FROM accounts").all();
142
+ }
140
143
  updateLastSync(accountId, timestamp) {
141
144
  this.db.prepare("UPDATE accounts SET last_sync = ? WHERE id = ?").run(timestamp, accountId);
142
145
  }