@bobfrankston/mailx 1.0.118 → 1.0.120

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
@@ -541,12 +541,26 @@ onWsEvent((event) => {
541
541
  startupStatus.textContent = "Loading accounts...";
542
542
  // Don't refresh folder tree on connect — it's already loaded by initFolderTree
543
543
  break;
544
- case "syncProgress":
544
+ case "syncProgress": {
545
545
  if (statusSync)
546
- statusSync.textContent = `Syncing ${event.accountId}: ${event.phase} ${event.progress}%`;
546
+ statusSync.textContent = `Syncing ${event.accountId}: ${event.phase} ${event.progress || 0}%`;
547
547
  if (startupStatus)
548
548
  startupStatus.textContent = `Syncing ${event.accountId}: ${event.phase}`;
549
+ // Mark syncing folder in tree
550
+ const syncPath = event.phase?.startsWith("sync:") ? event.phase.slice(5) : null;
551
+ // Clear previous syncing markers for this account
552
+ document.querySelectorAll(`.ft-folder.ft-syncing[data-account-id="${event.accountId}"]`).forEach(el => el.classList.remove("ft-syncing"));
553
+ if (syncPath) {
554
+ const folderEl = document.querySelector(`.ft-folder[data-account-id="${event.accountId}"][data-folder-path="${CSS.escape(syncPath)}"]`);
555
+ if (folderEl) {
556
+ if (event.progress < 100)
557
+ folderEl.classList.add("ft-syncing");
558
+ else
559
+ folderEl.classList.remove("ft-syncing");
560
+ }
561
+ }
549
562
  break;
563
+ }
550
564
  case "folderCountsChanged": {
551
565
  refreshFolderTree();
552
566
  updateNewMessageCount();
@@ -703,6 +717,7 @@ const optTwoLine = document.getElementById("opt-two-line");
703
717
  const optPreview = document.getElementById("opt-preview");
704
718
  const optSnippet = document.getElementById("opt-snippet");
705
719
  const optFlagged = document.getElementById("opt-flagged");
720
+ const optFolderCounts = document.getElementById("opt-folder-counts");
706
721
  // Toggle dropdown
707
722
  viewBtn?.addEventListener("click", (e) => {
708
723
  e.stopPropagation();
@@ -720,6 +735,7 @@ const savedTwoLine = localStorage.getItem("mailx-two-line") === "true";
720
735
  const savedPreview = localStorage.getItem("mailx-preview") !== "false"; // default true
721
736
  const savedSnippet = localStorage.getItem("mailx-snippet") !== "false"; // default true
722
737
  const savedFlagged = localStorage.getItem("mailx-flagged") === "true";
738
+ const savedFolderCounts = localStorage.getItem("mailx-folder-counts") === "true";
723
739
  if (optTwoLine)
724
740
  optTwoLine.checked = savedTwoLine;
725
741
  if (optPreview)
@@ -728,6 +744,8 @@ if (optSnippet)
728
744
  optSnippet.checked = savedSnippet;
729
745
  if (optFlagged)
730
746
  optFlagged.checked = savedFlagged;
747
+ if (optFolderCounts)
748
+ optFolderCounts.checked = savedFolderCounts;
731
749
  if (savedTwoLine)
732
750
  document.getElementById("message-list")?.classList.add("two-line");
733
751
  if (!savedPreview)
@@ -736,6 +754,8 @@ if (!savedSnippet)
736
754
  document.getElementById("message-list")?.classList.add("no-snippets");
737
755
  if (savedFlagged)
738
756
  document.getElementById("ml-body")?.classList.add("flagged-only");
757
+ if (savedFolderCounts)
758
+ document.getElementById("folder-tree")?.classList.add("show-folder-counts");
739
759
  // Two-line toggle
740
760
  optTwoLine?.addEventListener("change", () => {
741
761
  const list = document.getElementById("message-list");
@@ -780,6 +800,17 @@ optFlagged?.addEventListener("change", () => {
780
800
  }
781
801
  localStorage.setItem("mailx-flagged", String(optFlagged.checked));
782
802
  });
803
+ // Folder counts toggle
804
+ optFolderCounts?.addEventListener("change", () => {
805
+ const tree = document.getElementById("folder-tree");
806
+ if (optFolderCounts.checked) {
807
+ tree?.classList.add("show-folder-counts");
808
+ }
809
+ else {
810
+ tree?.classList.remove("show-folder-counts");
811
+ }
812
+ localStorage.setItem("mailx-folder-counts", String(optFolderCounts.checked));
813
+ });
783
814
  // ── Settings menu ──
784
815
  const settingsBtn = document.getElementById("btn-settings");
785
816
  const settingsDropdown = document.getElementById("settings-dropdown");
@@ -844,7 +875,37 @@ fetch("/api/version").then(r => r.json()).then(d => {
844
875
  : "";
845
876
  if (el)
846
877
  el.textContent = `mailx v${d.version}${storageLabel}${isApp ? "" : " [browser]"}`;
847
- if (storage.cloudError) {
878
+ if (d.settingsError) {
879
+ showAlert(d.settingsError, "settings-error");
880
+ // Add repair button to the banner
881
+ const banner = document.getElementById("alert-banner");
882
+ if (banner && !banner.querySelector(".repair-btn")) {
883
+ const btn = document.createElement("button");
884
+ btn.className = "repair-btn status-action";
885
+ btn.textContent = "Repair: restore accounts from cache";
886
+ 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";
887
+ btn.onclick = async () => {
888
+ btn.textContent = "Restoring...";
889
+ btn.disabled = true;
890
+ try {
891
+ const r = await fetch("/api/repair-accounts", { method: "POST" });
892
+ const data = await r.json();
893
+ if (data.ok) {
894
+ hideAlert();
895
+ setTimeout(() => location.reload(), 1000);
896
+ }
897
+ else {
898
+ btn.textContent = `Failed: ${data.error}`;
899
+ }
900
+ }
901
+ catch (e) {
902
+ btn.textContent = `Error: ${e.message}`;
903
+ }
904
+ };
905
+ banner.querySelector("#alert-text")?.after(btn);
906
+ }
907
+ }
908
+ else if (storage.cloudError) {
848
909
  showAlert(`Cloud storage error: ${storage.cloudError}`, "cloud-error");
849
910
  }
850
911
  }).catch(async () => {
@@ -100,6 +100,7 @@ function renderNode(node, container, depth) {
100
100
  folderEl.className = "ft-folder";
101
101
  folderEl.dataset.accountId = node.accountId;
102
102
  folderEl.dataset.folderId = String(node.id);
103
+ folderEl.dataset.folderPath = node.path;
103
104
  folderEl.style.paddingLeft = `${depth * 16 + 8}px`;
104
105
  // Expand/collapse toggle
105
106
  const toggle = document.createElement("span");
@@ -139,6 +140,13 @@ function renderNode(node, container, depth) {
139
140
  badge.textContent = String(node.unreadCount);
140
141
  folderEl.appendChild(badge);
141
142
  }
143
+ // Total count (shown when View > Folder counts is checked)
144
+ if (node.totalCount > 0) {
145
+ const total = document.createElement("span");
146
+ total.className = "ft-total-count";
147
+ total.textContent = String(node.totalCount);
148
+ folderEl.appendChild(total);
149
+ }
142
150
  folderEl.addEventListener("click", () => {
143
151
  if (node.id === -1) {
144
152
  // Virtual parent — toggle expand instead of selecting
package/client/index.html CHANGED
@@ -26,6 +26,7 @@
26
26
  <label class="tb-menu-item"><input type="checkbox" id="opt-preview" checked> Preview pane</label>
27
27
  <label class="tb-menu-item"><input type="checkbox" id="opt-snippet" checked> Preview snippets</label>
28
28
  <label class="tb-menu-item"><input type="checkbox" id="opt-flagged"> ★ Flagged only</label>
29
+ <label class="tb-menu-item"><input type="checkbox" id="opt-folder-counts"> Folder counts</label>
29
30
  </div>
30
31
  </div>
31
32
  <div class="tb-menu" id="settings-menu">
@@ -259,6 +259,31 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
259
259
  outline-offset: -2px;
260
260
  }
261
261
 
262
+ .ft-folder.ft-syncing .ft-folder-name::after {
263
+ content: " \21BB"; /* ↻ clockwise arrow */
264
+ color: var(--color-accent);
265
+ animation: ft-spin 1s linear infinite;
266
+ display: inline-block;
267
+ }
268
+
269
+ @keyframes ft-spin {
270
+ from { transform: rotate(0deg); }
271
+ to { transform: rotate(360deg); }
272
+ }
273
+
274
+ .ft-total-count {
275
+ display: none;
276
+ margin-left: auto;
277
+ padding: 0 6px;
278
+ font-size: 0.75rem;
279
+ color: var(--color-text-muted);
280
+ opacity: 0.7;
281
+ }
282
+
283
+ .show-folder-counts .ft-total-count {
284
+ display: inline;
285
+ }
286
+
262
287
  .ml-row.dragging {
263
288
  opacity: 0.5;
264
289
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.118",
3
+ "version": "1.0.120",
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,7 +20,7 @@
20
20
  "postinstall": "node launcher/builder/postinstall.js"
21
21
  },
22
22
  "dependencies": {
23
- "@bobfrankston/iflow": "^1.0.47",
23
+ "@bobfrankston/iflow": "^1.0.48",
24
24
  "@bobfrankston/miscinfo": "^1.0.7",
25
25
  "@bobfrankston/oauthsupport": "^1.0.20",
26
26
  "@bobfrankston/rust-builder": "^0.1.3",
@@ -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))
@@ -686,6 +688,7 @@ export class ImapManager extends EventEmitter {
686
688
  client = this.createClient(accountId);
687
689
  const count = await client.getMessagesCount("INBOX");
688
690
  await client.logout();
691
+ this.trackLogout(accountId);
689
692
  client = null;
690
693
  const prev = this.lastInboxCounts.get(accountId) ?? count;
691
694
  this.lastInboxCounts.set(accountId, count);
@@ -694,6 +697,7 @@ export class ImapManager extends EventEmitter {
694
697
  client = this.createClient(accountId);
695
698
  await this.syncFolder(accountId, inbox.id, client);
696
699
  await client.logout();
700
+ this.trackLogout(accountId);
697
701
  client = null;
698
702
  }
699
703
  }
@@ -701,11 +705,13 @@ export class ImapManager extends EventEmitter {
701
705
  // Lightweight check — silently ignore errors
702
706
  }
703
707
  finally {
704
- if (client)
708
+ if (client) {
705
709
  try {
706
710
  await client.logout();
707
711
  }
708
712
  catch { /* ignore */ }
713
+ this.trackLogout(accountId);
714
+ }
709
715
  }
710
716
  }
711
717
  }
@@ -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" });
@@ -39,12 +39,21 @@ export class MailxService {
39
39
  }
40
40
  // ── Accounts ──
41
41
  getAccounts() {
42
- const accounts = this.db.getAccounts();
42
+ const dbAccounts = this.db.getAccounts();
43
43
  const settings = loadSettings();
44
- return accounts.map(a => {
45
- const cfg = settings.accounts.find(s => s.id === a.id);
46
- return { ...a, label: cfg?.label, defaultSend: cfg?.defaultSend || false };
47
- });
44
+ // Order by settings (accounts.jsonc is the source of truth for order)
45
+ const ordered = [];
46
+ for (const cfg of settings.accounts) {
47
+ const a = dbAccounts.find(d => d.id === cfg.id);
48
+ if (a)
49
+ ordered.push({ ...a, label: cfg.label, defaultSend: cfg.defaultSend || false });
50
+ }
51
+ // Append any DB accounts not in settings
52
+ for (const a of dbAccounts) {
53
+ if (!ordered.find((o) => o.id === a.id))
54
+ ordered.push(a);
55
+ }
56
+ return ordered;
48
57
  }
49
58
  // ── Folders ──
50
59
  getFolders(accountId) {
@@ -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
  }