@bobfrankston/mailx 1.0.45 → 1.0.47
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 +5 -20
- package/client/components/folder-tree.js +8 -26
- package/client/components/message-list.js +2 -6
- package/client/components/message-viewer.js +4 -16
- package/client/compose/compose.js +4 -7
- package/client/index.html +12 -3
- package/client/lib/api-client.js +75 -0
- package/client/styles/components.css +4 -0
- package/package.json +2 -2
- package/packages/mailx-imap/index.js +15 -3
- package/packages/mailx-server/index.js +34 -3
- package/packages/mailx-settings/index.js +7 -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 } from "./lib/api-client.js";
|
|
8
|
+
import { connectWebSocket, onWsEvent, triggerSync, getAccounts, getFolders, deleteMessage, undeleteMessage, restartServer, getSyncPending } from "./lib/api-client.js";
|
|
9
9
|
// ── New message badge (favicon + title) ──
|
|
10
10
|
let baseTitle = "mailx";
|
|
11
11
|
let lastSeenCount = 0;
|
|
@@ -180,7 +180,7 @@ document.getElementById("btn-restart")?.addEventListener("click", async () => {
|
|
|
180
180
|
if (statusSync)
|
|
181
181
|
statusSync.textContent = "Restarting...";
|
|
182
182
|
try {
|
|
183
|
-
await
|
|
183
|
+
await restartServer();
|
|
184
184
|
}
|
|
185
185
|
catch { /* server is shutting down */ }
|
|
186
186
|
// Server broadcasts reload event; if missed, WebSocket reconnect will trigger page reload
|
|
@@ -247,11 +247,7 @@ async function deleteCurrentMessage() {
|
|
|
247
247
|
return;
|
|
248
248
|
const { accountId, message } = current;
|
|
249
249
|
try {
|
|
250
|
-
|
|
251
|
-
if (!res.ok) {
|
|
252
|
-
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
253
|
-
throw new Error(err.error || res.statusText);
|
|
254
|
-
}
|
|
250
|
+
await deleteMessage(accountId, message.uid);
|
|
255
251
|
lastDeleted = { accountId, uid: message.uid, folderId: message.folderId, subject: message.subject };
|
|
256
252
|
// Show undo notification in status bar
|
|
257
253
|
const statusSync = document.getElementById("status-sync");
|
|
@@ -296,15 +292,7 @@ async function undoDelete() {
|
|
|
296
292
|
return;
|
|
297
293
|
const { accountId, uid, folderId } = lastDeleted;
|
|
298
294
|
try {
|
|
299
|
-
|
|
300
|
-
method: "POST",
|
|
301
|
-
headers: { "Content-Type": "application/json" },
|
|
302
|
-
body: JSON.stringify({ folderId }),
|
|
303
|
-
});
|
|
304
|
-
if (!res.ok) {
|
|
305
|
-
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
306
|
-
throw new Error(err.error || res.statusText);
|
|
307
|
-
}
|
|
295
|
+
await undeleteMessage(accountId, uid, folderId);
|
|
308
296
|
const statusSync = document.getElementById("status-sync");
|
|
309
297
|
if (statusSync)
|
|
310
298
|
statusSync.textContent = "Message restored";
|
|
@@ -638,10 +626,7 @@ fetch("/api/version").then(r => r.json()).then(d => {
|
|
|
638
626
|
let serverDown = false;
|
|
639
627
|
setInterval(async () => {
|
|
640
628
|
try {
|
|
641
|
-
const
|
|
642
|
-
if (!res.ok)
|
|
643
|
-
return;
|
|
644
|
-
const data = await res.json();
|
|
629
|
+
const data = await getSyncPending();
|
|
645
630
|
const el = document.getElementById("status-pending");
|
|
646
631
|
if (el) {
|
|
647
632
|
el.textContent = data.pending > 0 ? `↻ ${data.pending} pending` : "";
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Folder tree component -- renders account folders with hierarchy,
|
|
3
3
|
* expand/collapse, and optional unified inbox.
|
|
4
4
|
*/
|
|
5
|
-
import { getAccounts, getFolders } from "../lib/api-client.js";
|
|
5
|
+
import { getAccounts, getFolders, moveMessage, markFolderRead, createFolder, renameFolder, deleteFolder, emptyFolder } from "../lib/api-client.js";
|
|
6
6
|
import { showContextMenu } from "./context-menu.js";
|
|
7
7
|
let onFolderSelect;
|
|
8
8
|
let onUnifiedInbox = null;
|
|
@@ -166,7 +166,7 @@ function renderNode(node, container, depth) {
|
|
|
166
166
|
const items = [
|
|
167
167
|
{ label: "Mark all read", action: async () => {
|
|
168
168
|
try {
|
|
169
|
-
await
|
|
169
|
+
await markFolderRead(node.accountId, node.id);
|
|
170
170
|
const treeContainer = document.getElementById("folder-tree");
|
|
171
171
|
if (treeContainer)
|
|
172
172
|
loadFolderTree(treeContainer);
|
|
@@ -179,11 +179,7 @@ function renderNode(node, container, depth) {
|
|
|
179
179
|
if (!name)
|
|
180
180
|
return;
|
|
181
181
|
try {
|
|
182
|
-
await
|
|
183
|
-
method: "POST",
|
|
184
|
-
headers: { "Content-Type": "application/json" },
|
|
185
|
-
body: JSON.stringify({ parentPath: node.path, name }),
|
|
186
|
-
});
|
|
182
|
+
await createFolder(node.accountId, node.path, name);
|
|
187
183
|
const treeContainer = document.getElementById("folder-tree");
|
|
188
184
|
if (treeContainer)
|
|
189
185
|
loadFolderTree(treeContainer);
|
|
@@ -197,11 +193,7 @@ function renderNode(node, container, depth) {
|
|
|
197
193
|
if (!newName || newName === node.name)
|
|
198
194
|
return;
|
|
199
195
|
try {
|
|
200
|
-
await
|
|
201
|
-
method: "POST",
|
|
202
|
-
headers: { "Content-Type": "application/json" },
|
|
203
|
-
body: JSON.stringify({ newName }),
|
|
204
|
-
});
|
|
196
|
+
await renameFolder(node.accountId, node.id, newName);
|
|
205
197
|
const treeContainer = document.getElementById("folder-tree");
|
|
206
198
|
if (treeContainer)
|
|
207
199
|
loadFolderTree(treeContainer);
|
|
@@ -214,7 +206,7 @@ function renderNode(node, container, depth) {
|
|
|
214
206
|
if (!confirm(`Delete folder "${node.name}"? Messages will be moved to Trash.`))
|
|
215
207
|
return;
|
|
216
208
|
try {
|
|
217
|
-
await
|
|
209
|
+
await deleteFolder(node.accountId, node.id);
|
|
218
210
|
const treeContainer = document.getElementById("folder-tree");
|
|
219
211
|
if (treeContainer)
|
|
220
212
|
loadFolderTree(treeContainer);
|
|
@@ -230,7 +222,7 @@ function renderNode(node, container, depth) {
|
|
|
230
222
|
if (!confirm(`Permanently delete all messages in "${node.name}"?`))
|
|
231
223
|
return;
|
|
232
224
|
try {
|
|
233
|
-
await
|
|
225
|
+
await emptyFolder(node.accountId, node.id);
|
|
234
226
|
const treeContainer = document.getElementById("folder-tree");
|
|
235
227
|
if (treeContainer)
|
|
236
228
|
loadFolderTree(treeContainer);
|
|
@@ -267,18 +259,8 @@ function renderNode(node, container, depth) {
|
|
|
267
259
|
try {
|
|
268
260
|
let moved = 0;
|
|
269
261
|
for (const msg of toMove) {
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
body.targetAccountId = node.accountId;
|
|
273
|
-
const res = await fetch(`/api/message/${msg.accountId}/${msg.uid}/move`, {
|
|
274
|
-
method: "POST",
|
|
275
|
-
headers: { "Content-Type": "application/json" },
|
|
276
|
-
body: JSON.stringify(body),
|
|
277
|
-
});
|
|
278
|
-
if (!res.ok) {
|
|
279
|
-
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
280
|
-
throw new Error(err.error);
|
|
281
|
-
}
|
|
262
|
+
const targetAccountId = msg.accountId !== node.accountId ? node.accountId : undefined;
|
|
263
|
+
await moveMessage(msg.accountId, msg.uid, node.id, targetAccountId);
|
|
282
264
|
moved++;
|
|
283
265
|
}
|
|
284
266
|
if (statusEl)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Message list component -- renders paginated message rows.
|
|
3
3
|
* Loads more messages on scroll.
|
|
4
4
|
*/
|
|
5
|
-
import { getMessages, getUnifiedInbox, searchMessages } from "../lib/api-client.js";
|
|
5
|
+
import { getMessages, getUnifiedInbox, searchMessages, updateFlags } from "../lib/api-client.js";
|
|
6
6
|
/** Clear the message viewer when no message is selected */
|
|
7
7
|
function clearViewer() {
|
|
8
8
|
const bodyEl = document.getElementById("mv-body");
|
|
@@ -302,11 +302,7 @@ function appendMessages(body, accountId, items) {
|
|
|
302
302
|
? currentFlags.filter((f) => f !== "\\Flagged")
|
|
303
303
|
: [...currentFlags, "\\Flagged"];
|
|
304
304
|
try {
|
|
305
|
-
await
|
|
306
|
-
method: "PATCH",
|
|
307
|
-
headers: { "Content-Type": "application/json" },
|
|
308
|
-
body: JSON.stringify({ flags: newFlags }),
|
|
309
|
-
});
|
|
305
|
+
await updateFlags(msgAccountId, msg.uid, newFlags);
|
|
310
306
|
msg.flags = newFlags;
|
|
311
307
|
row.classList.toggle("flagged");
|
|
312
308
|
flag.textContent = row.classList.contains("flagged") ? "\u2605" : "\u2606";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Message viewer component -- displays full message in sandboxed iframe.
|
|
3
3
|
*/
|
|
4
|
-
import { getMessage, updateFlags } from "../lib/api-client.js";
|
|
4
|
+
import { getMessage, updateFlags, allowRemoteContent } from "../lib/api-client.js";
|
|
5
5
|
/** Currently displayed message (for reply/forward) */
|
|
6
6
|
let currentMessage = null;
|
|
7
7
|
let currentAccountId = "";
|
|
@@ -189,30 +189,18 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
189
189
|
};
|
|
190
190
|
banner.querySelector("#btn-load-remote").addEventListener("click", loadRemote);
|
|
191
191
|
banner.querySelector("#btn-allow-sender")?.addEventListener("click", async () => {
|
|
192
|
-
await
|
|
193
|
-
method: "POST",
|
|
194
|
-
headers: { "Content-Type": "application/json" },
|
|
195
|
-
body: JSON.stringify({ type: "sender", value: senderAddr }),
|
|
196
|
-
});
|
|
192
|
+
await allowRemoteContent("sender", senderAddr);
|
|
197
193
|
loadRemote();
|
|
198
194
|
});
|
|
199
195
|
banner.querySelector("#btn-allow-domain")?.addEventListener("click", async () => {
|
|
200
|
-
await
|
|
201
|
-
method: "POST",
|
|
202
|
-
headers: { "Content-Type": "application/json" },
|
|
203
|
-
body: JSON.stringify({ type: "domain", value: senderDomain }),
|
|
204
|
-
});
|
|
196
|
+
await allowRemoteContent("domain", senderDomain);
|
|
205
197
|
loadRemote();
|
|
206
198
|
});
|
|
207
199
|
banner.querySelector("#btn-allow-to")?.addEventListener("click", async () => {
|
|
208
200
|
const addr = deliveredTo || toAddr;
|
|
209
201
|
if (!addr)
|
|
210
202
|
return;
|
|
211
|
-
await
|
|
212
|
-
method: "POST",
|
|
213
|
-
headers: { "Content-Type": "application/json" },
|
|
214
|
-
body: JSON.stringify({ type: "recipient", value: addr }),
|
|
215
|
-
});
|
|
203
|
+
await allowRemoteContent("recipient", addr);
|
|
216
204
|
loadRemote();
|
|
217
205
|
});
|
|
218
206
|
}
|
|
@@ -261,7 +261,7 @@ async function saveDraft() {
|
|
|
261
261
|
return; // empty
|
|
262
262
|
lastDraftContent = content;
|
|
263
263
|
try {
|
|
264
|
-
const
|
|
264
|
+
const data = await fetch("/api/draft", {
|
|
265
265
|
method: "POST",
|
|
266
266
|
headers: { "Content-Type": "application/json" },
|
|
267
267
|
body: JSON.stringify({
|
|
@@ -273,12 +273,9 @@ async function saveDraft() {
|
|
|
273
273
|
cc: ccInput.value,
|
|
274
274
|
previousDraftUid: draftUid,
|
|
275
275
|
}),
|
|
276
|
-
});
|
|
277
|
-
if (
|
|
278
|
-
|
|
279
|
-
if (data.draftUid)
|
|
280
|
-
draftUid = data.draftUid;
|
|
281
|
-
}
|
|
276
|
+
}).then(r => r.ok ? r.json() : null);
|
|
277
|
+
if (data?.draftUid)
|
|
278
|
+
draftUid = data.draftUid;
|
|
282
279
|
}
|
|
283
280
|
catch { /* ignore draft save errors */ }
|
|
284
281
|
}
|
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,9 +34,17 @@
|
|
|
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
|
|
package/client/lib/api-client.js
CHANGED
|
@@ -122,6 +122,81 @@ export function allowRemoteContent(type, value) {
|
|
|
122
122
|
body: JSON.stringify({ type, value })
|
|
123
123
|
});
|
|
124
124
|
}
|
|
125
|
+
// ── Message actions ──
|
|
126
|
+
// IMPORTANT: All server operations MUST go through these centralized methods
|
|
127
|
+
// so IPC mode works. Never use fetch("/api/...") directly in components.
|
|
128
|
+
export function deleteMessage(accountId, uid) {
|
|
129
|
+
if (hasIPC)
|
|
130
|
+
return mailxapi.deleteMessage(accountId, uid);
|
|
131
|
+
return api(`/message/${accountId}/${uid}`, { method: "DELETE" });
|
|
132
|
+
}
|
|
133
|
+
export function undeleteMessage(accountId, uid, folderId) {
|
|
134
|
+
if (hasIPC)
|
|
135
|
+
return mailxapi.undeleteMessage(accountId, uid, folderId);
|
|
136
|
+
return api(`/message/${accountId}/${uid}/undelete`, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
body: JSON.stringify({ folderId })
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
export function moveMessage(accountId, uid, targetFolderId, targetAccountId) {
|
|
142
|
+
if (hasIPC)
|
|
143
|
+
return mailxapi.moveMessage(accountId, uid, targetFolderId, targetAccountId);
|
|
144
|
+
const body = { targetFolderId };
|
|
145
|
+
if (targetAccountId)
|
|
146
|
+
body.targetAccountId = targetAccountId;
|
|
147
|
+
return api(`/message/${accountId}/${uid}/move`, {
|
|
148
|
+
method: "POST",
|
|
149
|
+
body: JSON.stringify(body)
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
export function restartServer() {
|
|
153
|
+
if (hasIPC)
|
|
154
|
+
return mailxapi.restart?.();
|
|
155
|
+
return api("/restart", { method: "POST" }).catch(() => { });
|
|
156
|
+
}
|
|
157
|
+
// ── Folder management ──
|
|
158
|
+
export function markFolderRead(accountId, folderId) {
|
|
159
|
+
if (hasIPC)
|
|
160
|
+
return mailxapi.markFolderRead?.(accountId, folderId);
|
|
161
|
+
return api(`/folder/${accountId}/${folderId}/mark-read`, { method: "POST" });
|
|
162
|
+
}
|
|
163
|
+
export function createFolder(accountId, parentPath, name) {
|
|
164
|
+
if (hasIPC)
|
|
165
|
+
return mailxapi.createFolder?.(accountId, parentPath, name);
|
|
166
|
+
return api(`/folder/${accountId}`, {
|
|
167
|
+
method: "POST",
|
|
168
|
+
body: JSON.stringify({ parentPath, name })
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
export function renameFolder(accountId, folderId, newName) {
|
|
172
|
+
if (hasIPC)
|
|
173
|
+
return mailxapi.renameFolder?.(accountId, folderId, newName);
|
|
174
|
+
return api(`/folder/${accountId}/${folderId}/rename`, {
|
|
175
|
+
method: "POST",
|
|
176
|
+
body: JSON.stringify({ newName })
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
export function deleteFolder(accountId, folderId) {
|
|
180
|
+
if (hasIPC)
|
|
181
|
+
return mailxapi.deleteFolder?.(accountId, folderId);
|
|
182
|
+
return api(`/folder/${accountId}/${folderId}`, { method: "DELETE" });
|
|
183
|
+
}
|
|
184
|
+
export function emptyFolder(accountId, folderId) {
|
|
185
|
+
if (hasIPC)
|
|
186
|
+
return mailxapi.emptyFolder?.(accountId, folderId);
|
|
187
|
+
return api(`/folder/${accountId}/${folderId}/empty`, { method: "POST" });
|
|
188
|
+
}
|
|
189
|
+
// ── Compose ──
|
|
190
|
+
export function sendMessage(body) {
|
|
191
|
+
if (hasIPC)
|
|
192
|
+
return mailxapi.sendMessage?.(body);
|
|
193
|
+
return api("/send", { method: "POST", body: JSON.stringify(body) });
|
|
194
|
+
}
|
|
195
|
+
export function saveDraft(body) {
|
|
196
|
+
if (hasIPC)
|
|
197
|
+
return mailxapi.saveDraft?.(body);
|
|
198
|
+
return api("/draft", { method: "POST", body: JSON.stringify(body) });
|
|
199
|
+
}
|
|
125
200
|
const eventHandlers = [];
|
|
126
201
|
export function onEvent(handler) {
|
|
127
202
|
eventHandlers.push(handler);
|
|
@@ -91,6 +91,9 @@
|
|
|
91
91
|
}
|
|
92
92
|
.tb-menu-item:hover { background: var(--color-bg-hover); }
|
|
93
93
|
.tb-menu-item input[type="checkbox"] { accent-color: var(--color-accent); }
|
|
94
|
+
button.tb-menu-item { background: none; border: none; color: inherit; width: 100%; text-align: left; }
|
|
95
|
+
.tb-menu-sep { border: none; border-top: 1px solid var(--color-border); margin: var(--gap-xs) 0; }
|
|
96
|
+
.tb-menu-hint { display: block; padding: var(--gap-xs) var(--gap-md); font-size: 0.75rem; color: var(--color-text-muted); }
|
|
94
97
|
.tb-sep { width: 1px; height: 1.2rem; background: var(--color-border); margin: 0 var(--gap-xs); }
|
|
95
98
|
|
|
96
99
|
.search-bar {
|
|
@@ -338,6 +341,7 @@
|
|
|
338
341
|
margin-left: var(--gap-xs);
|
|
339
342
|
}
|
|
340
343
|
}
|
|
344
|
+
.no-snippets .ml-preview { display: none; }
|
|
341
345
|
.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
346
|
|
|
343
347
|
.ml-empty {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.47",
|
|
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.28",
|
|
24
24
|
"@bobfrankston/miscinfo": "^1.0.6",
|
|
25
25
|
"@bobfrankston/oauthsupport": "^1.0.11",
|
|
26
26
|
"@bobfrankston/rust-builder": "^0.1.2",
|
|
@@ -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
|
|
@@ -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, getSharedDir, 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;
|
|
@@ -67,7 +67,7 @@ app.use((req, res, next) => {
|
|
|
67
67
|
res.on("finish", () => {
|
|
68
68
|
const ms = Date.now() - start;
|
|
69
69
|
// Skip noisy polling endpoints
|
|
70
|
-
if (req.path
|
|
70
|
+
if (req.path.endsWith("/sync/pending"))
|
|
71
71
|
return;
|
|
72
72
|
console.log(` ${req.method} ${req.path} ${res.statusCode} ${ms}ms`);
|
|
73
73
|
});
|
|
@@ -125,12 +125,43 @@ ${accountInfo.map((a) => `<tr><td>${a.name}</td><td>${a.folders}</td><td>${a.inb
|
|
|
125
125
|
app.post("/api/restart", (req, res) => {
|
|
126
126
|
res.json({ ok: true });
|
|
127
127
|
broadcast({ type: "reload" });
|
|
128
|
-
// Graceful shutdown — node --watch will auto-restart
|
|
129
128
|
setTimeout(async () => {
|
|
130
129
|
console.log(" Restart requested via API");
|
|
131
130
|
await shutdown();
|
|
132
131
|
}, 500);
|
|
133
132
|
});
|
|
133
|
+
// Rebuild: wipe DB + message store, keep accounts/settings, restart
|
|
134
|
+
app.post("/api/rebuild", (req, res) => {
|
|
135
|
+
res.json({ ok: true });
|
|
136
|
+
broadcast({ type: "reload" });
|
|
137
|
+
setTimeout(async () => {
|
|
138
|
+
console.log(" Rebuild requested — wiping DB and message store...");
|
|
139
|
+
imapManager.stopPeriodicSync();
|
|
140
|
+
try {
|
|
141
|
+
await imapManager.shutdown();
|
|
142
|
+
}
|
|
143
|
+
catch { /* proceed */ }
|
|
144
|
+
db.close();
|
|
145
|
+
// Remove DB files
|
|
146
|
+
const dbDir = getConfigDir();
|
|
147
|
+
for (const f of ["mailx.db", "mailx.db-wal", "mailx.db-shm"]) {
|
|
148
|
+
const p = path.join(dbDir, f);
|
|
149
|
+
if (fs.existsSync(p)) {
|
|
150
|
+
fs.unlinkSync(p);
|
|
151
|
+
console.log(` Deleted ${f}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// Remove message store
|
|
155
|
+
const storePath = getStorePath();
|
|
156
|
+
if (fs.existsSync(storePath)) {
|
|
157
|
+
fs.rmSync(storePath, { recursive: true });
|
|
158
|
+
console.log(` Deleted ${storePath}`);
|
|
159
|
+
}
|
|
160
|
+
console.log(" Rebuild complete — restarting...");
|
|
161
|
+
server?.close();
|
|
162
|
+
process.exit(0);
|
|
163
|
+
}, 500);
|
|
164
|
+
});
|
|
134
165
|
// SPA fallback
|
|
135
166
|
app.get("*", (req, res) => {
|
|
136
167
|
if (!req.path.startsWith("/api"))
|
|
@@ -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
|