@bobfrankston/mailx 1.0.47 → 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/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
@@ -48,6 +48,11 @@
48
48
  </div>
49
49
  </header>
50
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
+
51
56
  <div class="folder-panel">
52
57
  <div class="ft-filter">
53
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 {
@@ -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.47",
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.28",
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",
@@ -1018,15 +1018,18 @@ export class ImapManager extends EventEmitter {
1018
1018
  const account = settings.accounts.find(a => a.id === accountId);
1019
1019
  if (!account || account.imap.auth !== "oauth2")
1020
1020
  return null;
1021
- // Find credentials.json same as iflow uses
1022
- const iflowDir = path.resolve(import.meta.dirname, "..", "..", "..", "MailApps", "iflow");
1023
- const credentialsPath = path.join(iflowDir, "credentials.json");
1024
- if (!fs.existsSync(credentialsPath)) {
1025
- 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");
1026
1029
  return null;
1027
1030
  }
1028
1031
  const accountDir = account.imap.user.replace(/[@.]/g, "_");
1029
- const tokenDir = path.join(iflowDir, "tokens", accountDir);
1032
+ const tokenDir = path.join(getConfigDir(), "tokens", accountDir);
1030
1033
  const token = await authenticateOAuth(credentialsPath, {
1031
1034
  scope: "https://www.googleapis.com/auth/contacts.readonly",
1032
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, getStorePath, 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();
@@ -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>
@@ -199,6 +196,9 @@ imapManager.on("syncProgress", (accountId, phase, progress) => {
199
196
  imapManager.on("folderCountsChanged", (accountId, counts) => {
200
197
  broadcast({ type: "folderCountsChanged", accountId, counts });
201
198
  });
199
+ imapManager.on("syncError", (accountId, error) => {
200
+ broadcast({ type: "error", message: `${accountId}: ${error}` });
201
+ });
202
202
  // ── Startup ──
203
203
  async function start() {
204
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";
@@ -155,6 +155,32 @@ export async function cloudWrite(filename, content) {
155
155
  export function isCloudMode() {
156
156
  return pendingCloudConfig !== null;
157
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
+ }
158
184
  // ── File helpers ──
159
185
  /** Read JSON or JSONC file. If exact path not found, tries .json/.jsonc variant. */
160
186
  function readJsonc(filePath) {