@bobfrankston/mailx 1.0.147 → 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/bin/mailx.js CHANGED
@@ -3,16 +3,17 @@
3
3
  * mailx -- email client
4
4
  *
5
5
  * Usage:
6
- * mailx Start server + open in msger
7
- * mailx --server Start server + open msger (same as default)
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 --external Bind to all interfaces
10
- * mailx --verbose Show detailed startup info
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 account connectivity
15
- * mailx -rebuild Wipe local cache, keep accounts
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 (folder-ID based, no path navigation)
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
- const content = JSON.stringify(settings, null, 2);
336
- const ok = await gdrive.write("settings.jsonc", content);
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 } };
@@ -496,8 +595,9 @@ async function main() {
496
595
  const logDate = new Date().toISOString().slice(0, 10);
497
596
  const logPath = path.join(logDir, `mailx-${logDate}.log`);
498
597
  const logStream = fs.createWriteStream(logPath, { flags: "a" });
499
- console.log = (...a) => { logStream.write(a.join(" ") + "\n"); };
500
- console.error = (...a) => { logStream.write("ERROR " + a.join(" ") + "\n"); };
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`); };
501
601
  }
502
602
  // --server mode: Express + HTTP (for dev/remote access)
503
603
  if (serverMode) {
@@ -537,6 +637,7 @@ async function main() {
537
637
  url: "index.html",
538
638
  contentDir: clientDir,
539
639
  initScript: mailxapiScript,
640
+ icon: path.join(clientDir, "icon.png"),
540
641
  size: { width: 1400, height: 900 },
541
642
  escapeCloses: false,
542
643
  });
@@ -590,16 +691,36 @@ async function main() {
590
691
  }
591
692
  imapManager.startPeriodicSync(settings.sync.intervalMinutes);
592
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
+ });
593
721
  // Wait for window close, then shut down
594
722
  await handle.closed;
595
- console.log("Window closed — shutting down");
596
- imapManager.stopPeriodicSync();
597
- try {
598
- await imapManager.shutdown();
599
- }
600
- catch { /* proceed */ }
601
- db.close();
602
- process.exit(0);
723
+ await gracefulShutdown("Window closed");
603
724
  }
604
725
  main().catch(console.error);
605
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
- const statusSync = document.getElementById("status-sync");
230
- if (statusSync)
231
- statusSync.textContent = "Restarting...";
232
- try {
233
- await restartServer();
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
- fetch("/api/settings").then(r => r.json()).then(s => {
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
- fetch("/api/settings").then(r => r.json()).then(settings => {
874
+ getSettings().then((settings) => {
869
875
  settings.ui = { ...settings.ui, editor };
870
- fetch("/api/settings", {
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
- fetch("/api/autocomplete/settings").then(r => r.json()).then(ac => {
890
+ getAutocompleteSettings().then((ac) => {
889
891
  if (optAutocomplete)
890
892
  optAutocomplete.checked = ac.enabled || false;
891
893
  }).catch(() => { });
892
894
  optAutocomplete?.addEventListener("change", () => {
893
- fetch("/api/autocomplete/settings").then(r => r.json()).then(ac => {
895
+ getAutocompleteSettings().then((ac) => {
894
896
  ac.enabled = optAutocomplete.checked;
895
- fetch("/api/autocomplete/settings", {
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 = isApp ? mailxapi.getVersion() : fetch("/api/version").then(r => r.json());
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 r = await fetch("/api/repair-accounts", { method: "POST" });
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 — in IPC mode this means service isn't responding yet
953
- if (!isApp) {
954
- const el = document.getElementById("app-version");
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 res = await fetch("/api/sync/pending");
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
- (isApp ? mailxapi.getVersion() : fetch("/api/version").then(r => r.json())).then((d) => {
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 known OAuth providers
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 res = await fetch("/api/setup", {
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! Restarting...";
404
- setTimeout(() => location.reload(), 2000);
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
- fetch("/api/version").then(r => r.json()).then(d => {
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
- const res = await fetch("/api/version");
47
- if (res.ok) {
48
- // Check settings for editor preference
49
- const settingsRes = await fetch("/api/settings");
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 res = await fetch(`/api/contacts?q=${encodeURIComponent(token)}`);
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
- fetch("/api/accounts")
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 fetch("/api/draft", {
319
- method: "POST",
320
- headers: { "Content-Type": "application/json" },
321
- body: JSON.stringify({
322
- accountId: getFromAccountId(),
323
- subject: subjectInput.value,
324
- bodyHtml: editor.getHtml(),
325
- bodyText: editor.getText(),
326
- to: toInput.value,
327
- cc: ccInput.value,
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
- const res = await fetch("/api/send", {
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
- fetch("/api/draft", {
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
  }
Binary file
@@ -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;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-client",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "dependencies": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.147",
3
+ "version": "1.0.150",
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,10 +20,10 @@
20
20
  "postinstall": "node bin/postinstall.js"
21
21
  },
22
22
  "dependencies": {
23
- "@bobfrankston/iflow": "^1.0.52",
23
+ "@bobfrankston/iflow": "^1.0.53",
24
24
  "@bobfrankston/miscinfo": "^1.0.7",
25
25
  "@bobfrankston/oauthsupport": "^1.0.20",
26
- "@bobfrankston/msger": "^0.1.199",
26
+ "@bobfrankston/msger": "^0.1.200",
27
27
  "@capacitor/android": "^8.3.0",
28
28
  "@capacitor/cli": "^8.3.0",
29
29
  "@capacitor/core": "^8.3.0",