@bobfrankston/mailx 1.0.228 → 1.0.230
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 +4 -0
- package/client/app.js +5 -0
- package/client/components/message-list.js +65 -5
- package/client/lib/api-client.js +3 -0
- package/client/lib/mailxapi.js +3 -0
- package/client/styles/components.css +52 -0
- package/package.json +3 -3
- package/packages/mailx-imap/index.d.ts +8 -0
- package/packages/mailx-imap/index.js +41 -0
- package/packages/mailx-service/index.d.ts +2 -0
- package/packages/mailx-service/index.js +4 -0
- package/packages/mailx-service/jsonrpc.js +2 -0
package/bin/mailx.js
CHANGED
|
@@ -775,6 +775,9 @@ async function main() {
|
|
|
775
775
|
imapManager.on("accountError", (accountId, error, hint, isOAuth) => {
|
|
776
776
|
handle.send({ _event: "accountError", type: "accountError", accountId, error, hint, isOAuth });
|
|
777
777
|
});
|
|
778
|
+
imapManager.on("configChanged", (filename) => {
|
|
779
|
+
handle.send({ _event: "configChanged", type: "configChanged", filename });
|
|
780
|
+
});
|
|
778
781
|
// Brief pause for WebView2 to initialize before starting IMAP (avoids stdin writes during init)
|
|
779
782
|
await new Promise(r => setTimeout(r, 500));
|
|
780
783
|
// Register all accounts (OAuth may open browser for Gmail — event loop stays free for IPC)
|
|
@@ -797,6 +800,7 @@ async function main() {
|
|
|
797
800
|
}
|
|
798
801
|
imapManager.startPeriodicSync(settings.sync.intervalMinutes);
|
|
799
802
|
imapManager.startOutboxWorker();
|
|
803
|
+
imapManager.watchConfigFiles();
|
|
800
804
|
// Graceful shutdown — close IMAP connections, stop timers, close DB
|
|
801
805
|
let shuttingDown = false;
|
|
802
806
|
async function gracefulShutdown(reason) {
|
package/client/app.js
CHANGED
|
@@ -866,6 +866,11 @@ onWsEvent((event) => {
|
|
|
866
866
|
case "reload":
|
|
867
867
|
location.reload();
|
|
868
868
|
break;
|
|
869
|
+
case "configChanged":
|
|
870
|
+
// A watched config file (accounts.jsonc etc.) was modified externally.
|
|
871
|
+
// Show a banner telling the user to restart for changes to take effect.
|
|
872
|
+
showAlert(`${event.filename} was updated — restart mailx to apply changes`, `config-${event.filename}`);
|
|
873
|
+
break;
|
|
869
874
|
case "error":
|
|
870
875
|
if (statusSync)
|
|
871
876
|
statusSync.textContent = `Error: ${event.message}`;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Message list component — renders paginated message rows.
|
|
3
3
|
* Reads from message-state; operations mutate state, list reacts.
|
|
4
4
|
*/
|
|
5
|
-
import { getMessages as apiGetMessages, getUnifiedInbox as apiGetUnifiedInbox, searchMessages, updateFlags } from "../lib/api-client.js";
|
|
5
|
+
import { getMessages as apiGetMessages, getUnifiedInbox as apiGetUnifiedInbox, searchMessages, updateFlags, getThreadMessages } from "../lib/api-client.js";
|
|
6
6
|
import * as state from "../lib/message-state.js";
|
|
7
7
|
import { showContextMenu } from "./context-menu.js";
|
|
8
8
|
let onMessageSelect;
|
|
@@ -301,6 +301,61 @@ function restoreSelection(body, savedUid) {
|
|
|
301
301
|
row.classList.add("selected");
|
|
302
302
|
}
|
|
303
303
|
}
|
|
304
|
+
/** Show a floating list of all messages in a thread when the pill is clicked.
|
|
305
|
+
* Each entry in the popup selects that message in the viewer when clicked.
|
|
306
|
+
* This is simpler than inline expansion and avoids duplicating the row builder. */
|
|
307
|
+
async function showThreadPopup(pillEl, headMsg) {
|
|
308
|
+
// Remove any existing popup
|
|
309
|
+
document.querySelectorAll(".ml-thread-popup").forEach(el => el.remove());
|
|
310
|
+
let thread = [];
|
|
311
|
+
try {
|
|
312
|
+
thread = await getThreadMessages(headMsg.accountId, headMsg.threadId);
|
|
313
|
+
}
|
|
314
|
+
catch { /* ignore */ }
|
|
315
|
+
if (!thread || thread.length === 0)
|
|
316
|
+
return;
|
|
317
|
+
thread.sort((a, b) => (a.date || 0) - (b.date || 0));
|
|
318
|
+
const popup = document.createElement("div");
|
|
319
|
+
popup.className = "ml-thread-popup";
|
|
320
|
+
for (const msg of thread) {
|
|
321
|
+
const item = document.createElement("div");
|
|
322
|
+
item.className = "ml-thread-popup-item";
|
|
323
|
+
if (!msg.flags.includes("\\Seen"))
|
|
324
|
+
item.classList.add("unread");
|
|
325
|
+
const from = document.createElement("span");
|
|
326
|
+
from.className = "ml-thread-popup-from";
|
|
327
|
+
from.textContent = msg.from?.name || msg.from?.address || "?";
|
|
328
|
+
const date = document.createElement("span");
|
|
329
|
+
date.className = "ml-thread-popup-date";
|
|
330
|
+
date.textContent = formatDate(msg.date);
|
|
331
|
+
const subject = document.createElement("span");
|
|
332
|
+
subject.className = "ml-thread-popup-subject";
|
|
333
|
+
subject.textContent = msg.subject || "(no subject)";
|
|
334
|
+
item.appendChild(from);
|
|
335
|
+
item.appendChild(date);
|
|
336
|
+
item.appendChild(subject);
|
|
337
|
+
item.addEventListener("click", async () => {
|
|
338
|
+
state.select({ accountId: msg.accountId, uid: msg.uid, folderId: msg.folderId, subject: msg.subject, from: msg.from, to: msg.to, cc: msg.cc, date: msg.date, flags: msg.flags, size: msg.size, preview: msg.preview, hasAttachments: msg.hasAttachments });
|
|
339
|
+
onMessageSelect(msg.accountId, msg.uid, msg.folderId);
|
|
340
|
+
popup.remove();
|
|
341
|
+
});
|
|
342
|
+
popup.appendChild(item);
|
|
343
|
+
}
|
|
344
|
+
document.body.appendChild(popup);
|
|
345
|
+
const rect = pillEl.getBoundingClientRect();
|
|
346
|
+
popup.style.left = `${rect.left}px`;
|
|
347
|
+
popup.style.top = `${rect.bottom + 4}px`;
|
|
348
|
+
// Dismiss on outside click
|
|
349
|
+
setTimeout(() => {
|
|
350
|
+
const dismiss = (e) => {
|
|
351
|
+
if (!popup.contains(e.target)) {
|
|
352
|
+
popup.remove();
|
|
353
|
+
document.removeEventListener("mousedown", dismiss, true);
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
document.addEventListener("mousedown", dismiss, true);
|
|
357
|
+
}, 0);
|
|
358
|
+
}
|
|
304
359
|
function appendMessages(body, accountId, items) {
|
|
305
360
|
// Thread grouping: when the list has the "threaded" class, collapse messages
|
|
306
361
|
// sharing the same threadId to a single row showing the most recent message,
|
|
@@ -373,15 +428,20 @@ function appendMessages(body, accountId, items) {
|
|
|
373
428
|
const subject = document.createElement("span");
|
|
374
429
|
subject.className = "ml-subject";
|
|
375
430
|
subject.innerHTML = escapeHtml(msg.subject);
|
|
376
|
-
// Thread size pill:
|
|
377
|
-
// represents a collapsed thread with multiple messages.
|
|
431
|
+
// Thread size pill: click to show a popup list of the thread's messages.
|
|
378
432
|
if (threadSize) {
|
|
379
433
|
const n = threadSize.get(msg) || 1;
|
|
380
|
-
if (n > 1) {
|
|
434
|
+
if (n > 1 && msg.threadId) {
|
|
435
|
+
row.classList.add("thread-head");
|
|
436
|
+
row.dataset.threadId = msg.threadId;
|
|
381
437
|
const threadPill = document.createElement("span");
|
|
382
438
|
threadPill.className = "ml-thread-pill";
|
|
383
439
|
threadPill.textContent = String(n);
|
|
384
|
-
threadPill.title = `${n} messages in this thread`;
|
|
440
|
+
threadPill.title = `${n} messages in this thread — click to see list`;
|
|
441
|
+
threadPill.addEventListener("click", async (e) => {
|
|
442
|
+
e.stopPropagation();
|
|
443
|
+
await showThreadPopup(threadPill, msg);
|
|
444
|
+
});
|
|
385
445
|
subject.prepend(threadPill);
|
|
386
446
|
}
|
|
387
447
|
}
|
package/client/lib/api-client.js
CHANGED
|
@@ -149,6 +149,9 @@ export function deleteDraft(accountId, draftUid, draftId) {
|
|
|
149
149
|
export function addContact(name, email) {
|
|
150
150
|
return ipc().addContact?.(name, email);
|
|
151
151
|
}
|
|
152
|
+
export function getThreadMessages(accountId, threadId) {
|
|
153
|
+
return ipc().getThreadMessages?.(accountId, threadId);
|
|
154
|
+
}
|
|
152
155
|
export function readJsoncFile(name) {
|
|
153
156
|
return ipc().readJsoncFile?.(name);
|
|
154
157
|
}
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -100,6 +100,9 @@
|
|
|
100
100
|
addContact: function(name, email) {
|
|
101
101
|
return callNode("addContact", { name: name, email: email });
|
|
102
102
|
},
|
|
103
|
+
getThreadMessages: function(accountId, threadId) {
|
|
104
|
+
return callNode("getThreadMessages", { accountId: accountId, threadId: threadId });
|
|
105
|
+
},
|
|
103
106
|
readJsoncFile: function(name) {
|
|
104
107
|
return callNode("readJsoncFile", { name: name });
|
|
105
108
|
},
|
|
@@ -411,6 +411,58 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
411
411
|
vertical-align: baseline;
|
|
412
412
|
min-width: 1.5em;
|
|
413
413
|
text-align: center;
|
|
414
|
+
cursor: pointer;
|
|
415
|
+
}
|
|
416
|
+
.ml-thread-pill:hover { filter: brightness(1.15); }
|
|
417
|
+
|
|
418
|
+
/* Popup that lists the messages in a thread, anchored below the clicked pill. */
|
|
419
|
+
.ml-thread-popup {
|
|
420
|
+
position: fixed;
|
|
421
|
+
min-width: 320px;
|
|
422
|
+
max-width: 600px;
|
|
423
|
+
max-height: 60vh;
|
|
424
|
+
overflow-y: auto;
|
|
425
|
+
background: var(--color-bg);
|
|
426
|
+
color: var(--color-text);
|
|
427
|
+
border: 1px solid var(--color-border);
|
|
428
|
+
border-radius: var(--radius-md);
|
|
429
|
+
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
|
430
|
+
padding: var(--gap-xs);
|
|
431
|
+
z-index: 1500;
|
|
432
|
+
font-family: var(--font-ui);
|
|
433
|
+
font-size: var(--font-size-sm);
|
|
434
|
+
}
|
|
435
|
+
.ml-thread-popup-item {
|
|
436
|
+
display: grid;
|
|
437
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
438
|
+
gap: 4px var(--gap-sm);
|
|
439
|
+
padding: 6px 10px;
|
|
440
|
+
border-radius: var(--radius-sm);
|
|
441
|
+
cursor: pointer;
|
|
442
|
+
}
|
|
443
|
+
.ml-thread-popup-item:hover { background: var(--color-bg-hover); }
|
|
444
|
+
.ml-thread-popup-item.unread .ml-thread-popup-from,
|
|
445
|
+
.ml-thread-popup-item.unread .ml-thread-popup-subject { font-weight: 600; }
|
|
446
|
+
.ml-thread-popup-from {
|
|
447
|
+
grid-column: 1;
|
|
448
|
+
grid-row: 1;
|
|
449
|
+
overflow: hidden;
|
|
450
|
+
text-overflow: ellipsis;
|
|
451
|
+
white-space: nowrap;
|
|
452
|
+
}
|
|
453
|
+
.ml-thread-popup-date {
|
|
454
|
+
grid-column: 2;
|
|
455
|
+
grid-row: 1;
|
|
456
|
+
color: var(--color-text-muted);
|
|
457
|
+
font-variant-numeric: tabular-nums;
|
|
458
|
+
}
|
|
459
|
+
.ml-thread-popup-subject {
|
|
460
|
+
grid-column: 1 / -1;
|
|
461
|
+
grid-row: 2;
|
|
462
|
+
color: var(--color-text-muted);
|
|
463
|
+
overflow: hidden;
|
|
464
|
+
text-overflow: ellipsis;
|
|
465
|
+
white-space: nowrap;
|
|
414
466
|
}
|
|
415
467
|
|
|
416
468
|
/* Generic modal — used by the JSONC config editor launched from Settings */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.230",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"@bobfrankston/iflow-node": "^0.1.2",
|
|
25
25
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
26
26
|
"@bobfrankston/oauthsupport": "^1.0.22",
|
|
27
|
-
"@bobfrankston/msger": "^0.1.
|
|
27
|
+
"@bobfrankston/msger": "^0.1.292",
|
|
28
28
|
"@capacitor/android": "^8.3.0",
|
|
29
29
|
"@capacitor/cli": "^8.3.0",
|
|
30
30
|
"@capacitor/core": "^8.3.0",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"@bobfrankston/iflow-node": "^0.1.2",
|
|
79
79
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
80
80
|
"@bobfrankston/oauthsupport": "^1.0.22",
|
|
81
|
-
"@bobfrankston/msger": "^0.1.
|
|
81
|
+
"@bobfrankston/msger": "^0.1.292",
|
|
82
82
|
"@capacitor/android": "^8.3.0",
|
|
83
83
|
"@capacitor/cli": "^8.3.0",
|
|
84
84
|
"@capacitor/core": "^8.3.0",
|
|
@@ -18,6 +18,7 @@ export interface ImapManagerEvents {
|
|
|
18
18
|
unread: number;
|
|
19
19
|
}>) => void;
|
|
20
20
|
accountError: (accountId: string, error: string, hint: string, isOAuth: boolean) => void;
|
|
21
|
+
configChanged: (filename: string) => void;
|
|
21
22
|
}
|
|
22
23
|
export declare class ImapManager extends EventEmitter {
|
|
23
24
|
private configs;
|
|
@@ -205,6 +206,13 @@ export declare class ImapManager extends EventEmitter {
|
|
|
205
206
|
startOutboxWorker(): void;
|
|
206
207
|
/** Stop Outbox worker */
|
|
207
208
|
stopOutboxWorker(): void;
|
|
209
|
+
private configWatchers;
|
|
210
|
+
/** Watch the local config files for external changes. On change, emit
|
|
211
|
+
* configChanged so the UI can show a "restart to apply" banner. Uses
|
|
212
|
+
* a debounce to coalesce rapid writes from save tools. */
|
|
213
|
+
watchConfigFiles(): void;
|
|
214
|
+
/** Stop all config file watchers */
|
|
215
|
+
stopWatchingConfig(): void;
|
|
208
216
|
private contactsSyncToken;
|
|
209
217
|
/** Get an OAuth token for Google APIs (contacts, calendar, etc.)
|
|
210
218
|
* Uses the SAME token as IMAP — scopes are combined in one grant */
|
|
@@ -2116,6 +2116,47 @@ export class ImapManager extends EventEmitter {
|
|
|
2116
2116
|
this.outboxInterval = null;
|
|
2117
2117
|
}
|
|
2118
2118
|
}
|
|
2119
|
+
// ── Config file watcher ──
|
|
2120
|
+
configWatchers = [];
|
|
2121
|
+
/** Watch the local config files for external changes. On change, emit
|
|
2122
|
+
* configChanged so the UI can show a "restart to apply" banner. Uses
|
|
2123
|
+
* a debounce to coalesce rapid writes from save tools. */
|
|
2124
|
+
watchConfigFiles() {
|
|
2125
|
+
const files = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc"];
|
|
2126
|
+
const configDir = getConfigDir();
|
|
2127
|
+
const debounce = new Map();
|
|
2128
|
+
for (const filename of files) {
|
|
2129
|
+
const full = path.join(configDir, filename);
|
|
2130
|
+
if (!fs.existsSync(full))
|
|
2131
|
+
continue;
|
|
2132
|
+
try {
|
|
2133
|
+
const watcher = fs.watch(full, () => {
|
|
2134
|
+
const prev = debounce.get(filename);
|
|
2135
|
+
if (prev)
|
|
2136
|
+
clearTimeout(prev);
|
|
2137
|
+
debounce.set(filename, setTimeout(() => {
|
|
2138
|
+
debounce.delete(filename);
|
|
2139
|
+
console.log(` [watch] ${filename} changed`);
|
|
2140
|
+
this.emit("configChanged", filename);
|
|
2141
|
+
}, 500));
|
|
2142
|
+
});
|
|
2143
|
+
this.configWatchers.push(watcher);
|
|
2144
|
+
}
|
|
2145
|
+
catch (e) {
|
|
2146
|
+
console.error(` [watch] Failed to watch ${filename}: ${e.message}`);
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
/** Stop all config file watchers */
|
|
2151
|
+
stopWatchingConfig() {
|
|
2152
|
+
for (const w of this.configWatchers) {
|
|
2153
|
+
try {
|
|
2154
|
+
w.close();
|
|
2155
|
+
}
|
|
2156
|
+
catch { /* ignore */ }
|
|
2157
|
+
}
|
|
2158
|
+
this.configWatchers = [];
|
|
2159
|
+
}
|
|
2119
2160
|
// ── Google Contacts Sync ──
|
|
2120
2161
|
contactsSyncToken = null;
|
|
2121
2162
|
/** Get an OAuth token for Google APIs (contacts, calendar, etc.)
|
|
@@ -59,6 +59,8 @@ export declare class MailxService {
|
|
|
59
59
|
* action on From/To/Cc addresses in the message viewer. Just calls the same
|
|
60
60
|
* validated upsert path as recordSentAddress. */
|
|
61
61
|
addContact(name: string, email: string): boolean;
|
|
62
|
+
/** Get all messages in a thread (across folders) for an account. */
|
|
63
|
+
getThreadMessages(accountId: string, threadId: string): any;
|
|
62
64
|
/** Read a JSONC config file from the shared cloud dir or local ~/.mailx.
|
|
63
65
|
* Names are whitelisted so the UI can't read arbitrary files. */
|
|
64
66
|
readJsoncFile(name: string): Promise<string | null>;
|
|
@@ -659,6 +659,10 @@ export class MailxService {
|
|
|
659
659
|
this.db.recordSentAddress(name || "", email);
|
|
660
660
|
return true;
|
|
661
661
|
}
|
|
662
|
+
/** Get all messages in a thread (across folders) for an account. */
|
|
663
|
+
getThreadMessages(accountId, threadId) {
|
|
664
|
+
return this.db.getThreadMessages(accountId, threadId);
|
|
665
|
+
}
|
|
662
666
|
/** Read a JSONC config file from the shared cloud dir or local ~/.mailx.
|
|
663
667
|
* Names are whitelisted so the UI can't read arbitrary files. */
|
|
664
668
|
async readJsoncFile(name) {
|
|
@@ -96,6 +96,8 @@ async function dispatchAction(svc, action, p) {
|
|
|
96
96
|
return svc.searchContacts(p.query);
|
|
97
97
|
case "addContact":
|
|
98
98
|
return { ok: svc.addContact(p.name, p.email) };
|
|
99
|
+
case "getThreadMessages":
|
|
100
|
+
return svc.getThreadMessages(p.accountId, p.threadId);
|
|
99
101
|
case "readJsoncFile":
|
|
100
102
|
return { content: await svc.readJsoncFile(p.name) };
|
|
101
103
|
case "writeJsoncFile":
|