@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 +28 -2
- package/client/app.js +73 -5
- package/client/index.html +17 -3
- package/client/lib/api-client.js +3 -0
- package/client/styles/components.css +18 -1
- package/client/styles/layout.css +4 -1
- package/package.json +3 -3
- package/packages/mailx-imap/index.js +24 -9
- package/packages/mailx-server/index.js +40 -9
- package/packages/mailx-settings/index.d.ts +5 -0
- package/packages/mailx-settings/index.js +33 -5
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
<
|
|
37
|
-
<
|
|
38
|
-
|
|
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">×</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">
|
package/client/lib/api-client.js
CHANGED
|
@@ -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
|
|
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 {
|
package/client/styles/layout.css
CHANGED
|
@@ -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.
|
|
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.
|
|
23
|
+
"@bobfrankston/iflow": "^1.0.29",
|
|
24
24
|
"@bobfrankston/miscinfo": "^1.0.6",
|
|
25
|
-
"@bobfrankston/oauthsupport": "^1.0.
|
|
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 (  & 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(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
35
|
+
.replace(/"/g, '"').replace(/'/g, "'").replace(/ /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
|
-
|
|
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
|
|
1010
|
-
const
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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(
|
|
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,
|
|
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
|
|
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
|
|
86
|
-
|
|
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>
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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) {
|