@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 +64 -3
- package/client/components/folder-tree.js +8 -0
- package/client/index.html +1 -0
- package/client/styles/components.css +25 -0
- package/package.json +2 -2
- package/packages/mailx-api/index.js +42 -0
- package/packages/mailx-imap/index.d.ts +2 -0
- package/packages/mailx-imap/index.js +7 -1
- package/packages/mailx-server/index.js +7 -1
- package/packages/mailx-service/index.js +14 -5
- package/packages/mailx-store/db.d.ts +6 -0
- package/packages/mailx-store/db.js +3 -0
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 (
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
42
|
+
const dbAccounts = this.db.getAccounts();
|
|
43
43
|
const settings = loadSettings();
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
}
|