@bobfrankston/mailx 1.0.66 → 1.0.69

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.
Files changed (59) hide show
  1. package/android/app/build/.npmkeep +0 -0
  2. package/android/app/build.gradle +54 -0
  3. package/android/app/capacitor.build.gradle +19 -0
  4. package/android/app/proguard-rules.pro +21 -0
  5. package/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java +26 -0
  6. package/android/app/src/main/AndroidManifest.xml +41 -0
  7. package/android/app/src/main/java/com/frankston/mailx/MainActivity.java +5 -0
  8. package/android/app/src/main/res/drawable/ic_launcher_background.xml +170 -0
  9. package/android/app/src/main/res/drawable/splash.png +0 -0
  10. package/android/app/src/main/res/drawable-land-hdpi/splash.png +0 -0
  11. package/android/app/src/main/res/drawable-land-mdpi/splash.png +0 -0
  12. package/android/app/src/main/res/drawable-land-xhdpi/splash.png +0 -0
  13. package/android/app/src/main/res/drawable-land-xxhdpi/splash.png +0 -0
  14. package/android/app/src/main/res/drawable-land-xxxhdpi/splash.png +0 -0
  15. package/android/app/src/main/res/drawable-port-hdpi/splash.png +0 -0
  16. package/android/app/src/main/res/drawable-port-mdpi/splash.png +0 -0
  17. package/android/app/src/main/res/drawable-port-xhdpi/splash.png +0 -0
  18. package/android/app/src/main/res/drawable-port-xxhdpi/splash.png +0 -0
  19. package/android/app/src/main/res/drawable-port-xxxhdpi/splash.png +0 -0
  20. package/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +34 -0
  21. package/android/app/src/main/res/layout/activity_main.xml +12 -0
  22. package/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 -0
  23. package/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +5 -0
  24. package/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  25. package/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png +0 -0
  26. package/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png +0 -0
  27. package/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  28. package/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png +0 -0
  29. package/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png +0 -0
  30. package/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  31. package/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
  32. package/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png +0 -0
  33. package/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  34. package/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
  35. package/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png +0 -0
  36. package/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  37. package/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
  38. package/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
  39. package/android/app/src/main/res/values/ic_launcher_background.xml +4 -0
  40. package/android/app/src/main/res/values/strings.xml +7 -0
  41. package/android/app/src/main/res/values/styles.xml +22 -0
  42. package/android/app/src/main/res/xml/file_paths.xml +5 -0
  43. package/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java +18 -0
  44. package/android/build.gradle +29 -0
  45. package/android/capacitor.settings.gradle +3 -0
  46. package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  47. package/android/gradle/wrapper/gradle-wrapper.properties +7 -0
  48. package/android/gradle.properties +23 -0
  49. package/android/gradlew +251 -0
  50. package/android/gradlew.bat +94 -0
  51. package/android/settings.gradle +5 -0
  52. package/android/variables.gradle +16 -0
  53. package/client/app.js +90 -32
  54. package/client/lib/api-client.js +203 -164
  55. package/client/styles/layout.css +31 -0
  56. package/download/apks/mailx-debug.apk +0 -0
  57. package/download/index.html +118 -0
  58. package/download/versions.json +19 -0
  59. package/package.json +17 -11
@@ -1,11 +1,14 @@
1
1
  /**
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.
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
7
+ *
8
+ * All server operations MUST go through these centralized methods.
9
+ * Never use fetch("/api/...") directly in components.
5
10
  */
6
- const hasIPC = typeof mailxapi !== "undefined" && mailxapi?.isApp;
7
- // ── HTTP fallback ──
8
- // Abort controller for message-list requests — cancel stale fetches when folder changes
11
+ // ── HTTP Transport (browser mode) ──
9
12
  let messageListAbort = null;
10
13
  export function abortMessageListRequests() {
11
14
  if (messageListAbort) {
@@ -18,216 +21,252 @@ function newMessageListSignal() {
18
21
  messageListAbort = new AbortController();
19
22
  return messageListAbort.signal;
20
23
  }
21
- async function api(path, options) {
22
- let res;
23
- try {
24
- res = await fetch(`/api${path}`, {
25
- headers: { "Content-Type": "application/json" },
26
- ...options
27
- });
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
+ res = await fetch(`${base}/api${path}`, {
44
+ headers: { "Content-Type": "application/json" },
45
+ ...options,
46
+ });
47
+ }
48
+ catch (e) {
49
+ if (e.name === "AbortError")
50
+ throw e;
51
+ throw new Error("Server offline — check connection or run: mailx -server");
52
+ }
53
+ // Detect HTML error responses (server not reachable, returns error page)
54
+ const ct = res.headers.get("content-type") || "";
55
+ if (ct.includes("text/html")) {
56
+ throw new Error("Server returned HTML instead of JSON — check server URL");
57
+ }
58
+ if (!res.ok) {
59
+ const err = await res.json().catch(() => ({ error: res.statusText }));
60
+ throw new Error(err.error || res.statusText);
61
+ }
62
+ return res.json();
63
+ }
64
+ onEvent(handler) {
65
+ this.handlers.push(handler);
66
+ }
67
+ connect() {
68
+ let wsUrl;
69
+ if (serverUrl) {
70
+ // Remote server — derive WS URL from HTTP URL
71
+ const u = new URL(serverUrl);
72
+ wsUrl = `${u.protocol === "https:" ? "wss:" : "ws:"}//${u.host}`;
73
+ }
74
+ else {
75
+ wsUrl = `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}`;
76
+ }
77
+ const ws = new WebSocket(wsUrl);
78
+ ws.onmessage = (ev) => {
79
+ try {
80
+ const event = JSON.parse(ev.data);
81
+ for (const h of this.handlers)
82
+ h(event);
83
+ }
84
+ catch { /* ignore */ }
85
+ };
86
+ ws.onclose = () => { setTimeout(() => this.connect(), 3000); };
87
+ }
88
+ }
89
+ class IpcTransport {
90
+ async call(method, params) {
91
+ // IPC methods are direct function calls on the injected mailxapi object
92
+ const p = params || {};
93
+ switch (method) {
94
+ case "getAccounts": return mailxapi.getAccounts();
95
+ case "getFolders": return mailxapi.getFolders(p.accountId);
96
+ case "getMessages": return mailxapi.getMessages(p.accountId, p.folderId, p.page, p.pageSize);
97
+ case "getUnifiedInbox": return mailxapi.getUnifiedInbox(p.page, p.pageSize);
98
+ case "searchMessages": return mailxapi.searchMessages(p.query, p.page, p.pageSize);
99
+ case "getMessage": return mailxapi.getMessage(p.accountId, p.uid, p.allowRemote, p.folderId);
100
+ case "updateFlags": return mailxapi.updateFlags(p.accountId, p.uid, p.flags);
101
+ case "triggerSync": return mailxapi.syncAll();
102
+ case "getSyncPending": return mailxapi.getSyncPending();
103
+ case "searchContacts": return mailxapi.searchContacts(p.query);
104
+ case "allowRemoteContent": return mailxapi.allowRemoteContent(p.type, p.value);
105
+ case "deleteMessage": return mailxapi.deleteMessage(p.accountId, p.uid);
106
+ case "undeleteMessage": return mailxapi.undeleteMessage(p.accountId, p.uid, p.folderId);
107
+ case "moveMessage": return mailxapi.moveMessage(p.accountId, p.uid, p.targetFolderId, p.targetAccountId);
108
+ case "restartServer": return mailxapi.restart?.();
109
+ case "markFolderRead": return mailxapi.markFolderRead?.(p.accountId, p.folderId);
110
+ case "createFolder": return mailxapi.createFolder?.(p.accountId, p.parentPath, p.name);
111
+ case "renameFolder": return mailxapi.renameFolder?.(p.accountId, p.folderId, p.newName);
112
+ case "deleteFolder": return mailxapi.deleteFolder?.(p.accountId, p.folderId);
113
+ case "emptyFolder": return mailxapi.emptyFolder?.(p.accountId, p.folderId);
114
+ case "sendMessage": return mailxapi.sendMessage?.(p.body);
115
+ case "saveDraft": return mailxapi.saveDraft?.(p.body);
116
+ default: throw new Error(`Unknown IPC method: ${method}`);
117
+ }
28
118
  }
29
- catch (e) {
30
- // Network error — server is down
31
- if (e.name === "AbortError")
32
- throw e;
33
- throw new Error("Server offline — run: mailx -server");
119
+ onEvent(handler) {
120
+ mailxapi.onEvent(handler);
34
121
  }
35
- if (!res.ok) {
36
- const err = await res.json().catch(() => ({ error: res.statusText }));
37
- throw new Error(err.error || res.statusText);
122
+ connect() {
123
+ // IPC events are push-based no connection needed
38
124
  }
39
- return res.json();
40
125
  }
41
- // ── API Methods (IPC or HTTP) ──
126
+ const httpRoutes = {
127
+ getAccounts: () => ({ path: "/accounts" }),
128
+ getFolders: (p) => ({ path: `/folders/${p.accountId}` }),
129
+ getMessages: (p) => ({
130
+ path: `/messages/${p.accountId}/${p.folderId}?page=${p.page || 1}&pageSize=${p.pageSize || 50}`,
131
+ options: { signal: newMessageListSignal() }
132
+ }),
133
+ getUnifiedInbox: (p) => ({
134
+ path: `/messages/unified/inbox?page=${p.page || 1}&pageSize=${p.pageSize || 50}`,
135
+ options: { signal: newMessageListSignal() }
136
+ }),
137
+ searchMessages: (p) => {
138
+ const params = new URLSearchParams({ q: p.query, page: String(p.page || 1), pageSize: String(p.pageSize || 50), scope: p.scope || "all" });
139
+ if ((p.scope === "current" || p.scope === "server") && p.accountId) {
140
+ params.set("accountId", p.accountId);
141
+ params.set("folderId", String(p.folderId));
142
+ }
143
+ return { path: `/search?${params}` };
144
+ },
145
+ getMessage: (p) => {
146
+ const params = new URLSearchParams();
147
+ if (p.allowRemote)
148
+ params.set("allowRemote", "true");
149
+ if (p.folderId != null)
150
+ params.set("folderId", String(p.folderId));
151
+ const q = params.toString() ? `?${params}` : "";
152
+ return { path: `/message/${p.accountId}/${p.uid}${q}` };
153
+ },
154
+ updateFlags: (p) => ({
155
+ path: `/message/${p.accountId}/${p.uid}/flags`,
156
+ options: { method: "PATCH", body: JSON.stringify({ flags: p.flags }) }
157
+ }),
158
+ triggerSync: () => ({ path: "/sync", options: { method: "POST" } }),
159
+ getSyncPending: () => ({ path: "/sync/pending" }),
160
+ searchContacts: (p) => ({ path: `/contacts?q=${encodeURIComponent(p.query)}` }),
161
+ allowRemoteContent: (p) => ({
162
+ path: "/settings/allow-remote",
163
+ options: { method: "POST", body: JSON.stringify({ type: p.type, value: p.value }) }
164
+ }),
165
+ deleteMessage: (p) => ({ path: `/message/${p.accountId}/${p.uid}`, options: { method: "DELETE" } }),
166
+ undeleteMessage: (p) => ({
167
+ path: `/message/${p.accountId}/${p.uid}/undelete`,
168
+ options: { method: "POST", body: JSON.stringify({ folderId: p.folderId }) }
169
+ }),
170
+ moveMessage: (p) => {
171
+ const body = { targetFolderId: p.targetFolderId };
172
+ if (p.targetAccountId)
173
+ body.targetAccountId = p.targetAccountId;
174
+ return { path: `/message/${p.accountId}/${p.uid}/move`, options: { method: "POST", body: JSON.stringify(body) } };
175
+ },
176
+ restartServer: () => ({ path: "/restart", options: { method: "POST" } }),
177
+ rebuildServer: () => ({ path: "/rebuild", options: { method: "POST" } }),
178
+ markFolderRead: (p) => ({ path: `/folder/${p.accountId}/${p.folderId}/mark-read`, options: { method: "POST" } }),
179
+ createFolder: (p) => ({
180
+ path: `/folder/${p.accountId}`,
181
+ options: { method: "POST", body: JSON.stringify({ parentPath: p.parentPath, name: p.name }) }
182
+ }),
183
+ renameFolder: (p) => ({
184
+ path: `/folder/${p.accountId}/${p.folderId}/rename`,
185
+ options: { method: "POST", body: JSON.stringify({ newName: p.newName }) }
186
+ }),
187
+ deleteFolder: (p) => ({ path: `/folder/${p.accountId}/${p.folderId}`, options: { method: "DELETE" } }),
188
+ emptyFolder: (p) => ({ path: `/folder/${p.accountId}/${p.folderId}/empty`, options: { method: "POST" } }),
189
+ sendMessage: (p) => ({ path: "/send", options: { method: "POST", body: JSON.stringify(p.body) } }),
190
+ saveDraft: (p) => ({ path: "/draft", options: { method: "POST", body: JSON.stringify(p.body) } }),
191
+ };
192
+ // ── Select transport ──
193
+ const hasIPC = typeof mailxapi !== "undefined" && mailxapi?.isApp;
194
+ const transport = hasIPC ? new IpcTransport() : new HttpTransport();
195
+ // ── Public API (unchanged signatures for all callers) ──
42
196
  export function getAccounts() {
43
- if (hasIPC)
44
- return mailxapi.getAccounts();
45
- return api("/accounts");
197
+ return transport.call("getAccounts");
46
198
  }
47
199
  export function getFolders(accountId) {
48
- if (hasIPC)
49
- return mailxapi.getFolders(accountId);
50
- return api(`/folders/${accountId}`);
200
+ return transport.call("getFolders", { accountId });
51
201
  }
52
202
  export function getMessages(accountId, folderId, page = 1, pageSize = 50) {
53
- if (hasIPC)
54
- return mailxapi.getMessages(accountId, folderId, page, pageSize);
55
- const signal = newMessageListSignal();
56
- return api(`/messages/${accountId}/${folderId}?page=${page}&pageSize=${pageSize}`, { signal });
203
+ return transport.call("getMessages", { accountId, folderId, page, pageSize });
57
204
  }
58
205
  export function getUnifiedInbox(page = 1, pageSize = 50) {
59
- if (hasIPC)
60
- return mailxapi.getUnifiedInbox(page, pageSize);
61
- const signal = newMessageListSignal();
62
- return api(`/messages/unified/inbox?page=${page}&pageSize=${pageSize}`, { signal });
206
+ return transport.call("getUnifiedInbox", { page, pageSize });
63
207
  }
64
208
  export function searchMessages(query, page = 1, pageSize = 50, scope = "all", accountId = "", folderId = 0) {
65
- if (hasIPC)
66
- return mailxapi.searchMessages(query, page, pageSize);
67
- const params = new URLSearchParams({ q: query, page: String(page), pageSize: String(pageSize), scope });
68
- if (scope === "current" && accountId) {
69
- params.set("accountId", accountId);
70
- params.set("folderId", String(folderId));
71
- }
72
- if (scope === "server" && accountId) {
73
- params.set("accountId", accountId);
74
- params.set("folderId", String(folderId));
75
- }
76
- return api(`/search?${params}`);
209
+ return transport.call("searchMessages", { query, page, pageSize, scope, accountId, folderId });
77
210
  }
78
211
  export function getMessage(accountId, uid, allowRemote = false, folderId) {
79
- if (hasIPC)
80
- return mailxapi.getMessage(accountId, uid, allowRemote, folderId);
81
- const params = new URLSearchParams();
82
- if (allowRemote)
83
- params.set("allowRemote", "true");
84
- if (folderId != null)
85
- params.set("folderId", String(folderId));
86
- const q = params.toString() ? `?${params}` : "";
87
- return api(`/message/${accountId}/${uid}${q}`);
212
+ return transport.call("getMessage", { accountId, uid, allowRemote, folderId });
88
213
  }
89
214
  export function updateFlags(accountId, uid, flags) {
90
- if (hasIPC)
91
- return mailxapi.updateFlags(accountId, uid, flags);
92
- return api(`/message/${accountId}/${uid}/flags`, {
93
- method: "PATCH",
94
- body: JSON.stringify({ flags })
95
- });
215
+ return transport.call("updateFlags", { accountId, uid, flags });
96
216
  }
97
217
  export function triggerSync() {
98
- if (hasIPC)
99
- return mailxapi.syncAll();
100
- return api("/sync", { method: "POST" });
101
- }
102
- export function getVersion() {
103
- if (hasIPC)
104
- return mailxapi.getVersion();
105
- return api("/version");
218
+ return transport.call("triggerSync");
106
219
  }
107
220
  export function getSyncPending() {
108
- if (hasIPC)
109
- return mailxapi.getSyncPending();
110
- return api("/sync/pending");
221
+ return transport.call("getSyncPending");
111
222
  }
112
223
  export function searchContacts(query) {
113
- if (hasIPC)
114
- return mailxapi.searchContacts(query);
115
- return api(`/contacts?q=${encodeURIComponent(query)}`);
224
+ return transport.call("searchContacts", { query });
116
225
  }
117
226
  export function allowRemoteContent(type, value) {
118
- if (hasIPC)
119
- return mailxapi.allowRemoteContent(type, value);
120
- return api("/settings/allow-remote", {
121
- method: "POST",
122
- body: JSON.stringify({ type, value })
123
- });
124
- }
125
- // ── Message actions ──
126
- // IMPORTANT: All server operations MUST go through these centralized methods
127
- // so IPC mode works. Never use fetch("/api/...") directly in components.
227
+ return transport.call("allowRemoteContent", { type, value });
228
+ }
128
229
  export function deleteMessage(accountId, uid) {
129
- if (hasIPC)
130
- return mailxapi.deleteMessage(accountId, uid);
131
- return api(`/message/${accountId}/${uid}`, { method: "DELETE" });
230
+ return transport.call("deleteMessage", { accountId, uid });
132
231
  }
133
232
  export function undeleteMessage(accountId, uid, folderId) {
134
- if (hasIPC)
135
- return mailxapi.undeleteMessage(accountId, uid, folderId);
136
- return api(`/message/${accountId}/${uid}/undelete`, {
137
- method: "POST",
138
- body: JSON.stringify({ folderId })
139
- });
233
+ return transport.call("undeleteMessage", { accountId, uid, folderId });
140
234
  }
141
235
  export function moveMessage(accountId, uid, targetFolderId, targetAccountId) {
142
- if (hasIPC)
143
- return mailxapi.moveMessage(accountId, uid, targetFolderId, targetAccountId);
144
- const body = { targetFolderId };
145
- if (targetAccountId)
146
- body.targetAccountId = targetAccountId;
147
- return api(`/message/${accountId}/${uid}/move`, {
148
- method: "POST",
149
- body: JSON.stringify(body)
150
- });
236
+ return transport.call("moveMessage", { accountId, uid, targetFolderId, targetAccountId });
151
237
  }
152
238
  export function restartServer() {
153
- if (hasIPC)
154
- return mailxapi.restart?.();
155
- return api("/restart", { method: "POST" }).catch(() => { });
239
+ return transport.call("restartServer").catch(() => { });
156
240
  }
157
241
  export function rebuildServer() {
158
- return api("/rebuild", { method: "POST" }).catch(() => { });
242
+ return transport.call("rebuildServer").catch(() => { });
159
243
  }
160
- // ── Folder management ──
161
244
  export function markFolderRead(accountId, folderId) {
162
- if (hasIPC)
163
- return mailxapi.markFolderRead?.(accountId, folderId);
164
- return api(`/folder/${accountId}/${folderId}/mark-read`, { method: "POST" });
245
+ return transport.call("markFolderRead", { accountId, folderId });
165
246
  }
166
247
  export function createFolder(accountId, parentPath, name) {
167
- if (hasIPC)
168
- return mailxapi.createFolder?.(accountId, parentPath, name);
169
- return api(`/folder/${accountId}`, {
170
- method: "POST",
171
- body: JSON.stringify({ parentPath, name })
172
- });
248
+ return transport.call("createFolder", { accountId, parentPath, name });
173
249
  }
174
250
  export function renameFolder(accountId, folderId, newName) {
175
- if (hasIPC)
176
- return mailxapi.renameFolder?.(accountId, folderId, newName);
177
- return api(`/folder/${accountId}/${folderId}/rename`, {
178
- method: "POST",
179
- body: JSON.stringify({ newName })
180
- });
251
+ return transport.call("renameFolder", { accountId, folderId, newName });
181
252
  }
182
253
  export function deleteFolder(accountId, folderId) {
183
- if (hasIPC)
184
- return mailxapi.deleteFolder?.(accountId, folderId);
185
- return api(`/folder/${accountId}/${folderId}`, { method: "DELETE" });
254
+ return transport.call("deleteFolder", { accountId, folderId });
186
255
  }
187
256
  export function emptyFolder(accountId, folderId) {
188
- if (hasIPC)
189
- return mailxapi.emptyFolder?.(accountId, folderId);
190
- return api(`/folder/${accountId}/${folderId}/empty`, { method: "POST" });
257
+ return transport.call("emptyFolder", { accountId, folderId });
191
258
  }
192
- // ── Compose ──
193
259
  export function sendMessage(body) {
194
- if (hasIPC)
195
- return mailxapi.sendMessage?.(body);
196
- return api("/send", { method: "POST", body: JSON.stringify(body) });
260
+ return transport.call("sendMessage", { body });
197
261
  }
198
262
  export function saveDraft(body) {
199
- if (hasIPC)
200
- return mailxapi.saveDraft?.(body);
201
- return api("/draft", { method: "POST", body: JSON.stringify(body) });
263
+ return transport.call("saveDraft", { body });
202
264
  }
203
- const eventHandlers = [];
204
265
  export function onEvent(handler) {
205
- eventHandlers.push(handler);
266
+ transport.onEvent(handler);
206
267
  }
207
268
  export function connectEvents() {
208
- if (hasIPC) {
209
- // IPC events come via mailxapi.onEvent
210
- mailxapi.onEvent((event) => {
211
- for (const h of eventHandlers)
212
- h(event);
213
- });
214
- }
215
- else {
216
- // WebSocket for HTTP mode
217
- const protocol = location.protocol === "https:" ? "wss:" : "ws:";
218
- const ws = new WebSocket(`${protocol}//${location.host}`);
219
- ws.onmessage = (ev) => {
220
- try {
221
- const event = JSON.parse(ev.data);
222
- for (const h of eventHandlers)
223
- h(event);
224
- }
225
- catch { /* ignore */ }
226
- };
227
- ws.onclose = () => {
228
- setTimeout(connectEvents, 3000);
229
- };
230
- }
269
+ transport.connect();
231
270
  }
232
271
  // Legacy exports for backward compatibility
233
272
  export const connectWebSocket = connectEvents;
@@ -56,6 +56,37 @@ body {
56
56
  background: var(--color-accent);
57
57
  }
58
58
 
59
+ /* Responsive: mid-width (tablets, foldables) — hide folders, keep list + preview */
60
+ @media (max-width: 1100px) and (min-width: 769px) {
61
+ body {
62
+ grid-template-columns: 1fr;
63
+ grid-template-rows: var(--toolbar-height) auto 1fr var(--statusbar-height);
64
+ grid-template-areas:
65
+ "toolbar"
66
+ "alert"
67
+ "main"
68
+ "status";
69
+ }
70
+
71
+ /* Folder panel: overlay slide-in from left (same as narrow) */
72
+ .folder-panel {
73
+ position: fixed;
74
+ left: -280px;
75
+ top: var(--toolbar-height);
76
+ bottom: var(--statusbar-height);
77
+ width: 280px;
78
+ z-index: 50;
79
+ transition: left 0.2s ease;
80
+ background: var(--color-bg);
81
+ border-right: 1px solid var(--color-border);
82
+ box-shadow: 2px 0 8px rgba(0,0,0,0.3);
83
+ }
84
+ .folder-panel.open { left: 0; }
85
+
86
+ /* Show hamburger */
87
+ #btn-menu { display: inline-flex !important; }
88
+ }
89
+
59
90
  /* Responsive: narrow viewport — single panel navigation */
60
91
  @media (max-width: 768px) {
61
92
  body {
Binary file
@@ -0,0 +1,118 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>mailx - Download</title>
7
+ <link rel="icon" type="image/svg+xml" href="../client/favicon.svg">
8
+ <style>
9
+ * { box-sizing: border-box; margin: 0; padding: 0; }
10
+ body { font-family: system-ui, sans-serif; background: #1e1e2e; color: #cdd6f4; min-height: 100vh; padding: 2rem; }
11
+ .container { max-width: 640px; margin: 0 auto; }
12
+ h1 { font-size: 1.8rem; margin-bottom: 0.5rem; }
13
+ h1 .version { font-size: 0.9rem; color: #89b4fa; font-weight: normal; }
14
+ .subtitle { color: #a6adc8; margin-bottom: 2rem; }
15
+ .section { background: #313244; border-radius: 12px; padding: 1.5rem; margin-bottom: 1.5rem; }
16
+ .section h2 { font-size: 1.1rem; margin-bottom: 1rem; color: #89b4fa; }
17
+ .dl-row { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 0; border-bottom: 1px solid #45475a; }
18
+ .dl-row:last-child { border-bottom: none; }
19
+ .dl-info { flex: 1; }
20
+ .dl-name { font-weight: 600; }
21
+ .dl-meta { font-size: 0.85rem; color: #a6adc8; }
22
+ .btn { display: inline-block; padding: 0.5rem 1.2rem; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 0.9rem; }
23
+ .btn-primary { background: #89b4fa; color: #1e1e2e; }
24
+ .btn-primary:hover { background: #b4d0fb; }
25
+ .btn-secondary { background: #45475a; color: #cdd6f4; }
26
+ .btn-secondary:hover { background: #585b70; }
27
+ .btn-disabled { background: #45475a; color: #6c7086; pointer-events: none; }
28
+ code { background: #45475a; padding: 2px 6px; border-radius: 4px; font-size: 0.9em; }
29
+ .install-cmd { display: block; background: #11111b; padding: 0.75rem 1rem; border-radius: 8px; margin: 0.5rem 0; font-family: monospace; font-size: 0.9rem; color: #a6e3a1; }
30
+ details { margin-top: 1.5rem; }
31
+ summary { cursor: pointer; color: #89b4fa; font-weight: 600; }
32
+ details ol { padding-left: 1.5rem; margin-top: 0.5rem; color: #a6adc8; }
33
+ details li { margin: 0.5rem 0; }
34
+ footer { text-align: center; margin-top: 2rem; font-size: 0.8rem; color: #6c7086; }
35
+ footer a { color: #89b4fa; text-decoration: none; }
36
+ </style>
37
+ </head>
38
+ <body>
39
+ <div class="container">
40
+ <h1>mailx <span class="version" id="version"></span></h1>
41
+ <p class="subtitle">Local-first email client with IMAP sync. Replaces Thunderbird/Outlook.</p>
42
+
43
+ <div class="section">
44
+ <h2>Android</h2>
45
+ <div id="apk-list">
46
+ <div class="dl-row">
47
+ <div class="dl-info">
48
+ <span class="dl-name">Loading...</span>
49
+ </div>
50
+ </div>
51
+ </div>
52
+ </div>
53
+
54
+ <div class="section">
55
+ <h2>Desktop (Windows / Linux / Mac)</h2>
56
+ <div class="dl-row">
57
+ <div class="dl-info">
58
+ <span class="dl-name">Install via npm</span>
59
+ <span class="dl-meta">Requires Node.js 22+</span>
60
+ </div>
61
+ </div>
62
+ <code class="install-cmd">npm install -g @bobfrankston/mailx && mailx</code>
63
+ </div>
64
+
65
+ <details>
66
+ <summary>Android installation help</summary>
67
+ <ol>
68
+ <li>Tap Download &rarr; tap the notification when done &rarr; Install</li>
69
+ <li>If blocked: Settings &rarr; Apps &rarr; Install unknown apps &rarr; allow your browser</li>
70
+ <li>The app needs a mailx server running on your network (desktop or server)</li>
71
+ <li>On first launch, enter your server address (e.g. <code>http://192.168.1.x:9333</code>)</li>
72
+ </ol>
73
+ </details>
74
+
75
+ <footer>
76
+ <a href="https://github.com/BobFrankston/mailx">GitHub</a> &middot;
77
+ <a href="https://www.npmjs.com/package/@bobfrankston/mailx">npm</a>
78
+ </footer>
79
+ </div>
80
+
81
+ <script>
82
+ async function load() {
83
+ try {
84
+ const resp = await fetch('versions.json', { cache: 'no-cache' });
85
+ if (!resp.ok) return;
86
+ const v = await resp.json();
87
+
88
+ document.getElementById('version').textContent = 'v' + v.version;
89
+
90
+ const list = document.getElementById('apk-list');
91
+ list.innerHTML = v.apks.map(apk => {
92
+ const size = apk.sizeMB ? apk.sizeMB + ' MB' : '';
93
+ const date = apk.built ? new Date(apk.built).toLocaleDateString() : '';
94
+ return '<div class="dl-row">' +
95
+ '<div class="dl-info">' +
96
+ '<span class="dl-name">' + apk.name + '</span><br>' +
97
+ '<span class="dl-meta">' + apk.description + ' &middot; ' + size + ' &middot; ' + date + '</span>' +
98
+ '</div>' +
99
+ '<a href="' + apk.file + '" class="btn btn-primary" id="dl-' + apk.name + '">Download APK</a>' +
100
+ '</div>';
101
+ }).join('');
102
+
103
+ // Check file exists
104
+ v.apks.forEach(async apk => {
105
+ try {
106
+ const r = await fetch(apk.file, { method: 'HEAD' });
107
+ if (!r.ok) {
108
+ const dl = document.getElementById('dl-' + apk.name);
109
+ if (dl) { dl.className = 'btn btn-disabled'; dl.removeAttribute('href'); dl.textContent = 'N/A'; }
110
+ }
111
+ } catch { }
112
+ });
113
+ } catch { }
114
+ }
115
+ load();
116
+ </script>
117
+ </body>
118
+ </html>
@@ -0,0 +1,19 @@
1
+ {
2
+ "version": "1.0.68",
3
+ "buildDate": "2026-04-02T21:39:00-04:00",
4
+ "apks": [
5
+ {
6
+ "name": "mailx",
7
+ "description": "mailx email client (connects to your mailx server)",
8
+ "file": "apks/mailx-debug.apk",
9
+ "sizeMB": 4.1,
10
+ "built": "2026-04-02T20:46:00-04:00",
11
+ "default": true
12
+ }
13
+ ],
14
+ "downloads": {
15
+ "npm": "npm install -g @bobfrankston/mailx",
16
+ "windows": "npm install -g @bobfrankston/mailx && mailx",
17
+ "linux": "npm install -g @bobfrankston/mailx && mailx -server"
18
+ }
19
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.66",
3
+ "version": "1.0.69",
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,16 +20,19 @@
20
20
  "postinstall": "node launcher/builder/postinstall.js"
21
21
  },
22
22
  "dependencies": {
23
- "@bobfrankston/iflow": "^1.0.31",
23
+ "@bobfrankston/iflow": "^1.0.32",
24
24
  "@bobfrankston/miscinfo": "^1.0.6",
25
- "@bobfrankston/oauthsupport": "^1.0.16",
25
+ "@bobfrankston/oauthsupport": "^1.0.17",
26
26
  "@bobfrankston/rust-builder": "^0.1.2",
27
- "mailparser": "^3.7.2",
28
- "quill": "^2.0.3",
27
+ "@capacitor/android": "^8.3.0",
28
+ "@capacitor/cli": "^8.3.0",
29
+ "@capacitor/core": "^8.3.0",
29
30
  "express": "^4.21.0",
31
+ "jsonc-parser": "^3.3.1",
32
+ "mailparser": "^3.7.2",
30
33
  "nodemailer": "^7.0.0",
31
- "ws": "^8.18.0",
32
- "jsonc-parser": "^3.3.1"
34
+ "quill": "^2.0.3",
35
+ "ws": "^8.18.0"
33
36
  },
34
37
  "devDependencies": {
35
38
  "@types/mailparser": "^3.4.6"
@@ -55,11 +58,14 @@
55
58
  "@bobfrankston/miscinfo": "file:../../projects/npm/miscinfo",
56
59
  "@bobfrankston/oauthsupport": "file:../../projects/oauth/oauthsupport",
57
60
  "@bobfrankston/rust-builder": "file:../../utils/rust-builder",
58
- "mailparser": "^3.7.2",
59
- "quill": "^2.0.3",
61
+ "@capacitor/android": "^8.3.0",
62
+ "@capacitor/cli": "^8.3.0",
63
+ "@capacitor/core": "^8.3.0",
60
64
  "express": "^4.21.0",
65
+ "jsonc-parser": "^3.3.1",
66
+ "mailparser": "^3.7.2",
61
67
  "nodemailer": "^7.0.0",
62
- "ws": "^8.18.0",
63
- "jsonc-parser": "^3.3.1"
68
+ "quill": "^2.0.3",
69
+ "ws": "^8.18.0"
64
70
  }
65
71
  }