@bobfrankston/mailx 1.0.146 → 1.0.150
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/README.md +275 -165
- package/bin/mailx.js +170 -26
- package/client/app.js +31 -36
- package/client/components/folder-tree.js +21 -13
- package/client/compose/compose.js +20 -45
- package/client/icon.png +0 -0
- package/client/lib/api-client.js +34 -0
- package/client/package.json +1 -1
- package/package.json +3 -3
- package/packages/mailx-api/index.js +6 -131
- package/packages/mailx-imap/index.d.ts +1 -0
- package/packages/mailx-imap/index.js +25 -7
- package/packages/mailx-service/index.d.ts +10 -0
- package/packages/mailx-service/index.js +101 -1
- package/packages/mailx-service/jsonrpc.js +20 -2
- package/packages/mailx-settings/index.js +32 -29
package/bin/mailx.js
CHANGED
|
@@ -3,16 +3,17 @@
|
|
|
3
3
|
* mailx -- email client
|
|
4
4
|
*
|
|
5
5
|
* Usage:
|
|
6
|
-
* mailx Start
|
|
7
|
-
* mailx --server Start
|
|
6
|
+
* mailx Start service + open in msger (IPC, no TCP)
|
|
7
|
+
* mailx --server Start Express HTTP server (dev/remote)
|
|
8
8
|
* mailx --no-browser Start server only (headless)
|
|
9
|
-
* mailx --
|
|
10
|
-
* mailx --
|
|
9
|
+
* mailx --verbose Show console output (default: log file only)
|
|
10
|
+
* mailx --import <file> Import accounts.jsonc into GDrive and merge
|
|
11
11
|
* mailx -v / --version Show version and exit
|
|
12
12
|
* mailx -kill Kill running mailx processes
|
|
13
|
-
* mailx -setup Interactive first-time setup
|
|
14
|
-
* mailx -test Test
|
|
15
|
-
* mailx -rebuild Wipe local cache,
|
|
13
|
+
* mailx -setup Interactive first-time setup (CLI)
|
|
14
|
+
* mailx -test Test IMAP/SMTP connectivity
|
|
15
|
+
* mailx -rebuild Wipe local cache, re-sync from IMAP
|
|
16
|
+
* mailx -repair Re-sync metadata (fix corrupt subjects) keeping .eml files
|
|
16
17
|
*/
|
|
17
18
|
import fs from "node:fs";
|
|
18
19
|
import path from "node:path";
|
|
@@ -30,8 +31,10 @@ const setupMode = hasFlag("setup");
|
|
|
30
31
|
const addMode = hasFlag("add");
|
|
31
32
|
const testMode = hasFlag("test");
|
|
32
33
|
const rebuildMode = hasFlag("rebuild");
|
|
34
|
+
const repairMode = hasFlag("repair");
|
|
35
|
+
const importMode = hasFlag("import");
|
|
33
36
|
// Validate arguments
|
|
34
|
-
const knownFlags = ["server", "no-browser", "verbose", "external", "kill", "v", "version", "setup", "add", "test", "rebuild", "native-imap", "log"];
|
|
37
|
+
const knownFlags = ["server", "no-browser", "verbose", "external", "kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "native-imap", "log", "import"];
|
|
35
38
|
for (const arg of args) {
|
|
36
39
|
const flag = arg.replace(/^--?/, "");
|
|
37
40
|
if (arg.startsWith("-") && !knownFlags.includes(flag)) {
|
|
@@ -128,6 +131,79 @@ if (rebuildMode) {
|
|
|
128
131
|
console.log(" Rebuild complete. Run 'mailx' to start fresh.");
|
|
129
132
|
process.exit(0);
|
|
130
133
|
}
|
|
134
|
+
// Repair: re-sync metadata (subjects, flags, envelopes) without deleting stored .eml files
|
|
135
|
+
if (repairMode) {
|
|
136
|
+
const { getConfigDir } = await import("@bobfrankston/mailx-settings");
|
|
137
|
+
const dbDir = getConfigDir();
|
|
138
|
+
const dbPath = path.join(dbDir, "mailx.db");
|
|
139
|
+
if (!fs.existsSync(dbPath)) {
|
|
140
|
+
console.error("No database found. Run 'mailx' first to create one.");
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
console.log("Repairing mailx metadata...");
|
|
144
|
+
console.log(" Message bodies (.eml files) will be preserved.");
|
|
145
|
+
console.log(" Clearing message metadata for re-sync...");
|
|
146
|
+
// Dynamic require — better-sqlite3 is a native module, not typed in bin/
|
|
147
|
+
const mod = "better-sqlite3";
|
|
148
|
+
const Database = (await import(/* webpackIgnore: true */ mod)).default;
|
|
149
|
+
const db = Database(dbPath);
|
|
150
|
+
db.pragma("journal_mode = WAL");
|
|
151
|
+
const count = db.prepare("SELECT COUNT(*) as cnt FROM messages").get().cnt;
|
|
152
|
+
db.exec("DELETE FROM messages");
|
|
153
|
+
db.exec("DELETE FROM messages_fts");
|
|
154
|
+
// Reset folder sync state so IMAP re-syncs all envelopes
|
|
155
|
+
db.exec("UPDATE folders SET total = 0, unread = 0");
|
|
156
|
+
db.close();
|
|
157
|
+
console.log(` Cleared ${count} message entries. Folder sync state reset.`);
|
|
158
|
+
console.log(" Run 'mailx' to re-sync from IMAP with correct encoding.");
|
|
159
|
+
process.exit(0);
|
|
160
|
+
}
|
|
161
|
+
// Import accounts from a local file into GDrive
|
|
162
|
+
if (importMode) {
|
|
163
|
+
const importPath = args.find(a => !a.startsWith("-"));
|
|
164
|
+
if (!importPath) {
|
|
165
|
+
console.error("Usage: mailx --import <path-to-accounts.jsonc>");
|
|
166
|
+
console.error(" Reads accounts from a local file and saves to Google Drive.");
|
|
167
|
+
console.error(" Example: mailx --import ~/OneDrive/home/.mailx/accounts.jsonc");
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
const { parse: parseJsonc } = await import("jsonc-parser");
|
|
171
|
+
const absPath = path.resolve(importPath);
|
|
172
|
+
if (!fs.existsSync(absPath)) {
|
|
173
|
+
console.error(`File not found: ${absPath}`);
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
const content = fs.readFileSync(absPath, "utf-8").replace(/\r/g, "");
|
|
177
|
+
const data = parseJsonc(content);
|
|
178
|
+
const accounts = data?.accounts || (Array.isArray(data) ? data : null);
|
|
179
|
+
if (!accounts || accounts.length === 0) {
|
|
180
|
+
console.error("No accounts found in file. Expected { accounts: [...] } or [...]");
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
console.log(`Found ${accounts.length} account(s) in ${absPath}`);
|
|
184
|
+
// Initialize cloud config (GDrive) and save
|
|
185
|
+
const { initCloudConfig, loadAccounts, saveAccounts } = await import("@bobfrankston/mailx-settings");
|
|
186
|
+
await initCloudConfig("gdrive");
|
|
187
|
+
// Merge: existing cloud accounts + imported, deduplicate by email
|
|
188
|
+
const existing = loadAccounts();
|
|
189
|
+
const merged = [...existing];
|
|
190
|
+
for (const acct of accounts) {
|
|
191
|
+
if (!merged.some(e => e.email === acct.email)) {
|
|
192
|
+
merged.push(acct);
|
|
193
|
+
console.log(` + ${acct.label || acct.email}`);
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
console.log(` = ${acct.label || acct.email} (already exists)`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// Wrap with name if the source had one
|
|
200
|
+
const wrapper = { accounts: merged };
|
|
201
|
+
if (data?.name)
|
|
202
|
+
wrapper.name = data.name;
|
|
203
|
+
saveAccounts(merged);
|
|
204
|
+
console.log(`Saved ${merged.length} account(s). Run 'mailx' to start.`);
|
|
205
|
+
process.exit(0);
|
|
206
|
+
}
|
|
131
207
|
// Version
|
|
132
208
|
if (hasFlag("v") || hasFlag("version")) {
|
|
133
209
|
const root = path.join(import.meta.dirname, "..");
|
|
@@ -324,7 +400,7 @@ async function runSetup() {
|
|
|
324
400
|
}
|
|
325
401
|
fs.mkdirSync(mailxDir, { recursive: true });
|
|
326
402
|
if (isGoogle) {
|
|
327
|
-
// Save to Google Drive via API
|
|
403
|
+
// Save to Google Drive via API — merge with existing settings if present
|
|
328
404
|
console.log("\nSaving settings to Google Drive via API...");
|
|
329
405
|
try {
|
|
330
406
|
const { gDriveFindOrCreateFolder, getCloudProvider } = await import("@bobfrankston/mailx-settings/cloud.js");
|
|
@@ -332,8 +408,31 @@ async function runSetup() {
|
|
|
332
408
|
if (folderId) {
|
|
333
409
|
const gdrive = getCloudProvider("gdrive", folderId);
|
|
334
410
|
if (gdrive) {
|
|
335
|
-
|
|
336
|
-
|
|
411
|
+
// Read existing accounts from Drive to merge (don't overwrite other accounts)
|
|
412
|
+
let accountsList = [account];
|
|
413
|
+
// Check accounts.jsonc first, then legacy settings.jsonc
|
|
414
|
+
const existingAccts = await gdrive.read("accounts.jsonc");
|
|
415
|
+
const existingSettings = existingAccts ? null : await gdrive.read("settings.jsonc");
|
|
416
|
+
const existingContent = existingAccts || existingSettings;
|
|
417
|
+
if (existingContent) {
|
|
418
|
+
try {
|
|
419
|
+
const { parse: parseJsonc } = await import("jsonc-parser");
|
|
420
|
+
const prev = parseJsonc(existingContent);
|
|
421
|
+
const prevAccounts = prev?.accounts || (Array.isArray(prev) ? prev : []);
|
|
422
|
+
if (prevAccounts.length > 0) {
|
|
423
|
+
accountsList = [...prevAccounts];
|
|
424
|
+
const newEmail = account.email.toLowerCase();
|
|
425
|
+
if (!accountsList.some((a) => a.email?.toLowerCase() === newEmail)) {
|
|
426
|
+
accountsList.push(account);
|
|
427
|
+
}
|
|
428
|
+
console.log(` Found ${prevAccounts.length} existing account(s) on Drive — merging`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
catch { /* parse failed, overwrite */ }
|
|
432
|
+
}
|
|
433
|
+
const accountsData = { name, accounts: accountsList };
|
|
434
|
+
const content = JSON.stringify(accountsData, null, 2);
|
|
435
|
+
const ok = await gdrive.write("accounts.jsonc", content);
|
|
337
436
|
if (ok) {
|
|
338
437
|
console.log("Settings saved to Google Drive (mailx folder)");
|
|
339
438
|
const config = { sharedDir: { provider: "gdrive", path: "mailx", folderId } };
|
|
@@ -488,8 +587,18 @@ async function main() {
|
|
|
488
587
|
console.log("No mailx configuration found.");
|
|
489
588
|
await runSetup();
|
|
490
589
|
}
|
|
491
|
-
//
|
|
492
|
-
|
|
590
|
+
// Redirect console to log file — keep terminal clean
|
|
591
|
+
if (!verbose) {
|
|
592
|
+
const home = process.env.USERPROFILE || process.env.HOME || ".";
|
|
593
|
+
const logDir = path.join(home, ".mailx", "logs");
|
|
594
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
595
|
+
const logDate = new Date().toISOString().slice(0, 10);
|
|
596
|
+
const logPath = path.join(logDir, `mailx-${logDate}.log`);
|
|
597
|
+
const logStream = fs.createWriteStream(logPath, { flags: "a" });
|
|
598
|
+
const ts = () => new Date().toISOString().slice(11, 23);
|
|
599
|
+
console.log = (...a) => { logStream.write(`${ts()} ${a.join(" ")}\n`); };
|
|
600
|
+
console.error = (...a) => { logStream.write(`${ts()} ERROR ${a.join(" ")}\n`); };
|
|
601
|
+
}
|
|
493
602
|
// --server mode: Express + HTTP (for dev/remote access)
|
|
494
603
|
if (serverMode) {
|
|
495
604
|
console.log("Starting mailx HTTP server...");
|
|
@@ -520,20 +629,35 @@ async function main() {
|
|
|
520
629
|
const imapManager = new ImapManager(db);
|
|
521
630
|
imapManager.useNativeClient = true;
|
|
522
631
|
const svc = new MailxService(db, imapManager);
|
|
523
|
-
// Open msger in service mode —
|
|
632
|
+
// Open msger in service mode — custom protocol serves files from client dir
|
|
524
633
|
const clientDir = path.join(import.meta.dirname, "..", "client");
|
|
525
|
-
const indexPath = path.join(clientDir, "index.html");
|
|
526
634
|
const mailxapiPath = path.join(clientDir, "lib", "mailxapi.js");
|
|
527
635
|
const mailxapiScript = fs.readFileSync(mailxapiPath, "utf-8");
|
|
528
636
|
const handle = showService({
|
|
529
|
-
url:
|
|
637
|
+
url: "index.html",
|
|
638
|
+
contentDir: clientDir,
|
|
530
639
|
initScript: mailxapiScript,
|
|
640
|
+
icon: path.join(clientDir, "icon.png"),
|
|
531
641
|
size: { width: 1400, height: 900 },
|
|
642
|
+
escapeCloses: false,
|
|
532
643
|
});
|
|
533
644
|
// Handle requests from WebView → dispatch to MailxService
|
|
645
|
+
// Pass server version to dispatch so getVersion returns it
|
|
646
|
+
const rootPkg = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, "..", "package.json"), "utf-8"));
|
|
534
647
|
handle.onRequest(async (req) => {
|
|
535
|
-
|
|
536
|
-
|
|
648
|
+
if (verbose)
|
|
649
|
+
console.error(`[ipc] ← ${req._action} (${req._cbid})`);
|
|
650
|
+
req._version = rootPkg.version;
|
|
651
|
+
try {
|
|
652
|
+
const response = await dispatch(svc, req);
|
|
653
|
+
if (verbose)
|
|
654
|
+
console.error(`[ipc] → ${req._action} (${req._cbid}) ok`);
|
|
655
|
+
handle.send(response);
|
|
656
|
+
}
|
|
657
|
+
catch (e) {
|
|
658
|
+
console.error(`[ipc] → ${req._action} (${req._cbid}) error: ${e.message}`);
|
|
659
|
+
handle.send({ _cbid: req._cbid, error: e.message });
|
|
660
|
+
}
|
|
537
661
|
});
|
|
538
662
|
// Wire IMAP events → push to WebView
|
|
539
663
|
imapManager.on("syncProgress", (accountId, phase, progress) => {
|
|
@@ -567,16 +691,36 @@ async function main() {
|
|
|
567
691
|
}
|
|
568
692
|
imapManager.startPeriodicSync(settings.sync.intervalMinutes);
|
|
569
693
|
imapManager.startOutboxWorker();
|
|
694
|
+
// Graceful shutdown — close IMAP connections, stop timers, close DB
|
|
695
|
+
let shuttingDown = false;
|
|
696
|
+
async function gracefulShutdown(reason) {
|
|
697
|
+
if (shuttingDown)
|
|
698
|
+
return;
|
|
699
|
+
shuttingDown = true;
|
|
700
|
+
console.log(`${reason} — shutting down`);
|
|
701
|
+
imapManager.stopPeriodicSync();
|
|
702
|
+
imapManager.stopOutboxWorker();
|
|
703
|
+
try {
|
|
704
|
+
await imapManager.shutdown();
|
|
705
|
+
}
|
|
706
|
+
catch { /* proceed */ }
|
|
707
|
+
db.close();
|
|
708
|
+
process.exit(0);
|
|
709
|
+
}
|
|
710
|
+
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
|
711
|
+
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
|
712
|
+
process.on("exit", () => {
|
|
713
|
+
if (!shuttingDown) {
|
|
714
|
+
// Sync fallback — shutdown() is async so can't fully run here,
|
|
715
|
+
// but at least stop timers and release semaphores
|
|
716
|
+
imapManager.stopPeriodicSync();
|
|
717
|
+
imapManager.stopOutboxWorker();
|
|
718
|
+
db.close();
|
|
719
|
+
}
|
|
720
|
+
});
|
|
570
721
|
// Wait for window close, then shut down
|
|
571
722
|
await handle.closed;
|
|
572
|
-
|
|
573
|
-
imapManager.stopPeriodicSync();
|
|
574
|
-
try {
|
|
575
|
-
await imapManager.shutdown();
|
|
576
|
-
}
|
|
577
|
-
catch { /* proceed */ }
|
|
578
|
-
db.close();
|
|
579
|
-
process.exit(0);
|
|
723
|
+
await gracefulShutdown("Window closed");
|
|
580
724
|
}
|
|
581
725
|
main().catch(console.error);
|
|
582
726
|
//# sourceMappingURL=mailx.js.map
|
package/client/app.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { initFolderTree, refreshFolderTree, updateFolderCounts } from "./components/folder-tree.js";
|
|
6
6
|
import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, getSelectedMessages } from "./components/message-list.js";
|
|
7
7
|
import { showMessage, getCurrentMessage } from "./components/message-viewer.js";
|
|
8
|
-
import { connectWebSocket, onWsEvent, triggerSync, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, rebuildServer } from "./lib/api-client.js";
|
|
8
|
+
import { connectWebSocket, onWsEvent, triggerSync, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, rebuildServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts } from "./lib/api-client.js";
|
|
9
9
|
// ── New message badge (favicon + title) ──
|
|
10
10
|
let baseTitle = "mailx";
|
|
11
11
|
let lastSeenCount = 0;
|
|
@@ -226,13 +226,19 @@ document.addEventListener("click", (e) => {
|
|
|
226
226
|
document.getElementById("btn-restart-quick")?.addEventListener("click", async () => {
|
|
227
227
|
if (restartDropdown)
|
|
228
228
|
restartDropdown.hidden = true;
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
229
|
+
if (isApp) {
|
|
230
|
+
// IPC mode: reload the UI (no server to restart)
|
|
231
|
+
location.reload();
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
const statusSync = document.getElementById("status-sync");
|
|
235
|
+
if (statusSync)
|
|
236
|
+
statusSync.textContent = "Restarting...";
|
|
237
|
+
try {
|
|
238
|
+
await restartServer();
|
|
239
|
+
}
|
|
240
|
+
catch { /* server is shutting down */ }
|
|
234
241
|
}
|
|
235
|
-
catch { /* server is shutting down */ }
|
|
236
242
|
});
|
|
237
243
|
document.getElementById("btn-rebuild")?.addEventListener("click", async () => {
|
|
238
244
|
if (restartDropdown)
|
|
@@ -856,7 +862,7 @@ settingsBtn?.addEventListener("click", (e) => {
|
|
|
856
862
|
});
|
|
857
863
|
// Close handled by the shared document click handler above
|
|
858
864
|
// Load current editor setting from server
|
|
859
|
-
|
|
865
|
+
getSettings().then((s) => {
|
|
860
866
|
const ed = s.ui?.editor || "quill";
|
|
861
867
|
if (optEditorQuill)
|
|
862
868
|
optEditorQuill.checked = ed === "quill";
|
|
@@ -865,13 +871,9 @@ fetch("/api/settings").then(r => r.json()).then(s => {
|
|
|
865
871
|
}).catch(() => { });
|
|
866
872
|
// Save editor choice to server settings
|
|
867
873
|
function saveEditorSetting(editor) {
|
|
868
|
-
|
|
874
|
+
getSettings().then((settings) => {
|
|
869
875
|
settings.ui = { ...settings.ui, editor };
|
|
870
|
-
|
|
871
|
-
method: "PUT",
|
|
872
|
-
headers: { "Content-Type": "application/json" },
|
|
873
|
-
body: JSON.stringify(settings),
|
|
874
|
-
});
|
|
876
|
+
saveSettings(settings);
|
|
875
877
|
}).catch(() => { });
|
|
876
878
|
}
|
|
877
879
|
optEditorQuill?.addEventListener("change", () => {
|
|
@@ -885,22 +887,18 @@ optEditorTiptap?.addEventListener("change", () => {
|
|
|
885
887
|
// ── AI autocomplete toggle ──
|
|
886
888
|
const optAutocomplete = document.getElementById("opt-autocomplete");
|
|
887
889
|
// Load current autocomplete setting
|
|
888
|
-
|
|
890
|
+
getAutocompleteSettings().then((ac) => {
|
|
889
891
|
if (optAutocomplete)
|
|
890
892
|
optAutocomplete.checked = ac.enabled || false;
|
|
891
893
|
}).catch(() => { });
|
|
892
894
|
optAutocomplete?.addEventListener("change", () => {
|
|
893
|
-
|
|
895
|
+
getAutocompleteSettings().then((ac) => {
|
|
894
896
|
ac.enabled = optAutocomplete.checked;
|
|
895
|
-
|
|
896
|
-
method: "POST",
|
|
897
|
-
headers: { "Content-Type": "application/json" },
|
|
898
|
-
body: JSON.stringify(ac),
|
|
899
|
-
});
|
|
897
|
+
saveAutocompleteSettings(ac);
|
|
900
898
|
}).catch(() => { });
|
|
901
899
|
});
|
|
902
900
|
const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
|
|
903
|
-
const versionPromise =
|
|
901
|
+
const versionPromise = getVersion();
|
|
904
902
|
versionPromise.then((d) => {
|
|
905
903
|
const el = document.getElementById("app-version");
|
|
906
904
|
const storage = d.storage || {};
|
|
@@ -928,8 +926,7 @@ versionPromise.then((d) => {
|
|
|
928
926
|
btn.textContent = "Restoring...";
|
|
929
927
|
btn.disabled = true;
|
|
930
928
|
try {
|
|
931
|
-
const
|
|
932
|
-
const data = await r.json();
|
|
929
|
+
const data = await repairAccounts();
|
|
933
930
|
if (data.ok) {
|
|
934
931
|
hideAlert();
|
|
935
932
|
setTimeout(() => location.reload(), 1000);
|
|
@@ -948,15 +945,16 @@ versionPromise.then((d) => {
|
|
|
948
945
|
else if (storage.cloudError) {
|
|
949
946
|
showAlert(`Cloud storage error: ${storage.cloudError}`, "cloud-error");
|
|
950
947
|
}
|
|
951
|
-
}).catch(() => {
|
|
952
|
-
// Version fetch failed
|
|
953
|
-
|
|
954
|
-
|
|
948
|
+
}).catch((e) => {
|
|
949
|
+
// Version fetch failed
|
|
950
|
+
const el = document.getElementById("app-version");
|
|
951
|
+
if (isApp) {
|
|
952
|
+
if (el)
|
|
953
|
+
el.textContent = `mailx [version error: ${e.message}]`;
|
|
954
|
+
}
|
|
955
|
+
else {
|
|
955
956
|
if (el)
|
|
956
957
|
el.textContent = "mailx [server offline]";
|
|
957
|
-
const startupStatus = document.getElementById("startup-status");
|
|
958
|
-
if (startupStatus)
|
|
959
|
-
startupStatus.textContent = "Server offline — start with: node packages/mailx-server/index.js";
|
|
960
958
|
}
|
|
961
959
|
});
|
|
962
960
|
// ── Sync pending indicator + server health check (HTTP mode only) ──
|
|
@@ -967,10 +965,7 @@ if (isApp) {
|
|
|
967
965
|
else
|
|
968
966
|
setInterval(async () => {
|
|
969
967
|
try {
|
|
970
|
-
const
|
|
971
|
-
if (!res.ok)
|
|
972
|
-
return;
|
|
973
|
-
const data = await res.json();
|
|
968
|
+
const data = await getSyncPending();
|
|
974
969
|
const el = document.getElementById("status-pending");
|
|
975
970
|
if (el) {
|
|
976
971
|
el.textContent = data.pending > 0 ? `↻ ${data.pending} pending` : "";
|
|
@@ -1010,7 +1005,7 @@ function scheduleMiddnightRefresh() {
|
|
|
1010
1005
|
}
|
|
1011
1006
|
scheduleMiddnightRefresh();
|
|
1012
1007
|
// ── Apply theme from settings ──
|
|
1013
|
-
|
|
1008
|
+
getVersion().then((d) => {
|
|
1014
1009
|
if (d.theme === "dark")
|
|
1015
1010
|
document.documentElement.classList.add("theme-dark");
|
|
1016
1011
|
else if (d.theme === "light")
|
|
@@ -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, moveMessage, moveMessages, markFolderRead, createFolder, renameFolder, deleteFolder, emptyFolder } from "../lib/api-client.js";
|
|
5
|
+
import { getAccounts, getFolders, moveMessage, moveMessages, markFolderRead, createFolder, renameFolder, deleteFolder, emptyFolder, setupAccount, getVersion } from "../lib/api-client.js";
|
|
6
6
|
import { showContextMenu } from "./context-menu.js";
|
|
7
7
|
let onFolderSelect;
|
|
8
8
|
let onUnifiedInbox = null;
|
|
@@ -359,8 +359,9 @@ async function loadFolderTree(container) {
|
|
|
359
359
|
<input id="setup-email" type="email" placeholder="you@gmail.com" required style="display:block;width:100%;padding:0.5rem;margin-top:0.25rem;background:var(--color-bg-surface);color:var(--color-text);border:1px solid var(--color-border);border-radius:4px">
|
|
360
360
|
</label>
|
|
361
361
|
<label id="setup-password-row" style="display:block;margin-bottom:0.5rem">
|
|
362
|
-
Password <span style="color:var(--color-text-muted);font-size:0.85rem">(not needed for Gmail/Outlook)</span>
|
|
362
|
+
Password <span id="setup-password-hint" style="color:var(--color-text-muted);font-size:0.85rem">(not needed for Gmail/Outlook)</span>
|
|
363
363
|
<input id="setup-password" type="password" placeholder="password" style="display:block;width:100%;padding:0.5rem;margin-top:0.25rem;background:var(--color-bg-surface);color:var(--color-text);border:1px solid var(--color-border);border-radius:4px">
|
|
364
|
+
<div id="setup-app-password-help" style="display:none;margin-top:0.25rem;font-size:0.8rem;color:var(--color-text-muted)"></div>
|
|
364
365
|
</label>
|
|
365
366
|
<button type="submit" style="margin-top:1rem;padding:0.5rem 2rem;background:var(--color-accent);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:1rem">Add Account</button>
|
|
366
367
|
<div id="setup-status" style="margin-top:1rem;color:var(--color-text-muted)"></div>
|
|
@@ -376,13 +377,24 @@ async function loadFolderTree(container) {
|
|
|
376
377
|
const form = document.getElementById("setup-form");
|
|
377
378
|
const emailInput = document.getElementById("setup-email");
|
|
378
379
|
const statusEl = document.getElementById("setup-status");
|
|
379
|
-
// Hide password for
|
|
380
|
+
// Hide password for OAuth providers, show app-password help for others
|
|
381
|
+
const APP_PASSWORD_HELP = {
|
|
382
|
+
"yahoo.com": "Use an app password: Yahoo Settings → Account Security → Generate app password",
|
|
383
|
+
"aol.com": "Use an app password: AOL Settings → Account Security → Generate app password",
|
|
384
|
+
"icloud.com": "Use an app-specific password: appleid.apple.com → Sign-In and Security → App-Specific Passwords",
|
|
385
|
+
};
|
|
380
386
|
emailInput?.addEventListener("input", () => {
|
|
381
387
|
const domain = emailInput.value.split("@")[1]?.toLowerCase() || "";
|
|
382
|
-
const isOAuth = ["gmail.com", "googlemail.com", "outlook.com", "hotmail.com"].includes(domain);
|
|
388
|
+
const isOAuth = ["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com"].includes(domain);
|
|
383
389
|
const pwRow = document.getElementById("setup-password-row");
|
|
384
390
|
if (pwRow)
|
|
385
391
|
pwRow.style.display = isOAuth ? "none" : "block";
|
|
392
|
+
const helpEl = document.getElementById("setup-app-password-help");
|
|
393
|
+
if (helpEl) {
|
|
394
|
+
const help = APP_PASSWORD_HELP[domain];
|
|
395
|
+
helpEl.style.display = help ? "block" : "none";
|
|
396
|
+
helpEl.textContent = help || "";
|
|
397
|
+
}
|
|
386
398
|
});
|
|
387
399
|
form?.addEventListener("submit", async (e) => {
|
|
388
400
|
e.preventDefault();
|
|
@@ -393,15 +405,11 @@ async function loadFolderTree(container) {
|
|
|
393
405
|
return;
|
|
394
406
|
statusEl.textContent = "Setting up account...";
|
|
395
407
|
try {
|
|
396
|
-
const
|
|
397
|
-
method: "POST",
|
|
398
|
-
headers: { "Content-Type": "application/json" },
|
|
399
|
-
body: JSON.stringify({ name, email, password }),
|
|
400
|
-
});
|
|
401
|
-
const data = await res.json();
|
|
408
|
+
const data = await setupAccount(name, email, password);
|
|
402
409
|
if (data.ok) {
|
|
403
|
-
statusEl.textContent = "Account added!
|
|
404
|
-
|
|
410
|
+
statusEl.textContent = "Account added! Syncing...";
|
|
411
|
+
// Wait for sync to populate folders before reloading
|
|
412
|
+
setTimeout(() => location.reload(), 5000);
|
|
405
413
|
}
|
|
406
414
|
else {
|
|
407
415
|
statusEl.textContent = `Error: ${data.error || "Setup failed"}`;
|
|
@@ -412,7 +420,7 @@ async function loadFolderTree(container) {
|
|
|
412
420
|
}
|
|
413
421
|
});
|
|
414
422
|
// Show cloud storage status in setup form
|
|
415
|
-
|
|
423
|
+
getVersion().then((d) => {
|
|
416
424
|
const cloudEl = document.getElementById("setup-cloud-status");
|
|
417
425
|
if (!cloudEl)
|
|
418
426
|
return;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Receives init data via window.opener.postMessage or URL params.
|
|
5
5
|
*/
|
|
6
6
|
import { createEditor } from "./editor.js";
|
|
7
|
+
import { getVersion, getSettings, getAccounts, searchContacts, sendMessage, saveDraft as apiSaveDraft, deleteDraft } from "../lib/api-client.js";
|
|
7
8
|
// ── Load editor scripts dynamically ──
|
|
8
9
|
function loadScript(src) {
|
|
9
10
|
return new Promise((resolve, reject) => {
|
|
@@ -43,16 +44,10 @@ async function loadEditorAssets(type) {
|
|
|
43
44
|
let editorType = "quill";
|
|
44
45
|
let appSettings = null;
|
|
45
46
|
try {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (settingsRes.ok) {
|
|
51
|
-
appSettings = await settingsRes.json();
|
|
52
|
-
if (appSettings.ui?.editor === "tiptap")
|
|
53
|
-
editorType = "tiptap";
|
|
54
|
-
}
|
|
55
|
-
}
|
|
47
|
+
await getVersion(); // verify server is up
|
|
48
|
+
appSettings = await getSettings();
|
|
49
|
+
if (appSettings.ui?.editor === "tiptap")
|
|
50
|
+
editorType = "tiptap";
|
|
56
51
|
}
|
|
57
52
|
catch { /* default to quill */ }
|
|
58
53
|
await loadEditorAssets(editorType);
|
|
@@ -171,10 +166,7 @@ function setupAutocomplete(input) {
|
|
|
171
166
|
}
|
|
172
167
|
debounce = setTimeout(async () => {
|
|
173
168
|
try {
|
|
174
|
-
const
|
|
175
|
-
if (!res.ok)
|
|
176
|
-
return;
|
|
177
|
-
const results = await res.json();
|
|
169
|
+
const results = await searchContacts(token);
|
|
178
170
|
if (results.length === 0) {
|
|
179
171
|
closeDropdown();
|
|
180
172
|
return;
|
|
@@ -291,12 +283,11 @@ else {
|
|
|
291
283
|
}
|
|
292
284
|
// If From dropdown is empty (new compose without init, or init had no accounts), fetch from API
|
|
293
285
|
if (fromSelect.options.length === 0) {
|
|
294
|
-
|
|
295
|
-
.then(r => r.json())
|
|
286
|
+
getAccounts()
|
|
296
287
|
.then((accounts) => {
|
|
297
288
|
populateFromSelect(accounts);
|
|
298
289
|
})
|
|
299
|
-
.catch(e => console.error("Failed to load accounts:", e));
|
|
290
|
+
.catch((e) => console.error("Failed to load accounts:", e));
|
|
300
291
|
}
|
|
301
292
|
// ── Auto-save drafts every 5 seconds ──
|
|
302
293
|
let draftUid = null;
|
|
@@ -315,20 +306,16 @@ async function saveDraft() {
|
|
|
315
306
|
lastDraftContent = content;
|
|
316
307
|
draftSaving = true;
|
|
317
308
|
try {
|
|
318
|
-
const data = await
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
previousDraftUid: draftUid,
|
|
329
|
-
draftId: draftId,
|
|
330
|
-
}),
|
|
331
|
-
}).then(r => r.ok ? r.json() : null);
|
|
309
|
+
const data = await apiSaveDraft({
|
|
310
|
+
accountId: getFromAccountId(),
|
|
311
|
+
subject: subjectInput.value,
|
|
312
|
+
bodyHtml: editor.getHtml(),
|
|
313
|
+
bodyText: editor.getText(),
|
|
314
|
+
to: toInput.value,
|
|
315
|
+
cc: ccInput.value,
|
|
316
|
+
previousDraftUid: draftUid,
|
|
317
|
+
draftId: draftId,
|
|
318
|
+
});
|
|
332
319
|
if (data?.draftUid)
|
|
333
320
|
draftUid = data.draftUid;
|
|
334
321
|
if (data?.draftId)
|
|
@@ -356,23 +343,11 @@ document.getElementById("btn-send")?.addEventListener("click", async () => {
|
|
|
356
343
|
bodyText: editor.getText(),
|
|
357
344
|
};
|
|
358
345
|
try {
|
|
359
|
-
|
|
360
|
-
method: "POST",
|
|
361
|
-
headers: { "Content-Type": "application/json" },
|
|
362
|
-
body: JSON.stringify(body),
|
|
363
|
-
});
|
|
364
|
-
if (!res.ok) {
|
|
365
|
-
const err = await res.json();
|
|
366
|
-
throw new Error(err.error || res.statusText);
|
|
367
|
-
}
|
|
346
|
+
await sendMessage(body);
|
|
368
347
|
// Delete draft after successful send
|
|
369
348
|
clearInterval(draftTimer);
|
|
370
349
|
if (draftUid) {
|
|
371
|
-
|
|
372
|
-
method: "DELETE",
|
|
373
|
-
headers: { "Content-Type": "application/json" },
|
|
374
|
-
body: JSON.stringify({ accountId: getFromAccountId(), draftUid }),
|
|
375
|
-
}).catch(() => { });
|
|
350
|
+
deleteDraft(getFromAccountId(), draftUid).catch(() => { });
|
|
376
351
|
}
|
|
377
352
|
window.close();
|
|
378
353
|
}
|
package/client/icon.png
ADDED
|
Binary file
|
package/client/lib/api-client.js
CHANGED
|
@@ -250,11 +250,45 @@ export function autocomplete(body, signal) {
|
|
|
250
250
|
return api("/autocomplete", { method: "POST", body: JSON.stringify(body), signal });
|
|
251
251
|
}
|
|
252
252
|
export function getAutocompleteSettings() {
|
|
253
|
+
if (hasIPC)
|
|
254
|
+
return mailxapi.getAutocompleteSettings?.();
|
|
253
255
|
return api("/autocomplete/settings");
|
|
254
256
|
}
|
|
255
257
|
export function saveAutocompleteSettings(settings) {
|
|
258
|
+
if (hasIPC)
|
|
259
|
+
return mailxapi.saveAutocompleteSettings?.(settings);
|
|
256
260
|
return api("/autocomplete/settings", { method: "POST", body: JSON.stringify(settings) });
|
|
257
261
|
}
|
|
262
|
+
export function getVersion() {
|
|
263
|
+
if (hasIPC)
|
|
264
|
+
return mailxapi.getVersion();
|
|
265
|
+
return api("/version");
|
|
266
|
+
}
|
|
267
|
+
export function getSettings() {
|
|
268
|
+
if (hasIPC)
|
|
269
|
+
return mailxapi.getSettings();
|
|
270
|
+
return api("/settings");
|
|
271
|
+
}
|
|
272
|
+
export function saveSettings(settings) {
|
|
273
|
+
if (hasIPC)
|
|
274
|
+
return mailxapi.saveSettingsData?.(settings);
|
|
275
|
+
return api("/settings", { method: "PUT", body: JSON.stringify(settings) });
|
|
276
|
+
}
|
|
277
|
+
export function repairAccounts() {
|
|
278
|
+
if (hasIPC)
|
|
279
|
+
return mailxapi.repairAccounts?.();
|
|
280
|
+
return api("/repair-accounts", { method: "POST" });
|
|
281
|
+
}
|
|
282
|
+
export function deleteDraft(accountId, draftUid) {
|
|
283
|
+
if (hasIPC)
|
|
284
|
+
return mailxapi.deleteDraft?.(accountId, draftUid);
|
|
285
|
+
return api("/draft", { method: "DELETE", body: JSON.stringify({ accountId, draftUid }) });
|
|
286
|
+
}
|
|
287
|
+
export function setupAccount(name, email, password) {
|
|
288
|
+
if (hasIPC)
|
|
289
|
+
return mailxapi.setupAccount?.(name, email, password);
|
|
290
|
+
return api("/setup", { method: "POST", body: JSON.stringify({ name, email, password }) });
|
|
291
|
+
}
|
|
258
292
|
// Legacy exports for backward compatibility
|
|
259
293
|
export const connectWebSocket = connectEvents;
|
|
260
294
|
export const onWsEvent = onEvent;
|