@bobfrankston/mailx 1.0.92 → 1.0.93

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/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, getSelectedMessages } from "./components/message-list.js";
7
7
  import { showMessage, getCurrentMessage } from "./components/message-viewer.js";
8
- import { connectWebSocket, onWsEvent, triggerSync, getAccounts, getFolders, deleteMessage, undeleteMessage, restartServer, rebuildServer, getSyncPending, getServerUrl, setServerUrl } from "./lib/api-client.js";
8
+ import { connectWebSocket, onWsEvent, triggerSync, getAccounts, getFolders, deleteMessage, undeleteMessage, restartServer, rebuildServer } from "./lib/api-client.js";
9
9
  // ── New message badge (favicon + title) ──
10
10
  let baseTitle = "mailx";
11
11
  let lastSeenCount = 0;
@@ -781,211 +781,37 @@ optEditorTiptap?.addEventListener("change", () => {
781
781
  saveEditorSetting("tiptap");
782
782
  });
783
783
  const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
784
- // Dismiss startup overlay immediately in browser/local mode the UI components
785
- // have their own loading states ("Loading accounts...", etc.). Only keep the
786
- // overlay for native app mode where ensureServer needs to start the server.
787
- if (!isApp) {
788
- const overlay = document.getElementById("startup-overlay");
789
- if (overlay) {
790
- overlay.classList.add("hidden");
791
- setTimeout(() => overlay.remove(), 400);
784
+ fetch("/api/version").then(r => r.json()).then(d => {
785
+ const el = document.getElementById("app-version");
786
+ const storage = d.storage || {};
787
+ const storageLabel = storage.provider && storage.provider !== "local" ? ` [${storage.provider}]` : "";
788
+ if (el)
789
+ el.textContent = `mailx v${d.version}${storageLabel}${isApp ? "" : " [browser]"}`;
790
+ }).catch(async () => {
791
+ // Server not running — try to start it if we're in the app
792
+ const startupStatus = document.getElementById("startup-status");
793
+ if (isApp) {
794
+ if (startupStatus)
795
+ startupStatus.textContent = "Starting server...";
796
+ await mailxapi.ensureServer();
797
+ location.reload();
792
798
  }
793
- }
794
- async function checkServer() {
795
- const base = getServerUrl();
796
- try {
797
- const res = await fetch(`${base}/api/version`, { signal: AbortSignal.timeout(5000) });
798
- const ct = res.headers.get("content-type") || "";
799
- if (ct.includes("text/html") || !res.ok)
800
- throw new Error("not JSON");
801
- const d = await res.json();
799
+ else {
802
800
  const el = document.getElementById("app-version");
803
- const storage = d.storage || { provider: "local", mode: "local" };
804
- const storageLabel = storage.provider === "local" ? "" : ` · ${storage.provider}${storage.mode === "api" ? " (API)" : ""}`;
805
801
  if (el)
806
- el.textContent = `mailx v${d.version}${storageLabel}${isApp ? "" : " · browser"}`;
807
- // Hide startup overlay on success
808
- const overlay = document.getElementById("startup-overlay");
809
- if (overlay) {
810
- overlay.classList.add("hidden");
811
- setTimeout(() => overlay.remove(), 400);
812
- }
802
+ el.textContent = "mailx [server offline]";
803
+ if (startupStatus)
804
+ startupStatus.textContent = "Server offline — start with: node packages/mailx-server/index.js";
813
805
  }
814
- catch {
815
- const startupStatus = document.getElementById("startup-status");
816
- if (isApp && mailxapi?.ensureServer) {
817
- // Desktop native app — start the embedded server
818
- if (startupStatus)
819
- startupStatus.textContent = "Starting server...";
820
- await mailxapi.ensureServer();
821
- location.reload();
822
- }
823
- else if (isApp) {
824
- // MAUI Android app — runs via bridge, no HTTP server
825
- const overlay = document.getElementById("startup-overlay");
826
- const content = overlay?.querySelector(".startup-content");
827
- if (content) {
828
- content.innerHTML = `
829
- <div style="text-align:center;max-width:400px">
830
- <h2 style="margin-bottom:1rem;color:var(--color-text)">mailx</h2>
831
- <p style="color:var(--color-text-muted);font-size:0.9rem" id="bridge-status">
832
- Initializing...
833
- </p>
834
- </div>`;
835
- }
836
- if (overlay)
837
- overlay.hidden = false;
838
- // Initialize local service
839
- try {
840
- const localService = await import("./lib/local-service.js");
841
- const statusEl = document.getElementById("bridge-status");
842
- // Check for saved settings
843
- let savedSettings = localService.getSettings();
844
- if (!savedSettings || !savedSettings.accounts?.length) {
845
- // No settings — prompt for account
846
- if (statusEl)
847
- statusEl.textContent = "No accounts configured";
848
- if (content) {
849
- content.innerHTML = `
850
- <div style="text-align:center;max-width:400px">
851
- <h2 style="margin-bottom:1rem;color:var(--color-text)">mailx — Setup</h2>
852
- <p style="color:var(--color-text-muted);font-size:0.9rem;margin-bottom:1rem">
853
- Enter your email account to get started.
854
- </p>
855
- <input type="email" id="setup-email" placeholder="you@example.com" style="width:100%;padding:0.5rem;border:1px solid var(--color-border);border-radius:4px;background:var(--color-bg);color:var(--color-text);font-size:1rem;margin-bottom:0.5rem">
856
- <input type="text" id="setup-imap" placeholder="IMAP server (e.g. imap.example.com)" style="width:100%;padding:0.5rem;border:1px solid var(--color-border);border-radius:4px;background:var(--color-bg);color:var(--color-text);font-size:1rem;margin-bottom:0.5rem">
857
- <input type="password" id="setup-pass" placeholder="Password" style="width:100%;padding:0.5rem;border:1px solid var(--color-border);border-radius:4px;background:var(--color-bg);color:var(--color-text);font-size:1rem;margin-bottom:0.75rem">
858
- <button id="setup-go" style="padding:0.5rem 1.5rem;background:var(--color-accent);color:#fff;border:none;border-radius:4px;font-size:1rem;cursor:pointer">Connect</button>
859
- <p id="setup-error" style="margin-top:0.75rem;color:oklch(0.65 0.2 25);font-size:0.85rem" hidden></p>
860
- </div>`;
861
- }
862
- document.getElementById("setup-go")?.addEventListener("click", async () => {
863
- const email = document.getElementById("setup-email").value.trim();
864
- const imap = document.getElementById("setup-imap").value.trim();
865
- const pass = document.getElementById("setup-pass").value;
866
- const errEl = document.getElementById("setup-error");
867
- if (!email || !pass) {
868
- errEl.textContent = "Email and password required";
869
- errEl.hidden = false;
870
- return;
871
- }
872
- const domain = email.split("@")[1] || "";
873
- const host = imap || `imap.${domain}`;
874
- const id = domain.replace(/\./g, "");
875
- const newSettings = {
876
- accounts: [{
877
- id, name: email.split("@")[0], email,
878
- imap: { host, port: 993, user: email, password: pass, auth: "password" },
879
- smtp: { host: `smtp.${domain}`, port: 587, user: email, password: pass, auth: "password" },
880
- enabled: true,
881
- }],
882
- ui: { theme: "system" },
883
- sync: { intervalMinutes: 5, historyDays: 100 },
884
- };
885
- errEl.hidden = true;
886
- document.getElementById("setup-go").textContent = "Connecting...";
887
- await localService.initialize(newSettings);
888
- if (overlay)
889
- overlay.hidden = true;
890
- });
891
- }
892
- else {
893
- // Settings exist — initialize and sync
894
- if (statusEl)
895
- statusEl.textContent = "Loading accounts...";
896
- await localService.initialize();
897
- if (overlay)
898
- overlay.hidden = true;
899
- }
900
- }
901
- catch (e) {
902
- const statusEl = document.getElementById("bridge-status");
903
- if (statusEl)
904
- statusEl.textContent = `Error: ${e.message}`;
905
- }
906
- }
907
- else if (getServerUrl()) {
908
- // Remote server configured but unreachable — prompt to change URL
909
- promptForServer();
910
- }
911
- else {
912
- // Local server not responding — dismiss overlay, show UI with offline status
913
- const overlay = document.getElementById("startup-overlay");
914
- if (overlay) {
915
- overlay.classList.add("hidden");
916
- setTimeout(() => overlay.remove(), 400);
917
- }
918
- const statusSync = document.getElementById("status-sync");
919
- if (statusSync) {
920
- statusSync.textContent = "SERVER OFFLINE";
921
- statusSync.style.color = "oklch(0.65 0.2 25)";
922
- }
923
- }
924
- }
925
- }
926
- function promptForServer() {
927
- const overlay = document.getElementById("startup-overlay");
928
- const content = overlay?.querySelector(".startup-content");
929
- if (!content)
930
- return;
931
- const current = getServerUrl();
932
- content.innerHTML = `
933
- <div style="text-align:center;max-width:400px">
934
- <h2 style="margin-bottom:1rem;color:var(--color-text)">Connect to mailx server</h2>
935
- <p style="margin-bottom:1rem;color:var(--color-text-muted);font-size:0.9rem">
936
- Enter the address of your mailx server (running on your desktop or network).
937
- </p>
938
- <input type="text" id="server-url-input" placeholder="http://192.168.1.x:9333"
939
- value="${current}" autocomplete="off"
940
- style="width:100%;padding:0.5rem;border:1px solid var(--color-border);border-radius:var(--radius-sm);
941
- background:var(--color-bg);color:var(--color-text);font-size:1rem;margin-bottom:0.75rem">
942
- <button id="server-url-btn" style="padding:0.5rem 1.5rem;background:var(--color-accent);color:#fff;
943
- border:none;border-radius:var(--radius-sm);font-size:1rem;cursor:pointer">Connect</button>
944
- <p id="server-url-error" style="margin-top:0.75rem;color:oklch(0.65 0.2 25);font-size:0.85rem" hidden></p>
945
- </div>
946
- `;
947
- if (overlay)
948
- overlay.hidden = false;
949
- const input = document.getElementById("server-url-input");
950
- const btn = document.getElementById("server-url-btn");
951
- const err = document.getElementById("server-url-error");
952
- async function tryConnect() {
953
- const url = input.value.trim().replace(/\/$/, "");
954
- if (!url) {
955
- err.textContent = "Enter a server URL";
956
- err.hidden = false;
957
- return;
958
- }
959
- btn.disabled = true;
960
- btn.textContent = "Connecting...";
961
- err.hidden = true;
962
- try {
963
- const res = await fetch(`${url}/api/version`, { signal: AbortSignal.timeout(5000) });
964
- const ct = res.headers.get("content-type") || "";
965
- if (ct.includes("text/html") || !res.ok)
966
- throw new Error("Server returned an error page");
967
- await res.json();
968
- setServerUrl(url);
969
- location.reload();
970
- }
971
- catch (e) {
972
- btn.disabled = false;
973
- btn.textContent = "Connect";
974
- err.textContent = e.message || "Cannot reach server";
975
- err.hidden = false;
976
- }
977
- }
978
- btn.addEventListener("click", tryConnect);
979
- input.addEventListener("keydown", (e) => { if (e.key === "Enter")
980
- tryConnect(); });
981
- input.focus();
982
- }
983
- checkServer();
806
+ });
984
807
  // ── Sync pending indicator + server health check ──
985
808
  let serverDown = false;
986
809
  setInterval(async () => {
987
810
  try {
988
- const data = await getSyncPending();
811
+ const res = await fetch("/api/sync/pending");
812
+ if (!res.ok)
813
+ return;
814
+ const data = await res.json();
989
815
  const el = document.getElementById("status-pending");
990
816
  if (el) {
991
817
  el.textContent = data.pending > 0 ? `↻ ${data.pending} pending` : "";
@@ -1025,8 +851,7 @@ function scheduleMiddnightRefresh() {
1025
851
  }
1026
852
  scheduleMiddnightRefresh();
1027
853
  // ── Apply theme from settings ──
1028
- const svrBase = getServerUrl();
1029
- fetch(`${svrBase}/api/version`).then(r => r.json()).then(d => {
854
+ fetch("/api/version").then(r => r.json()).then(d => {
1030
855
  if (d.theme === "dark")
1031
856
  document.documentElement.classList.add("theme-dark");
1032
857
  else if (d.theme === "light")
@@ -1,14 +1,14 @@
1
1
  /**
2
- * API client — transport-agnostic.
3
- * Automatically selects the right transport:
4
- * - HttpTransport: browser mode (REST + WebSocket)
5
- * - IPC: injected by native launcher (mailxapi global)
6
- * - BridgeTransport: Android (capacitor-nodejs bridge) — future
2
+ * API client — auto-detects IPC (WebView) vs HTTP (browser).
3
+ * When mailxapi is available (injected by launcher), calls go directly via IPC.
4
+ * Otherwise falls back to REST/WebSocket.
7
5
  *
8
6
  * All server operations MUST go through these centralized methods.
9
7
  * Never use fetch("/api/...") directly in components.
10
8
  */
11
- // ── HTTP Transport (browser mode) ──
9
+ const hasIPC = typeof mailxapi !== "undefined" && mailxapi?.isApp;
10
+ // ── HTTP fallback ──
11
+ // Abort controller for message-list requests — cancel stale fetches when folder changes
12
12
  let messageListAbort = null;
13
13
  export function abortMessageListRequests() {
14
14
  if (messageListAbort) {
@@ -21,290 +21,206 @@ function newMessageListSignal() {
21
21
  messageListAbort = new AbortController();
22
22
  return messageListAbort.signal;
23
23
  }
24
- /** Server base URL — empty for same-origin, set by remote config for mobile */
25
- let serverUrl = localStorage.getItem("mailx-server-url") || "";
26
- /** Set the server URL (for mobile/remote mode) */
27
- export function setServerUrl(url) {
28
- serverUrl = url.replace(/\/$/, "");
29
- localStorage.setItem("mailx-server-url", serverUrl);
30
- }
31
- /** Get the configured server URL */
32
- export function getServerUrl() { return serverUrl; }
33
- class HttpTransport {
34
- handlers = [];
35
- async call(method, params) {
36
- const route = httpRoutes[method];
37
- if (!route)
38
- throw new Error(`Unknown method: ${method}`);
39
- const { path, options } = route(params);
40
- const base = serverUrl ? serverUrl : "";
41
- let res;
42
- try {
43
- // Default 10s timeout prevents hangs when server is zombie (accepts TCP, never responds)
44
- const fetchOptions = {
45
- headers: { "Content-Type": "application/json" },
46
- ...options,
47
- };
48
- if (!fetchOptions.signal) {
49
- fetchOptions.signal = AbortSignal.timeout(10000);
50
- }
51
- res = await fetch(`${base}/api${path}`, fetchOptions);
52
- }
53
- catch (e) {
54
- if (e.name === "AbortError")
55
- throw e;
56
- if (e.name === "TimeoutError")
57
- throw new Error("Server not responding — request timed out");
58
- throw new Error("Server offline — check connection or run: mailx -server");
59
- }
60
- // Detect HTML error responses (server not reachable, returns error page)
61
- const ct = res.headers.get("content-type") || "";
62
- if (ct.includes("text/html")) {
63
- throw new Error("Server returned HTML instead of JSON — check server URL");
64
- }
65
- if (!res.ok) {
66
- const err = await res.json().catch(() => ({ error: res.statusText }));
67
- throw new Error(err.error || res.statusText);
68
- }
69
- return res.json();
70
- }
71
- onEvent(handler) {
72
- this.handlers.push(handler);
73
- }
74
- connect() {
75
- let wsUrl;
76
- if (serverUrl) {
77
- // Remote server — derive WS URL from HTTP URL
78
- const u = new URL(serverUrl);
79
- wsUrl = `${u.protocol === "https:" ? "wss:" : "ws:"}//${u.host}`;
80
- }
81
- else {
82
- wsUrl = `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}`;
83
- }
84
- const ws = new WebSocket(wsUrl);
85
- ws.onmessage = (ev) => {
86
- try {
87
- const event = JSON.parse(ev.data);
88
- for (const h of this.handlers)
89
- h(event);
90
- }
91
- catch { /* ignore */ }
92
- };
93
- ws.onclose = () => { setTimeout(() => this.connect(), 3000); };
94
- }
95
- }
96
- class IpcTransport {
97
- async call(method, params) {
98
- // IPC methods are direct function calls on the injected mailxapi object
99
- const p = params || {};
100
- switch (method) {
101
- case "getAccounts": return mailxapi.getAccounts();
102
- case "getFolders": return mailxapi.getFolders(p.accountId);
103
- case "getMessages": return mailxapi.getMessages(p.accountId, p.folderId, p.page, p.pageSize);
104
- case "getUnifiedInbox": return mailxapi.getUnifiedInbox(p.page, p.pageSize);
105
- case "searchMessages": return mailxapi.searchMessages(p.query, p.page, p.pageSize);
106
- case "getMessage": return mailxapi.getMessage(p.accountId, p.uid, p.allowRemote, p.folderId);
107
- case "updateFlags": return mailxapi.updateFlags(p.accountId, p.uid, p.flags);
108
- case "triggerSync": return mailxapi.syncAll();
109
- case "getSyncPending": return mailxapi.getSyncPending();
110
- case "searchContacts": return mailxapi.searchContacts(p.query);
111
- case "allowRemoteContent": return mailxapi.allowRemoteContent(p.type, p.value);
112
- case "deleteMessage": return mailxapi.deleteMessage(p.accountId, p.uid);
113
- case "undeleteMessage": return mailxapi.undeleteMessage(p.accountId, p.uid, p.folderId);
114
- case "moveMessage": return mailxapi.moveMessage(p.accountId, p.uid, p.targetFolderId, p.targetAccountId);
115
- case "restartServer": return mailxapi.restart?.();
116
- case "markFolderRead": return mailxapi.markFolderRead?.(p.accountId, p.folderId);
117
- case "createFolder": return mailxapi.createFolder?.(p.accountId, p.parentPath, p.name);
118
- case "renameFolder": return mailxapi.renameFolder?.(p.accountId, p.folderId, p.newName);
119
- case "deleteFolder": return mailxapi.deleteFolder?.(p.accountId, p.folderId);
120
- case "emptyFolder": return mailxapi.emptyFolder?.(p.accountId, p.folderId);
121
- case "sendMessage": return mailxapi.sendMessage?.(p.body);
122
- case "saveDraft": return mailxapi.saveDraft?.(p.body);
123
- default: throw new Error(`Unknown IPC method: ${method}`);
124
- }
125
- }
126
- onEvent(handler) {
127
- mailxapi.onEvent(handler);
128
- }
129
- connect() {
130
- // IPC events are push-based — no connection needed
131
- }
132
- }
133
- const httpRoutes = {
134
- getAccounts: () => ({ path: "/accounts" }),
135
- getFolders: (p) => ({ path: `/folders/${p.accountId}` }),
136
- getMessages: (p) => ({
137
- path: `/messages/${p.accountId}/${p.folderId}?page=${p.page || 1}&pageSize=${p.pageSize || 50}`,
138
- options: { signal: newMessageListSignal() }
139
- }),
140
- getUnifiedInbox: (p) => ({
141
- path: `/messages/unified/inbox?page=${p.page || 1}&pageSize=${p.pageSize || 50}`,
142
- options: { signal: newMessageListSignal() }
143
- }),
144
- searchMessages: (p) => {
145
- const params = new URLSearchParams({ q: p.query, page: String(p.page || 1), pageSize: String(p.pageSize || 50), scope: p.scope || "all" });
146
- if ((p.scope === "current" || p.scope === "server") && p.accountId) {
147
- params.set("accountId", p.accountId);
148
- params.set("folderId", String(p.folderId));
149
- }
150
- return { path: `/search?${params}` };
151
- },
152
- getMessage: (p) => {
153
- const params = new URLSearchParams();
154
- if (p.allowRemote)
155
- params.set("allowRemote", "true");
156
- if (p.folderId != null)
157
- params.set("folderId", String(p.folderId));
158
- const q = params.toString() ? `?${params}` : "";
159
- return { path: `/message/${p.accountId}/${p.uid}${q}` };
160
- },
161
- updateFlags: (p) => ({
162
- path: `/message/${p.accountId}/${p.uid}/flags`,
163
- options: { method: "PATCH", body: JSON.stringify({ flags: p.flags }) }
164
- }),
165
- triggerSync: () => ({ path: "/sync", options: { method: "POST" } }),
166
- getSyncPending: () => ({ path: "/sync/pending" }),
167
- searchContacts: (p) => ({ path: `/contacts?q=${encodeURIComponent(p.query)}` }),
168
- allowRemoteContent: (p) => ({
169
- path: "/settings/allow-remote",
170
- options: { method: "POST", body: JSON.stringify({ type: p.type, value: p.value }) }
171
- }),
172
- deleteMessage: (p) => ({ path: `/message/${p.accountId}/${p.uid}`, options: { method: "DELETE" } }),
173
- undeleteMessage: (p) => ({
174
- path: `/message/${p.accountId}/${p.uid}/undelete`,
175
- options: { method: "POST", body: JSON.stringify({ folderId: p.folderId }) }
176
- }),
177
- moveMessage: (p) => {
178
- const body = { targetFolderId: p.targetFolderId };
179
- if (p.targetAccountId)
180
- body.targetAccountId = p.targetAccountId;
181
- return { path: `/message/${p.accountId}/${p.uid}/move`, options: { method: "POST", body: JSON.stringify(body) } };
182
- },
183
- restartServer: () => ({ path: "/restart", options: { method: "POST" } }),
184
- rebuildServer: () => ({ path: "/rebuild", options: { method: "POST" } }),
185
- markFolderRead: (p) => ({ path: `/folder/${p.accountId}/${p.folderId}/mark-read`, options: { method: "POST" } }),
186
- createFolder: (p) => ({
187
- path: `/folder/${p.accountId}`,
188
- options: { method: "POST", body: JSON.stringify({ parentPath: p.parentPath, name: p.name }) }
189
- }),
190
- renameFolder: (p) => ({
191
- path: `/folder/${p.accountId}/${p.folderId}/rename`,
192
- options: { method: "POST", body: JSON.stringify({ newName: p.newName }) }
193
- }),
194
- deleteFolder: (p) => ({ path: `/folder/${p.accountId}/${p.folderId}`, options: { method: "DELETE" } }),
195
- emptyFolder: (p) => ({ path: `/folder/${p.accountId}/${p.folderId}/empty`, options: { method: "POST" } }),
196
- sendMessage: (p) => ({ path: "/send", options: { method: "POST", body: JSON.stringify(p.body) } }),
197
- saveDraft: (p) => ({ path: "/draft", options: { method: "POST", body: JSON.stringify(p.body) } }),
198
- };
199
- // ── Local Transport (MAUI Android — runs IMAP in WebView via bridge) ──
200
- class LocalTransport {
201
- handlers = [];
202
- localService = null;
203
- async call(method, params) {
204
- if (!this.localService) {
205
- this.localService = await import("./local-service.js");
206
- }
207
- return this.localService.handleCall(method, params || {});
24
+ async function api(path, options) {
25
+ let res;
26
+ try {
27
+ res = await fetch(`/api${path}`, {
28
+ headers: { "Content-Type": "application/json" },
29
+ ...options
30
+ });
208
31
  }
209
- onEvent(handler) {
210
- this.handlers.push(handler);
32
+ catch (e) {
33
+ // Network error — server is down
34
+ if (e.name === "AbortError")
35
+ throw e;
36
+ throw new Error("Server offline — run: mailx -server");
211
37
  }
212
- async connect() {
213
- if (!this.localService) {
214
- this.localService = await import("./local-service.js");
215
- }
216
- // Forward events from local service to transport handlers
217
- this.localService.onEvent((event) => {
218
- for (const h of this.handlers)
219
- h(event);
220
- });
38
+ if (!res.ok) {
39
+ const err = await res.json().catch(() => ({ error: res.statusText }));
40
+ throw new Error(err.error || res.statusText);
221
41
  }
42
+ return res.json();
222
43
  }
223
- // ── Select transport ──
224
- const hasIPC = typeof mailxapi !== "undefined" && mailxapi?.isApp;
225
- const hasBridge = typeof mailxapi !== "undefined" && mailxapi?.tcp !== undefined;
226
- const hasEnsureServer = typeof mailxapi !== "undefined" && mailxapi?.ensureServer !== undefined;
227
- // MAUI bridge (has tcp but no ensureServer) → LocalTransport
228
- // Desktop native (has ensureServer) → IpcTransport
229
- // Browser → HttpTransport
230
- const transport = hasBridge && !hasEnsureServer
231
- ? new LocalTransport()
232
- : hasIPC ? new IpcTransport() : new HttpTransport();
233
- // ── Public API (unchanged signatures for all callers) ──
44
+ // ── API Methods (IPC or HTTP) ──
234
45
  export function getAccounts() {
235
- return transport.call("getAccounts");
46
+ if (hasIPC)
47
+ return mailxapi.getAccounts();
48
+ return api("/accounts");
236
49
  }
237
50
  export function getFolders(accountId) {
238
- return transport.call("getFolders", { accountId });
51
+ if (hasIPC)
52
+ return mailxapi.getFolders(accountId);
53
+ return api(`/folders/${accountId}`);
239
54
  }
240
55
  export function getMessages(accountId, folderId, page = 1, pageSize = 50) {
241
- return transport.call("getMessages", { accountId, folderId, page, pageSize });
56
+ if (hasIPC)
57
+ return mailxapi.getMessages(accountId, folderId, page, pageSize);
58
+ const signal = newMessageListSignal();
59
+ return api(`/messages/${accountId}/${folderId}?page=${page}&pageSize=${pageSize}`, { signal });
242
60
  }
243
61
  export function getUnifiedInbox(page = 1, pageSize = 50) {
244
- return transport.call("getUnifiedInbox", { page, pageSize });
62
+ if (hasIPC)
63
+ return mailxapi.getUnifiedInbox(page, pageSize);
64
+ const signal = newMessageListSignal();
65
+ return api(`/messages/unified/inbox?page=${page}&pageSize=${pageSize}`, { signal });
245
66
  }
246
67
  export function searchMessages(query, page = 1, pageSize = 50, scope = "all", accountId = "", folderId = 0) {
247
- return transport.call("searchMessages", { query, page, pageSize, scope, accountId, folderId });
68
+ if (hasIPC)
69
+ return mailxapi.searchMessages(query, page, pageSize);
70
+ const params = new URLSearchParams({ q: query, page: String(page), pageSize: String(pageSize), scope });
71
+ if (scope === "current" && accountId) {
72
+ params.set("accountId", accountId);
73
+ params.set("folderId", String(folderId));
74
+ }
75
+ if (scope === "server" && accountId) {
76
+ params.set("accountId", accountId);
77
+ params.set("folderId", String(folderId));
78
+ }
79
+ return api(`/search?${params}`);
248
80
  }
249
81
  export function getMessage(accountId, uid, allowRemote = false, folderId) {
250
- return transport.call("getMessage", { accountId, uid, allowRemote, folderId });
82
+ if (hasIPC)
83
+ return mailxapi.getMessage(accountId, uid, allowRemote, folderId);
84
+ const params = new URLSearchParams();
85
+ if (allowRemote)
86
+ params.set("allowRemote", "true");
87
+ if (folderId != null)
88
+ params.set("folderId", String(folderId));
89
+ const q = params.toString() ? `?${params}` : "";
90
+ return api(`/message/${accountId}/${uid}${q}`);
251
91
  }
252
92
  export function updateFlags(accountId, uid, flags) {
253
- return transport.call("updateFlags", { accountId, uid, flags });
93
+ if (hasIPC)
94
+ return mailxapi.updateFlags(accountId, uid, flags);
95
+ return api(`/message/${accountId}/${uid}/flags`, {
96
+ method: "PATCH",
97
+ body: JSON.stringify({ flags })
98
+ });
254
99
  }
255
100
  export function triggerSync() {
256
- return transport.call("triggerSync");
101
+ if (hasIPC)
102
+ return mailxapi.syncAll();
103
+ return api("/sync", { method: "POST" });
257
104
  }
258
105
  export function getSyncPending() {
259
- return transport.call("getSyncPending");
106
+ if (hasIPC)
107
+ return mailxapi.getSyncPending();
108
+ return api("/sync/pending");
260
109
  }
261
110
  export function searchContacts(query) {
262
- return transport.call("searchContacts", { query });
111
+ if (hasIPC)
112
+ return mailxapi.searchContacts(query);
113
+ return api(`/contacts?q=${encodeURIComponent(query)}`);
263
114
  }
264
115
  export function allowRemoteContent(type, value) {
265
- return transport.call("allowRemoteContent", { type, value });
116
+ if (hasIPC)
117
+ return mailxapi.allowRemoteContent(type, value);
118
+ return api("/settings/allow-remote", {
119
+ method: "POST",
120
+ body: JSON.stringify({ type, value })
121
+ });
266
122
  }
267
123
  export function deleteMessage(accountId, uid) {
268
- return transport.call("deleteMessage", { accountId, uid });
124
+ if (hasIPC)
125
+ return mailxapi.deleteMessage?.(accountId, uid);
126
+ return api(`/message/${accountId}/${uid}`, { method: "DELETE" });
269
127
  }
270
128
  export function undeleteMessage(accountId, uid, folderId) {
271
- return transport.call("undeleteMessage", { accountId, uid, folderId });
129
+ if (hasIPC)
130
+ return mailxapi.undeleteMessage?.(accountId, uid, folderId);
131
+ return api(`/message/${accountId}/${uid}/undelete`, {
132
+ method: "POST",
133
+ body: JSON.stringify({ folderId })
134
+ });
272
135
  }
273
136
  export function moveMessage(accountId, uid, targetFolderId, targetAccountId) {
274
- return transport.call("moveMessage", { accountId, uid, targetFolderId, targetAccountId });
137
+ if (hasIPC)
138
+ return mailxapi.moveMessage?.(accountId, uid, targetFolderId, targetAccountId);
139
+ const body = { targetFolderId };
140
+ if (targetAccountId)
141
+ body.targetAccountId = targetAccountId;
142
+ return api(`/message/${accountId}/${uid}/move`, {
143
+ method: "POST",
144
+ body: JSON.stringify(body)
145
+ });
275
146
  }
276
147
  export function restartServer() {
277
- return transport.call("restartServer").catch(() => { });
148
+ if (hasIPC)
149
+ return mailxapi.restart?.();
150
+ return api("/restart", { method: "POST" }).catch(() => { });
278
151
  }
279
152
  export function rebuildServer() {
280
- return transport.call("rebuildServer").catch(() => { });
153
+ return api("/rebuild", { method: "POST" }).catch(() => { });
281
154
  }
282
155
  export function markFolderRead(accountId, folderId) {
283
- return transport.call("markFolderRead", { accountId, folderId });
156
+ if (hasIPC)
157
+ return mailxapi.markFolderRead?.(accountId, folderId);
158
+ return api(`/folder/${accountId}/${folderId}/mark-read`, { method: "POST" });
284
159
  }
285
160
  export function createFolder(accountId, parentPath, name) {
286
- return transport.call("createFolder", { accountId, parentPath, name });
161
+ if (hasIPC)
162
+ return mailxapi.createFolder?.(accountId, parentPath, name);
163
+ return api(`/folder/${accountId}`, {
164
+ method: "POST",
165
+ body: JSON.stringify({ parentPath, name })
166
+ });
287
167
  }
288
168
  export function renameFolder(accountId, folderId, newName) {
289
- return transport.call("renameFolder", { accountId, folderId, newName });
169
+ if (hasIPC)
170
+ return mailxapi.renameFolder?.(accountId, folderId, newName);
171
+ return api(`/folder/${accountId}/${folderId}/rename`, {
172
+ method: "POST",
173
+ body: JSON.stringify({ newName })
174
+ });
290
175
  }
291
176
  export function deleteFolder(accountId, folderId) {
292
- return transport.call("deleteFolder", { accountId, folderId });
177
+ if (hasIPC)
178
+ return mailxapi.deleteFolder?.(accountId, folderId);
179
+ return api(`/folder/${accountId}/${folderId}`, { method: "DELETE" });
293
180
  }
294
181
  export function emptyFolder(accountId, folderId) {
295
- return transport.call("emptyFolder", { accountId, folderId });
182
+ if (hasIPC)
183
+ return mailxapi.emptyFolder?.(accountId, folderId);
184
+ return api(`/folder/${accountId}/${folderId}/empty`, { method: "POST" });
296
185
  }
297
186
  export function sendMessage(body) {
298
- return transport.call("sendMessage", { body });
187
+ if (hasIPC)
188
+ return mailxapi.sendMessage?.(body);
189
+ return api("/send", { method: "POST", body: JSON.stringify(body) });
299
190
  }
300
191
  export function saveDraft(body) {
301
- return transport.call("saveDraft", { body });
192
+ if (hasIPC)
193
+ return mailxapi.saveDraft?.(body);
194
+ return api("/draft", { method: "POST", body: JSON.stringify(body) });
302
195
  }
196
+ const eventHandlers = [];
303
197
  export function onEvent(handler) {
304
- transport.onEvent(handler);
198
+ eventHandlers.push(handler);
305
199
  }
306
200
  export function connectEvents() {
307
- transport.connect();
201
+ if (hasIPC) {
202
+ // IPC events come via mailxapi.onEvent
203
+ mailxapi.onEvent((event) => {
204
+ for (const h of eventHandlers)
205
+ h(event);
206
+ });
207
+ }
208
+ else {
209
+ // WebSocket for HTTP mode
210
+ const protocol = location.protocol === "https:" ? "wss:" : "ws:";
211
+ const ws = new WebSocket(`${protocol}//${location.host}`);
212
+ ws.onmessage = (ev) => {
213
+ try {
214
+ const event = JSON.parse(ev.data);
215
+ for (const h of eventHandlers)
216
+ h(event);
217
+ }
218
+ catch { /* ignore */ }
219
+ };
220
+ ws.onclose = () => {
221
+ setTimeout(connectEvents, 3000);
222
+ };
223
+ }
308
224
  }
309
225
  // Legacy exports for backward compatibility
310
226
  export const connectWebSocket = connectEvents;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.92",
3
+ "version": "1.0.93",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",