@bobfrankston/mailx 1.0.46 → 1.0.49

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
@@ -28,14 +28,15 @@ const verbose = hasFlag("verbose");
28
28
  const setupMode = hasFlag("setup");
29
29
  const addMode = hasFlag("add");
30
30
  const testMode = hasFlag("test");
31
+ const rebuildMode = hasFlag("rebuild");
31
32
 
32
33
  // Validate arguments
33
- const knownFlags = ["server", "no-browser", "verbose", "external", "kill", "v", "version", "setup", "add", "test"];
34
+ const knownFlags = ["server", "no-browser", "verbose", "external", "kill", "v", "version", "setup", "add", "test", "rebuild"];
34
35
  for (const arg of args) {
35
36
  const flag = arg.replace(/^--?/, "");
36
37
  if (arg.startsWith("-") && !knownFlags.includes(flag)) {
37
38
  console.error(`Unknown option: ${arg}`);
38
- console.error("Usage: mailx [-server] [-verbose] [-kill] [-v] [-setup] [-no-browser] [-external]");
39
+ console.error("Usage: mailx [-server] [-verbose] [-kill] [-rebuild] [-v] [-setup] [-no-browser] [-external]");
39
40
  process.exit(1);
40
41
  }
41
42
  }
@@ -86,6 +87,31 @@ if (hasFlag("kill")) {
86
87
  process.exit(0);
87
88
  }
88
89
 
90
+ // Rebuild: wipe DB + message store, keep accounts/settings
91
+ if (rebuildMode) {
92
+ const { getConfigDir, getStorePath } = await import("@bobfrankston/mailx-settings");
93
+ const dbDir = getConfigDir();
94
+ const storePath = getStorePath();
95
+
96
+ console.log("Rebuilding mailx local cache...");
97
+ console.log(" Accounts and settings will be preserved.");
98
+
99
+ // Remove DB files
100
+ for (const f of ["mailx.db", "mailx.db-wal", "mailx.db-shm"]) {
101
+ const p = path.join(dbDir, f);
102
+ if (fs.existsSync(p)) { fs.unlinkSync(p); console.log(` Deleted ${f}`); }
103
+ }
104
+
105
+ // Remove message store
106
+ if (fs.existsSync(storePath)) {
107
+ fs.rmSync(storePath, { recursive: true });
108
+ console.log(` Deleted message store`);
109
+ }
110
+
111
+ console.log(" Rebuild complete. Run 'mailx' to start fresh.");
112
+ process.exit(0);
113
+ }
114
+
89
115
  // Version
90
116
  if (hasFlag("v") || hasFlag("version")) {
91
117
  const root = path.join(import.meta.dirname, "..");
package/client/app.js CHANGED
@@ -5,7 +5,7 @@
5
5
  import { initFolderTree, refreshFolderTree } from "./components/folder-tree.js";
6
6
  import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder } from "./components/message-list.js";
7
7
  import { showMessage, getCurrentMessage } from "./components/message-viewer.js";
8
- import { connectWebSocket, onWsEvent, triggerSync, getAccounts, getFolders, deleteMessage, undeleteMessage, restartServer, getSyncPending } from "./lib/api-client.js";
8
+ import { connectWebSocket, onWsEvent, triggerSync, getAccounts, getFolders, deleteMessage, undeleteMessage, restartServer, rebuildServer, getSyncPending } from "./lib/api-client.js";
9
9
  // ── New message badge (favicon + title) ──
10
10
  let baseTitle = "mailx";
11
11
  let lastSeenCount = 0;
@@ -90,6 +90,29 @@ function setTitle(title) {
90
90
  baseTitle = title;
91
91
  document.title = badgeCount > 0 ? `(${badgeCount}) ${baseTitle}` : baseTitle;
92
92
  }
93
+ // ── Alert banner ──
94
+ const alertBanner = document.getElementById("alert-banner");
95
+ const alertText = document.getElementById("alert-text");
96
+ const alertDismiss = document.getElementById("alert-dismiss");
97
+ const dismissedAlerts = new Set();
98
+ function showAlert(message, key) {
99
+ if (key && dismissedAlerts.has(key))
100
+ return;
101
+ if (alertBanner && alertText) {
102
+ alertText.textContent = message;
103
+ alertBanner.hidden = false;
104
+ alertBanner.dataset.key = key || "";
105
+ }
106
+ }
107
+ function hideAlert() {
108
+ if (alertBanner) {
109
+ const key = alertBanner.dataset.key;
110
+ if (key)
111
+ dismissedAlerts.add(key);
112
+ alertBanner.hidden = true;
113
+ }
114
+ }
115
+ alertDismiss?.addEventListener("click", hideAlert);
93
116
  // ── Wire up components ──
94
117
  const folderTree = document.getElementById("folder-tree");
95
118
  let currentFolderSpecialUse = "";
@@ -175,7 +198,21 @@ document.getElementById("btn-sync")?.addEventListener("click", async () => {
175
198
  btn.classList.remove("syncing");
176
199
  }
177
200
  });
178
- document.getElementById("btn-restart")?.addEventListener("click", async () => {
201
+ // Restart menu dropdown
202
+ const restartBtn = document.getElementById("btn-restart");
203
+ const restartDropdown = document.getElementById("restart-dropdown");
204
+ restartBtn?.addEventListener("click", () => {
205
+ if (restartDropdown)
206
+ restartDropdown.hidden = !restartDropdown.hidden;
207
+ });
208
+ document.addEventListener("click", (e) => {
209
+ if (restartDropdown && !restartDropdown.hidden && !e.target.closest("#restart-menu")) {
210
+ restartDropdown.hidden = true;
211
+ }
212
+ });
213
+ document.getElementById("btn-restart-quick")?.addEventListener("click", async () => {
214
+ if (restartDropdown)
215
+ restartDropdown.hidden = true;
179
216
  const statusSync = document.getElementById("status-sync");
180
217
  if (statusSync)
181
218
  statusSync.textContent = "Restarting...";
@@ -183,7 +220,19 @@ document.getElementById("btn-restart")?.addEventListener("click", async () => {
183
220
  await restartServer();
184
221
  }
185
222
  catch { /* server is shutting down */ }
186
- // Server broadcasts reload event; if missed, WebSocket reconnect will trigger page reload
223
+ });
224
+ document.getElementById("btn-rebuild")?.addEventListener("click", async () => {
225
+ if (restartDropdown)
226
+ restartDropdown.hidden = true;
227
+ if (!confirm("Rebuild local cache?\n\nThis wipes the local database and message store, then re-downloads everything.\nAccounts and settings are preserved.\n\nThis is safe and usually takes just a few minutes."))
228
+ return;
229
+ const statusSync = document.getElementById("status-sync");
230
+ if (statusSync)
231
+ statusSync.textContent = "Rebuilding...";
232
+ try {
233
+ await rebuildServer();
234
+ }
235
+ catch { /* server is shutting down */ }
187
236
  });
188
237
  async function openCompose(mode) {
189
238
  const current = getCurrentMessage();
@@ -496,6 +545,7 @@ onWsEvent((event) => {
496
545
  case "error":
497
546
  if (statusSync)
498
547
  statusSync.textContent = `Error: ${event.message}`;
548
+ showAlert(event.message, "ws-error");
499
549
  break;
500
550
  }
501
551
  });
@@ -539,6 +589,7 @@ const viewBtn = document.getElementById("btn-view");
539
589
  const viewDropdown = document.getElementById("view-dropdown");
540
590
  const optTwoLine = document.getElementById("opt-two-line");
541
591
  const optPreview = document.getElementById("opt-preview");
592
+ const optSnippet = document.getElementById("opt-snippet");
542
593
  const optFlagged = document.getElementById("opt-flagged");
543
594
  // Toggle dropdown
544
595
  viewBtn?.addEventListener("click", (e) => {
@@ -553,17 +604,22 @@ document.addEventListener("click", () => {
553
604
  // Restore saved view settings
554
605
  const savedTwoLine = localStorage.getItem("mailx-two-line") === "true";
555
606
  const savedPreview = localStorage.getItem("mailx-preview") !== "false"; // default true
607
+ const savedSnippet = localStorage.getItem("mailx-snippet") !== "false"; // default true
556
608
  const savedFlagged = localStorage.getItem("mailx-flagged") === "true";
557
609
  if (optTwoLine)
558
610
  optTwoLine.checked = savedTwoLine;
559
611
  if (optPreview)
560
612
  optPreview.checked = savedPreview;
613
+ if (optSnippet)
614
+ optSnippet.checked = savedSnippet;
561
615
  if (optFlagged)
562
616
  optFlagged.checked = savedFlagged;
563
617
  if (savedTwoLine)
564
618
  document.getElementById("message-list")?.classList.add("two-line");
565
619
  if (!savedPreview)
566
620
  document.querySelector(".main-area")?.classList.add("no-preview");
621
+ if (!savedSnippet)
622
+ document.getElementById("message-list")?.classList.add("no-snippets");
567
623
  if (savedFlagged)
568
624
  document.getElementById("ml-body")?.classList.add("flagged-only");
569
625
  // Two-line toggle
@@ -588,6 +644,17 @@ optPreview?.addEventListener("change", () => {
588
644
  }
589
645
  localStorage.setItem("mailx-preview", String(optPreview.checked));
590
646
  });
647
+ // Preview snippet toggle
648
+ optSnippet?.addEventListener("change", () => {
649
+ const list = document.getElementById("message-list");
650
+ if (optSnippet.checked) {
651
+ list?.classList.remove("no-snippets");
652
+ }
653
+ else {
654
+ list?.classList.add("no-snippets");
655
+ }
656
+ localStorage.setItem("mailx-snippet", String(optSnippet.checked));
657
+ });
591
658
  // Flagged-only filter
592
659
  optFlagged?.addEventListener("change", () => {
593
660
  const body = document.getElementById("ml-body");
@@ -602,9 +669,10 @@ optFlagged?.addEventListener("change", () => {
602
669
  const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
603
670
  fetch("/api/version").then(r => r.json()).then(d => {
604
671
  const el = document.getElementById("app-version");
605
- const driveName = d.drive === "local" ? "" : ` [${d.drive?.split(/[/\\]/).pop() || d.drive}]`;
672
+ const storage = d.storage || { provider: "local", mode: "local" };
673
+ const storageLabel = storage.provider === "local" ? "" : ` · ${storage.provider}${storage.mode === "api" ? " (API)" : ""}`;
606
674
  if (el)
607
- el.textContent = `mailx s${d.server}/c${d.client}${driveName}${isApp ? "" : " [browser]"}`;
675
+ el.textContent = `mailx v${d.version}${storageLabel}${isApp ? "" : " · browser"}`;
608
676
  }).catch(async () => {
609
677
  // Server not running — try to start it if we're in the app
610
678
  const startupStatus = document.getElementById("startup-status");
package/client/index.html CHANGED
@@ -24,6 +24,7 @@
24
24
  <div class="tb-menu-dropdown" id="view-dropdown" hidden>
25
25
  <label class="tb-menu-item"><input type="checkbox" id="opt-two-line"> Two-line view</label>
26
26
  <label class="tb-menu-item"><input type="checkbox" id="opt-preview" checked> Preview pane</label>
27
+ <label class="tb-menu-item"><input type="checkbox" id="opt-snippet" checked> Preview snippets</label>
27
28
  <label class="tb-menu-item"><input type="checkbox" id="opt-flagged"> ★ Flagged only</label>
28
29
  </div>
29
30
  </div>
@@ -33,12 +34,25 @@
33
34
  <button class="tb-btn" id="btn-sync" title="Sync all folders (F5)">
34
35
  <span class="tb-icon">↻</span> Sync
35
36
  </button>
36
- <button class="tb-btn" id="btn-restart" title="Restart server and reload page">
37
- <span class="tb-icon">⚡</span> Restart
38
- </button>
37
+ <div class="tb-menu" id="restart-menu">
38
+ <button class="tb-btn" id="btn-restart" title="Restart server and reload page">
39
+ <span class="tb-icon">⚡</span> Restart ▾
40
+ </button>
41
+ <div class="tb-menu-dropdown" id="restart-dropdown" hidden>
42
+ <button class="tb-menu-item" id="btn-restart-quick" title="Restart the server process">Restart server</button>
43
+ <button class="tb-menu-item" id="btn-rebuild" title="Wipe local DB and message cache, re-download everything. Accounts and settings are preserved. Safe and fast.">Rebuild local cache</button>
44
+ <hr class="tb-menu-sep">
45
+ <span class="tb-menu-hint">CLI: mailx --rebuild for full reset</span>
46
+ </div>
47
+ </div>
39
48
  </div>
40
49
  </header>
41
50
 
51
+ <div class="alert-banner" id="alert-banner" hidden>
52
+ <span id="alert-text"></span>
53
+ <button class="alert-dismiss" id="alert-dismiss" title="Dismiss">&times;</button>
54
+ </div>
55
+
42
56
  <div class="folder-panel">
43
57
  <div class="ft-filter">
44
58
  <input type="text" id="ft-filter-input" placeholder="Find folder..." autocomplete="off">
@@ -154,6 +154,9 @@ export function restartServer() {
154
154
  return mailxapi.restart?.();
155
155
  return api("/restart", { method: "POST" }).catch(() => { });
156
156
  }
157
+ export function rebuildServer() {
158
+ return api("/rebuild", { method: "POST" }).catch(() => { });
159
+ }
157
160
  // ── Folder management ──
158
161
  export function markFolderRead(accountId, folderId) {
159
162
  if (hasIPC)
@@ -1,5 +1,18 @@
1
1
  /* mailx component styles */
2
2
 
3
+ /* ── Alert Banner ── */
4
+ .alert-banner {
5
+ display: flex; align-items: center; gap: var(--gap-sm);
6
+ padding: var(--gap-xs) var(--gap-md);
7
+ background: oklch(0.45 0.15 25); color: #fff;
8
+ font-size: var(--font-size-sm); font-weight: 500;
9
+ grid-area: alert;
10
+ }
11
+ .alert-banner[hidden] { display: none; }
12
+ .alert-banner #alert-text { flex: 1; }
13
+ .alert-dismiss { background: none; border: none; color: #fff; font-size: 1.2em; cursor: pointer; padding: 0 var(--gap-xs); opacity: 0.7; }
14
+ .alert-dismiss:hover { opacity: 1; }
15
+
3
16
  /* ── Context Menu ── */
4
17
 
5
18
  .ctx-menu {
@@ -65,7 +78,7 @@
65
78
  .tb-icon { font-size: 1.1em; }
66
79
  .tb-btn.syncing .tb-icon { animation: spin 1s linear infinite; }
67
80
  @keyframes spin { to { transform: rotate(360deg); } }
68
- .app-version { font-size: var(--font-size-sm); color: var(--color-text-muted); }
81
+ .app-version { font-size: var(--font-size-sm); color: var(--color-text); opacity: 0.7; }
69
82
 
70
83
  .tb-menu { position: relative; display: inline-block; }
71
84
  .tb-menu-dropdown {
@@ -91,6 +104,9 @@
91
104
  }
92
105
  .tb-menu-item:hover { background: var(--color-bg-hover); }
93
106
  .tb-menu-item input[type="checkbox"] { accent-color: var(--color-accent); }
107
+ button.tb-menu-item { background: none; border: none; color: inherit; width: 100%; text-align: left; }
108
+ .tb-menu-sep { border: none; border-top: 1px solid var(--color-border); margin: var(--gap-xs) 0; }
109
+ .tb-menu-hint { display: block; padding: var(--gap-xs) var(--gap-md); font-size: 0.75rem; color: var(--color-text-muted); }
94
110
  .tb-sep { width: 1px; height: 1.2rem; background: var(--color-border); margin: 0 var(--gap-xs); }
95
111
 
96
112
  .search-bar {
@@ -338,6 +354,7 @@
338
354
  margin-left: var(--gap-xs);
339
355
  }
340
356
  }
357
+ .no-snippets .ml-preview { display: none; }
341
358
  .ml-date { white-space: nowrap; text-align: right; color: var(--color-text-muted); font-family: var(--font-mono); font-size: var(--font-size-sm); }
342
359
 
343
360
  .ml-empty {
@@ -9,9 +9,10 @@
9
9
  body {
10
10
  display: grid;
11
11
  grid-template-columns: var(--folder-width) 1fr;
12
- grid-template-rows: var(--toolbar-height) 1fr var(--statusbar-height);
12
+ grid-template-rows: var(--toolbar-height) auto 1fr var(--statusbar-height);
13
13
  grid-template-areas:
14
14
  "toolbar toolbar"
15
+ "alert alert"
15
16
  "folders main"
16
17
  "status status";
17
18
  height: 100vh;
@@ -59,8 +60,10 @@ body {
59
60
  @media (max-width: 768px) {
60
61
  body {
61
62
  grid-template-columns: 1fr;
63
+ grid-template-rows: var(--toolbar-height) auto 1fr var(--statusbar-height);
62
64
  grid-template-areas:
63
65
  "toolbar"
66
+ "alert"
64
67
  "main"
65
68
  "status";
66
69
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.46",
3
+ "version": "1.0.49",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -20,9 +20,9 @@
20
20
  "postinstall": "node launcher/builder/postinstall.js"
21
21
  },
22
22
  "dependencies": {
23
- "@bobfrankston/iflow": "^1.0.26",
23
+ "@bobfrankston/iflow": "^1.0.29",
24
24
  "@bobfrankston/miscinfo": "^1.0.6",
25
- "@bobfrankston/oauthsupport": "^1.0.11",
25
+ "@bobfrankston/oauthsupport": "^1.0.12",
26
26
  "@bobfrankston/rust-builder": "^0.1.2",
27
27
  "mailparser": "^3.7.2",
28
28
  "quill": "^2.0.3",
@@ -6,7 +6,7 @@
6
6
  import { ImapClient, createAutoImapConfig } from "@bobfrankston/iflow";
7
7
  import { authenticateOAuth } from "@bobfrankston/oauthsupport";
8
8
  import { FileMessageStore } from "@bobfrankston/mailx-store";
9
- import { loadSettings, getStorePath } from "@bobfrankston/mailx-settings";
9
+ import { loadSettings, getStorePath, getConfigDir } from "@bobfrankston/mailx-settings";
10
10
  import { EventEmitter } from "node:events";
11
11
  import * as fs from "node:fs";
12
12
  import * as path from "node:path";
@@ -26,13 +26,23 @@ function toEmailAddresses(addrs) {
26
26
  return [];
27
27
  return addrs.map(toEmailAddress);
28
28
  }
29
+ /** Decode HTML entities (&#8199; &amp; etc.) to plain characters */
30
+ function decodeEntities(text) {
31
+ return text
32
+ .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
33
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
34
+ .replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
35
+ .replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&nbsp;/g, " ");
36
+ }
29
37
  /** Extract a plain-text preview from message source */
30
38
  async function extractPreview(source) {
31
39
  try {
32
40
  const parsed = await simpleParser(source);
33
41
  const bodyText = parsed.text || "";
34
42
  const bodyHtml = parsed.html || "";
35
- const preview = bodyText.replace(/\s+/g, " ").trim().slice(0, 200);
43
+ // Use text part; fall back to stripping HTML tags if text is empty
44
+ let raw = bodyText || bodyHtml.replace(/<[^>]+>/g, " ");
45
+ const preview = decodeEntities(raw).replace(/\s+/g, " ").trim().slice(0, 200);
36
46
  const hasAttachments = (parsed.attachments?.length || 0) > 0;
37
47
  return { bodyHtml, bodyText, preview, hasAttachments };
38
48
  }
@@ -105,11 +115,13 @@ export class ImapManager extends EventEmitter {
105
115
  if (this.configs.has(account.id))
106
116
  return;
107
117
  // createAutoImapConfig auto-detects Gmail from server/username and sets up OAuth
118
+ // Token directory in ~/.mailx/ so tokens persist across npm reinstalls
108
119
  const config = createAutoImapConfig({
109
120
  server: account.imap.host,
110
121
  port: account.imap.port,
111
122
  username: account.imap.user,
112
- password: account.imap.password
123
+ password: account.imap.password,
124
+ tokenDirectory: getConfigDir()
113
125
  });
114
126
  this.configs.set(account.id, config);
115
127
  // Register account in DB
@@ -1006,15 +1018,18 @@ export class ImapManager extends EventEmitter {
1006
1018
  const account = settings.accounts.find(a => a.id === accountId);
1007
1019
  if (!account || account.imap.auth !== "oauth2")
1008
1020
  return null;
1009
- // Find credentials.json same as iflow uses
1010
- const iflowDir = path.resolve(import.meta.dirname, "..", "..", "..", "MailApps", "iflow");
1011
- const credentialsPath = path.join(iflowDir, "credentials.json");
1012
- if (!fs.existsSync(credentialsPath)) {
1013
- console.error(" [contacts] credentials.json not found at", credentialsPath);
1021
+ // Find iflow-credentials.json from the iflow package
1022
+ const credentialsCandidates = [
1023
+ path.resolve(import.meta.dirname, "..", "..", "node_modules", "@bobfrankston", "iflow", "iflow-credentials.json"),
1024
+ path.resolve(import.meta.dirname, "..", "..", "..", "node_modules", "@bobfrankston", "iflow", "iflow-credentials.json"),
1025
+ ];
1026
+ const credentialsPath = credentialsCandidates.find(p => fs.existsSync(p));
1027
+ if (!credentialsPath) {
1028
+ console.error(" [contacts] iflow-credentials.json not found");
1014
1029
  return null;
1015
1030
  }
1016
1031
  const accountDir = account.imap.user.replace(/[@.]/g, "_");
1017
- const tokenDir = path.join(iflowDir, "tokens", accountDir);
1032
+ const tokenDir = path.join(getConfigDir(), "tokens", accountDir);
1018
1033
  const token = await authenticateOAuth(credentialsPath, {
1019
1034
  scope: "https://www.googleapis.com/auth/contacts.readonly",
1020
1035
  tokenDirectory: tokenDir,
@@ -9,7 +9,7 @@ import * as fs from "node:fs";
9
9
  import { MailxDB } from "@bobfrankston/mailx-store";
10
10
  import { ImapManager } from "@bobfrankston/mailx-imap";
11
11
  import { createApiRouter } from "@bobfrankston/mailx-api";
12
- import { loadSettings, getConfigDir, getSharedDir, initLocalConfig } from "@bobfrankston/mailx-settings";
12
+ import { loadSettings, getConfigDir, getStorePath, getStorageInfo, initLocalConfig } from "@bobfrankston/mailx-settings";
13
13
  import { ports } from "@bobfrankston/miscinfo";
14
14
  import { createServer } from "node:http";
15
15
  const PORT = ports.mailx;
@@ -44,7 +44,6 @@ console.error = (...args) => {
44
44
  // Read version from root package.json (the published version)
45
45
  const rootPkg = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, "..", "..", "package.json"), "utf-8"));
46
46
  const SERVER_VERSION = rootPkg.version;
47
- const CLIENT_VERSION = rootPkg.version;
48
47
  // ── Initialize ──
49
48
  initLocalConfig();
50
49
  const settings = loadSettings();
@@ -67,7 +66,7 @@ app.use((req, res, next) => {
67
66
  res.on("finish", () => {
68
67
  const ms = Date.now() - start;
69
68
  // Skip noisy polling endpoints
70
- if (req.path === "/api/sync/pending")
69
+ if (req.path.endsWith("/sync/pending"))
71
70
  return;
72
71
  console.log(` ${req.method} ${req.path} ${res.statusCode} ${ms}ms`);
73
72
  });
@@ -82,10 +81,8 @@ app.use("/node_modules", express.static(path.join(rootDir, "node_modules"), { et
82
81
  const apiRouter = createApiRouter(db, imapManager);
83
82
  app.use("/api", apiRouter);
84
83
  app.get("/api/version", (req, res) => {
85
- const sharedDir = getSharedDir();
86
- const localDir = getConfigDir();
87
- const drive = sharedDir === localDir ? "local" : sharedDir;
88
- res.json({ server: SERVER_VERSION, client: CLIENT_VERSION, theme: settings.ui?.theme || "system", drive });
84
+ const storage = getStorageInfo();
85
+ res.json({ version: SERVER_VERSION, theme: settings.ui?.theme || "system", storage });
89
86
  });
90
87
  app.get("/status", (req, res) => {
91
88
  const accounts = db.getAccounts();
@@ -111,7 +108,7 @@ h1{font-size:1.2rem}h2{font-size:1rem;margin-top:1.5rem}.ok{color:#a6e3a1}.warn{
111
108
  a{color:#89b4fa}</style></head>
112
109
  <body>
113
110
  <h1>mailx status</h1>
114
- <p>Server: v${SERVER_VERSION} | Client: v${CLIENT_VERSION}</p>
111
+ <p>mailx v${SERVER_VERSION}</p>
115
112
  <p>Uptime: ${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m | Memory: ${Math.round(mem.rss / 1048576)} MB</p>
116
113
  <p>Pending sync: <span class="${pendingSync > 0 ? "warn" : "ok"}">${pendingSync}</span></p>
117
114
  <h2>Accounts</h2>
@@ -125,12 +122,43 @@ ${accountInfo.map((a) => `<tr><td>${a.name}</td><td>${a.folders}</td><td>${a.inb
125
122
  app.post("/api/restart", (req, res) => {
126
123
  res.json({ ok: true });
127
124
  broadcast({ type: "reload" });
128
- // Graceful shutdown — node --watch will auto-restart
129
125
  setTimeout(async () => {
130
126
  console.log(" Restart requested via API");
131
127
  await shutdown();
132
128
  }, 500);
133
129
  });
130
+ // Rebuild: wipe DB + message store, keep accounts/settings, restart
131
+ app.post("/api/rebuild", (req, res) => {
132
+ res.json({ ok: true });
133
+ broadcast({ type: "reload" });
134
+ setTimeout(async () => {
135
+ console.log(" Rebuild requested — wiping DB and message store...");
136
+ imapManager.stopPeriodicSync();
137
+ try {
138
+ await imapManager.shutdown();
139
+ }
140
+ catch { /* proceed */ }
141
+ db.close();
142
+ // Remove DB files
143
+ const dbDir = getConfigDir();
144
+ for (const f of ["mailx.db", "mailx.db-wal", "mailx.db-shm"]) {
145
+ const p = path.join(dbDir, f);
146
+ if (fs.existsSync(p)) {
147
+ fs.unlinkSync(p);
148
+ console.log(` Deleted ${f}`);
149
+ }
150
+ }
151
+ // Remove message store
152
+ const storePath = getStorePath();
153
+ if (fs.existsSync(storePath)) {
154
+ fs.rmSync(storePath, { recursive: true });
155
+ console.log(` Deleted ${storePath}`);
156
+ }
157
+ console.log(" Rebuild complete — restarting...");
158
+ server?.close();
159
+ process.exit(0);
160
+ }, 500);
161
+ });
134
162
  // SPA fallback
135
163
  app.get("*", (req, res) => {
136
164
  if (!req.path.startsWith("/api"))
@@ -168,6 +196,9 @@ imapManager.on("syncProgress", (accountId, phase, progress) => {
168
196
  imapManager.on("folderCountsChanged", (accountId, counts) => {
169
197
  broadcast({ type: "folderCountsChanged", accountId, counts });
170
198
  });
199
+ imapManager.on("syncError", (accountId, error) => {
200
+ broadcast({ type: "error", message: `${accountId}: ${error}` });
201
+ });
171
202
  // ── Startup ──
172
203
  async function start() {
173
204
  console.log("mailx server starting...");
@@ -24,6 +24,11 @@ export declare function cloudRead(filename: string): Promise<string | null>;
24
24
  export declare function cloudWrite(filename: string, content: string): Promise<boolean>;
25
25
  /** Whether cloud API fallback is active */
26
26
  export declare function isCloudMode(): boolean;
27
+ /** Get storage provider info for display (e.g. "OneDrive", "Google Drive", "local") */
28
+ export declare function getStorageInfo(): {
29
+ provider: string;
30
+ mode: "mount" | "api" | "local";
31
+ };
27
32
  declare const DEFAULT_PREFERENCES: {
28
33
  ui: {
29
34
  theme: "system" | "dark" | "light";
@@ -107,11 +107,13 @@ function getSharedDir() {
107
107
  if (resolved)
108
108
  return resolved;
109
109
  }
110
- // Nothing mounted — save last provider entry for API fallback
111
- const lastProvider = [...entries].reverse().find(e => typeof e !== "string");
112
- if (lastProvider) {
113
- pendingCloudConfig = lastProvider;
114
- console.log(` No cloud drive mounted — will try ${lastProvider.provider} API`);
110
+ // Nothing mounted — save last provider entry for API fallback (log once)
111
+ if (!pendingCloudConfig) {
112
+ const lastProvider = [...entries].reverse().find(e => typeof e !== "string");
113
+ if (lastProvider) {
114
+ pendingCloudConfig = lastProvider;
115
+ console.log(` No cloud drive mounted — will try ${lastProvider.provider} API`);
116
+ }
115
117
  }
116
118
  }
117
119
  // Legacy: derive from settingsPath
@@ -153,6 +155,32 @@ export async function cloudWrite(filename, content) {
153
155
  export function isCloudMode() {
154
156
  return pendingCloudConfig !== null;
155
157
  }
158
+ /** Get storage provider info for display (e.g. "OneDrive", "Google Drive", "local") */
159
+ export function getStorageInfo() {
160
+ const config = readLocalConfig();
161
+ if (config.sharedDir) {
162
+ const entries = Array.isArray(config.sharedDir) ? config.sharedDir : [config.sharedDir];
163
+ for (const entry of entries) {
164
+ const resolved = resolveSharedEntry(entry);
165
+ if (resolved && resolved !== LOCAL_DIR) {
166
+ // Mounted cloud drive
167
+ const name = typeof entry === "string" ? "cloud" :
168
+ entry.provider === "onedrive" ? "OneDrive" :
169
+ entry.provider === "gdrive" ? "Google Drive" :
170
+ entry.provider === "dropbox" ? "Dropbox" : entry.provider;
171
+ return { provider: name, mode: "mount" };
172
+ }
173
+ }
174
+ // Not mounted but using API fallback
175
+ if (pendingCloudConfig) {
176
+ const name = pendingCloudConfig.provider === "onedrive" ? "OneDrive" :
177
+ pendingCloudConfig.provider === "gdrive" ? "Google Drive" :
178
+ pendingCloudConfig.provider === "dropbox" ? "Dropbox" : pendingCloudConfig.provider;
179
+ return { provider: name, mode: "api" };
180
+ }
181
+ }
182
+ return { provider: "local", mode: "local" };
183
+ }
156
184
  // ── File helpers ──
157
185
  /** Read JSON or JSONC file. If exact path not found, tries .json/.jsonc variant. */
158
186
  function readJsonc(filePath) {