@bobfrankston/mailx 1.0.187 → 1.0.189

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, 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, initViewer } from "./components/message-viewer.js";
8
- import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, rebuildServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags } from "./lib/api-client.js";
8
+ import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags } from "./lib/api-client.js";
9
9
  import * as messageState from "./lib/message-state.js";
10
10
  // ── New message badge (favicon + title) ──
11
11
  let baseTitle = "mailx";
@@ -259,9 +259,9 @@ document.getElementById("btn-rebuild")?.addEventListener("click", async () => {
259
259
  if (statusSync)
260
260
  statusSync.textContent = "Rebuilding...";
261
261
  try {
262
- await rebuildServer();
262
+ await restartServer();
263
263
  }
264
- catch { /* server is shutting down */ }
264
+ catch { /* restarting */ }
265
265
  });
266
266
  document.getElementById("btn-factory-reset")?.addEventListener("click", async () => {
267
267
  if (restartDropdown)
@@ -341,10 +341,39 @@ async function openCompose(mode) {
341
341
  }
342
342
  // Store init data for compose window to pick up
343
343
  sessionStorage.setItem("composeInit", JSON.stringify(init));
344
- // Open compose always as popup via window.open().
345
- // In IPC mode, msger's new_window_req_handler allows msger.localhost popups.
346
- // DO NOT use window.location.href — msger exits on navigation.
347
- window.open("compose/compose.html", "_blank", "width=800,height=600,menubar=no,toolbar=no,status=no");
344
+ // Inline compose: load compose.html in an overlay iframe (same origin, same IPC)
345
+ // Popup windows don't work in IPC mode (custom protocol doesn't propagate to child windows)
346
+ showComposeOverlay();
347
+ }
348
+ function showComposeOverlay() {
349
+ // Remove existing overlay if any
350
+ document.getElementById("compose-overlay")?.remove();
351
+ const overlay = document.createElement("div");
352
+ overlay.id = "compose-overlay";
353
+ overlay.style.cssText = "position:fixed;top:0;left:0;right:0;bottom:0;z-index:1000;background:rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;";
354
+ const frame = document.createElement("iframe");
355
+ frame.src = "compose/compose.html";
356
+ frame.style.cssText = "width:min(900px,90vw);height:min(700px,85vh);border:none;border-radius:8px;box-shadow:0 8px 32px rgba(0,0,0,0.4);background:#fff;";
357
+ // Close overlay when compose calls window.close()
358
+ frame.addEventListener("load", () => {
359
+ try {
360
+ const win = frame.contentWindow;
361
+ if (win) {
362
+ // Override window.close() in the iframe to remove the overlay instead
363
+ win.close = () => overlay.remove();
364
+ }
365
+ }
366
+ catch { /* cross-origin safety */ }
367
+ });
368
+ // Click backdrop to close (with confirmation)
369
+ overlay.addEventListener("click", (e) => {
370
+ if (e.target === overlay) {
371
+ if (confirm("Discard this message?"))
372
+ overlay.remove();
373
+ }
374
+ });
375
+ overlay.appendChild(frame);
376
+ document.body.appendChild(overlay);
348
377
  }
349
378
  function quoteBody(msg) {
350
379
  const date = new Date(msg.date).toLocaleString();
@@ -432,7 +461,13 @@ document.getElementById("btn-reply-all")?.addEventListener("click", () => openCo
432
461
  document.getElementById("btn-forward")?.addEventListener("click", () => openCompose("forward"));
433
462
  // Context menu events from message-list right-click
434
463
  document.addEventListener("mailx-compose", ((e) => {
435
- openCompose(e.detail.mode);
464
+ if (e.detail.mode === "draft" && sessionStorage.getItem("composeInit")) {
465
+ // Draft already stored by viewer — just show overlay
466
+ showComposeOverlay();
467
+ }
468
+ else {
469
+ openCompose(e.detail.mode);
470
+ }
436
471
  }));
437
472
  document.addEventListener("mailx-delete", () => deleteSelectedMessages());
438
473
  // ── Search ──
@@ -783,7 +818,7 @@ document.addEventListener("keydown", (e) => {
783
818
  document.getElementById("btn-sync")?.click();
784
819
  }
785
820
  // R = Toggle read/unread
786
- if (e.key === "r" && !e.ctrlKey && !e.metaKey && !e.altKey) {
821
+ if (e.key.toLowerCase() === "r" && !e.ctrlKey && !e.metaKey && !e.altKey) {
787
822
  const active = document.activeElement;
788
823
  if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.tagName === "SELECT"))
789
824
  return;
@@ -378,12 +378,16 @@ async function loadFolderTree(container) {
378
378
  mvHeader.style.display = "none";
379
379
  const mainBody = document.getElementById("mv-body");
380
380
  if (mainBody) {
381
+ const isAndroid = window.mailxapi?.platform === "android";
382
+ const formDisplay = isAndroid ? "display:none;" : "";
383
+ const introText = isAndroid ? "" : "Add your email account to get started.";
384
+ const checkingHtml = isAndroid ? '<div style="padding:0.5rem;color:var(--color-text-muted)">Checking for accounts...</div>' : "";
381
385
  mainBody.innerHTML = `<div style="padding:2rem;line-height:1.8;max-width:500px">
382
386
  <h2 style="margin-bottom:1rem">Welcome to mailx</h2>
383
- <div id="setup-device-accounts"></div>
387
+ <div id="setup-device-accounts">${checkingHtml}</div>
384
388
  <div id="setup-cloud-status"></div>
385
- <p id="setup-form-intro">Add your email account to get started.</p>
386
- <form id="setup-form" style="margin-top:1rem">
389
+ <p id="setup-form-intro">${introText}</p>
390
+ <form id="setup-form" style="margin-top:1rem;${formDisplay}">
387
391
  <label style="display:block;margin-bottom:0.5rem">
388
392
  Your name
389
393
  <input id="setup-name" type="text" placeholder="Your Name" 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">
@@ -486,31 +490,56 @@ async function loadFolderTree(container) {
486
490
  </div>`;
487
491
  }
488
492
  }).catch(() => { });
489
- // On Android, check for device Google accounts and show picker
493
+ // On Android, check for device Google accounts
490
494
  getDeviceAccounts().then(async (deviceAccounts) => {
491
495
  const pickerEl = document.getElementById("setup-device-accounts");
492
- if (!pickerEl || deviceAccounts.length === 0)
496
+ if (!pickerEl)
493
497
  return;
498
+ if (deviceAccounts.length === 0) {
499
+ // No device accounts — show the form
500
+ pickerEl.innerHTML = "";
501
+ const f = document.getElementById("setup-form");
502
+ const i = document.getElementById("setup-form-intro");
503
+ if (f)
504
+ f.style.display = "block";
505
+ if (i)
506
+ i.textContent = "Add your email account to get started.";
507
+ return;
508
+ }
494
509
  const formEl = document.getElementById("setup-form");
495
510
  const introEl = document.getElementById("setup-form-intro");
511
+ // Auto-setup helper
512
+ async function autoSetup(email, name) {
513
+ if (introEl)
514
+ introEl.textContent = "";
515
+ if (formEl)
516
+ formEl.style.display = "none";
517
+ pickerEl.innerHTML = `<div style="padding:0.5rem;color:var(--color-text-muted)">Setting up ${email}...</div>`;
518
+ const result = await setupAccount(name, email, "");
519
+ if (result?.ok) {
520
+ pickerEl.innerHTML = `<div style="padding:0.5rem;color:var(--color-accent)">${result.message || "Account added!"}</div>`;
521
+ setTimeout(() => location.reload(), 2000);
522
+ }
523
+ else {
524
+ pickerEl.innerHTML = `<div style="padding:0.5rem;color:#f55">${result?.error || "Setup failed"}</div>`;
525
+ if (formEl)
526
+ formEl.style.display = "block";
527
+ }
528
+ }
529
+ // One account — auto-select it
530
+ if (deviceAccounts.length === 1) {
531
+ await autoSetup(deviceAccounts[0].email, deviceAccounts[0].name);
532
+ return;
533
+ }
534
+ // Multiple accounts — show picker
496
535
  if (introEl)
497
536
  introEl.textContent = "Select an account:";
498
537
  if (formEl)
499
538
  formEl.style.display = "none";
500
- pickerEl.innerHTML = deviceAccounts.map((a) => `<button class="device-account-btn" data-email="${a.email}" data-name="${a.name}" style="display:block;width:100%;padding:0.75rem 1rem;margin-bottom:0.5rem;background:var(--color-bg-surface);color:var(--color-text);border:1px solid var(--color-border);border-radius:6px;cursor:pointer;font-size:1rem;text-align:left">${a.email}</button>`).join("") + `<button id="setup-show-form" style="margin-top:0.5rem;padding:0.5rem 1rem;background:none;color:var(--color-text-muted);border:none;cursor:pointer;font-size:0.9rem">Use a different account...</button>`;
539
+ pickerEl.innerHTML = deviceAccounts.map((a) => `<button class="device-account-btn" data-email="${a.email}" data-name="${a.name}" style="display:block;width:100%;padding:0.75rem 1rem;margin-bottom:0.5rem;background:var(--color-accent);color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:1rem;text-align:left">Use ${a.email}</button>`).join("") + `<button id="setup-show-form" style="margin-top:0.5rem;padding:0.5rem 1rem;background:none;color:var(--color-text-muted);border:none;cursor:pointer;font-size:0.9rem">Use a different account...</button>`;
501
540
  pickerEl.querySelectorAll(".device-account-btn").forEach((btn) => {
502
541
  btn.addEventListener("click", async () => {
503
- const email = btn.dataset.email || "";
504
- const name = btn.dataset.name || "";
505
- pickerEl.innerHTML = `<div style="padding:0.5rem;color:var(--color-text-muted)">Setting up ${email}...</div>`;
506
- const result = await setupAccount(name, email, "");
507
- if (result?.ok) {
508
- pickerEl.innerHTML = `<div style="padding:0.5rem;color:var(--color-accent)">${result.message || "Account added!"}</div>`;
509
- setTimeout(() => location.reload(), 2000);
510
- }
511
- else {
512
- pickerEl.innerHTML = `<div style="padding:0.5rem;color:#f55">${result?.error || "Setup failed"}</div>`;
513
- }
542
+ await autoSetup(btn.dataset.email || "", btn.dataset.name || "");
514
543
  });
515
544
  });
516
545
  document.getElementById("setup-show-form")?.addEventListener("click", () => {
@@ -520,7 +549,18 @@ async function loadFolderTree(container) {
520
549
  if (introEl)
521
550
  introEl.textContent = "Add your email account to get started.";
522
551
  });
523
- }).catch(() => { });
552
+ }).catch(() => {
553
+ // Bridge failed — show the form
554
+ const p = document.getElementById("setup-device-accounts");
555
+ if (p)
556
+ p.innerHTML = "";
557
+ const f = document.getElementById("setup-form");
558
+ const i = document.getElementById("setup-form-intro");
559
+ if (f)
560
+ f.style.display = "block";
561
+ if (i)
562
+ i.textContent = "Add your email account to get started.";
563
+ });
524
564
  }
525
565
  // Dismiss startup overlay
526
566
  const overlay = document.getElementById("startup-overlay");
@@ -144,7 +144,8 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
144
144
  draftFolderId: msg.folderId,
145
145
  };
146
146
  sessionStorage.setItem("composeInit", JSON.stringify(init));
147
- window.open("compose/compose.html", "_blank", "width=800,height=600,menubar=no,toolbar=no,status=no");
147
+ // Trigger compose overlay via custom event (app.ts handles it)
148
+ document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "draft" } }));
148
149
  };
149
150
  }
150
151
  else {
@@ -1,24 +1,24 @@
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.
5
- *
6
- * All server operations MUST go through these centralized methods.
7
- * Never use fetch("/api/...") directly in components.
2
+ * API client — all operations go through the IPC bridge (mailxapi).
3
+ * mailxapi is injected by the launcher (msger on desktop, MAUI on Android).
8
4
  */
9
- // Lazy IPC detection — checked on each call, not at module load time.
10
- // Handles: desktop (initScript before page load), Android (bootstrap before app import),
11
- // and popup windows (opener's bridge).
12
5
  function getIpc() {
13
6
  if (typeof mailxapi !== "undefined" && mailxapi?.isApp)
14
7
  return mailxapi;
15
8
  if (window.opener?.mailxapi?.isApp)
16
9
  return window.opener.mailxapi;
10
+ // Compose iframe — check parent
11
+ if (window.parent?.mailxapi?.isApp)
12
+ return window.parent.mailxapi;
17
13
  return null;
18
14
  }
19
- function hasIPC() { return getIpc() !== null; }
20
- // ── HTTP fallback ──
21
- // Abort controller for message-list requests — cancel stale fetches when folder changes
15
+ function ipc() {
16
+ const bridge = getIpc();
17
+ if (!bridge)
18
+ throw new Error("IPC bridge not available");
19
+ return bridge;
20
+ }
21
+ // ── Abort controller for message-list requests ──
22
22
  let messageListAbort = null;
23
23
  export function abortMessageListRequests() {
24
24
  if (messageListAbort) {
@@ -26,309 +26,134 @@ export function abortMessageListRequests() {
26
26
  messageListAbort = null;
27
27
  }
28
28
  }
29
- function newMessageListSignal() {
30
- abortMessageListRequests();
31
- messageListAbort = new AbortController();
32
- return messageListAbort.signal;
33
- }
34
- async function api(path, options) {
35
- let res;
36
- try {
37
- res = await fetch(`/api${path}`, {
38
- headers: { "Content-Type": "application/json" },
39
- ...options
40
- });
41
- }
42
- catch (e) {
43
- // Network error — server is down
44
- if (e.name === "AbortError")
45
- throw e;
46
- throw new Error("Server offline — run: mailx -server");
47
- }
48
- if (!res.ok) {
49
- const err = await res.json().catch(() => ({ error: res.statusText }));
50
- throw new Error(err.error || res.statusText);
51
- }
52
- return res.json();
53
- }
54
- // ── API Methods (IPC or HTTP) ──
29
+ // ── API Methods ──
55
30
  export function getAccounts() {
56
- if (hasIPC())
57
- return getIpc().getAccounts();
58
- return api("/accounts");
31
+ return ipc().getAccounts();
59
32
  }
60
33
  export function getFolders(accountId) {
61
- if (hasIPC())
62
- return getIpc().getFolders(accountId);
63
- return api(`/folders/${accountId}`);
34
+ return ipc().getFolders(accountId);
64
35
  }
65
36
  export function getMessages(accountId, folderId, page = 1, pageSize = 50) {
66
- if (hasIPC())
67
- return getIpc().getMessages(accountId, folderId, page, pageSize);
68
- const signal = newMessageListSignal();
69
- return api(`/messages/${accountId}/${folderId}?page=${page}&pageSize=${pageSize}`, { signal });
37
+ abortMessageListRequests();
38
+ return ipc().getMessages(accountId, folderId, page, pageSize);
70
39
  }
71
40
  export function getUnifiedInbox(page = 1, pageSize = 50) {
72
- if (hasIPC())
73
- return getIpc().getUnifiedInbox(page, pageSize);
74
- const signal = newMessageListSignal();
75
- return api(`/messages/unified/inbox?page=${page}&pageSize=${pageSize}`, { signal });
41
+ abortMessageListRequests();
42
+ return ipc().getUnifiedInbox(page, pageSize);
76
43
  }
77
44
  export function searchMessages(query, page = 1, pageSize = 50, scope = "all", accountId = "", folderId = 0) {
78
- if (hasIPC())
79
- return getIpc().searchMessages(query, page, pageSize);
80
- const params = new URLSearchParams({ q: query, page: String(page), pageSize: String(pageSize), scope });
81
- if (scope === "current" && accountId) {
82
- params.set("accountId", accountId);
83
- params.set("folderId", String(folderId));
84
- }
85
- if (scope === "server" && accountId) {
86
- params.set("accountId", accountId);
87
- params.set("folderId", String(folderId));
88
- }
89
- return api(`/search?${params}`);
45
+ return ipc().searchMessages(query, page, pageSize);
90
46
  }
91
47
  export function getMessage(accountId, uid, allowRemote = false, folderId) {
92
- if (hasIPC())
93
- return getIpc().getMessage(accountId, uid, allowRemote, folderId);
94
- const params = new URLSearchParams();
95
- if (allowRemote)
96
- params.set("allowRemote", "true");
97
- if (folderId != null)
98
- params.set("folderId", String(folderId));
99
- const q = params.toString() ? `?${params}` : "";
100
- return api(`/message/${accountId}/${uid}${q}`);
48
+ return ipc().getMessage(accountId, uid, allowRemote, folderId);
101
49
  }
102
50
  export function updateFlags(accountId, uid, flags) {
103
- if (hasIPC())
104
- return getIpc().updateFlags(accountId, uid, flags);
105
- return api(`/message/${accountId}/${uid}/flags`, {
106
- method: "PATCH",
107
- body: JSON.stringify({ flags })
108
- });
51
+ return ipc().updateFlags(accountId, uid, flags);
109
52
  }
110
53
  export function triggerSync() {
111
- if (hasIPC())
112
- return getIpc().syncAll();
113
- return api("/sync", { method: "POST" });
54
+ return ipc().syncAll();
114
55
  }
115
56
  export function syncAccount(accountId) {
116
- if (hasIPC())
117
- return getIpc().syncAccount(accountId);
118
- return api(`/sync/${accountId}`, { method: "POST" });
57
+ return ipc().syncAccount(accountId);
119
58
  }
120
59
  export function reauthenticate(accountId) {
121
- if (hasIPC())
122
- return getIpc().reauthenticate(accountId);
123
- return api(`/reauth/${accountId}`, { method: "POST" });
60
+ return ipc().reauthenticate(accountId);
124
61
  }
125
62
  export function getSyncPending() {
126
- if (hasIPC())
127
- return getIpc().getSyncPending();
128
- return api("/sync/pending");
63
+ return ipc().getSyncPending();
129
64
  }
130
65
  export function searchContacts(query) {
131
- if (hasIPC())
132
- return getIpc().searchContacts(query);
133
- return api(`/contacts?q=${encodeURIComponent(query)}`);
66
+ return ipc().searchContacts(query);
134
67
  }
135
68
  export function allowRemoteContent(type, value) {
136
- if (hasIPC())
137
- return getIpc().allowRemoteContent(type, value);
138
- return api("/settings/allow-remote", {
139
- method: "POST",
140
- body: JSON.stringify({ type, value })
141
- });
69
+ return ipc().allowRemoteContent(type, value);
142
70
  }
143
71
  export function deleteMessage(accountId, uid) {
144
- if (hasIPC())
145
- return getIpc().deleteMessage?.(accountId, uid);
146
- return api(`/message/${accountId}/${uid}`, { method: "DELETE" });
72
+ return ipc().deleteMessage?.(accountId, uid);
147
73
  }
148
74
  export function deleteMessages(accountId, uids) {
149
75
  if (uids.length === 1)
150
76
  return deleteMessage(accountId, uids[0]);
151
- if (hasIPC())
152
- return getIpc().deleteMessages?.(accountId, uids);
153
- return api("/messages/delete", {
154
- method: "POST", body: JSON.stringify({ accountId, uids })
155
- });
77
+ return ipc().deleteMessages?.(accountId, uids);
156
78
  }
157
79
  export function moveMessages(accountId, uids, targetFolderId, targetAccountId) {
158
80
  if (uids.length === 1)
159
81
  return moveMessage(accountId, uids[0], targetFolderId, targetAccountId);
160
- if (hasIPC())
161
- return getIpc().moveMessages?.(accountId, uids, targetFolderId, targetAccountId);
162
- const body = { accountId, uids, targetFolderId };
163
- if (targetAccountId)
164
- body.targetAccountId = targetAccountId;
165
- return api("/messages/move", {
166
- method: "POST", body: JSON.stringify(body)
167
- });
82
+ return ipc().moveMessages?.(accountId, uids, targetFolderId, targetAccountId);
168
83
  }
169
84
  export function undeleteMessage(accountId, uid, folderId) {
170
- if (hasIPC())
171
- return getIpc().undeleteMessage?.(accountId, uid, folderId);
172
- return api(`/message/${accountId}/${uid}/undelete`, {
173
- method: "POST",
174
- body: JSON.stringify({ folderId })
175
- });
85
+ return ipc().undeleteMessage?.(accountId, uid, folderId);
176
86
  }
177
87
  export function moveMessage(accountId, uid, targetFolderId, targetAccountId) {
178
- if (hasIPC())
179
- return getIpc().moveMessage?.(accountId, uid, targetFolderId, targetAccountId);
180
- const body = { targetFolderId };
181
- if (targetAccountId)
182
- body.targetAccountId = targetAccountId;
183
- return api(`/message/${accountId}/${uid}/move`, {
184
- method: "POST",
185
- body: JSON.stringify(body)
186
- });
88
+ return ipc().moveMessage?.(accountId, uid, targetFolderId, targetAccountId);
187
89
  }
188
90
  export function restartServer() {
189
- if (hasIPC())
190
- return getIpc().restart?.();
191
- return api("/restart", { method: "POST" }).catch(() => { });
192
- }
193
- export function rebuildServer() {
194
- return api("/rebuild", { method: "POST" }).catch(() => { });
91
+ return ipc().restart?.();
195
92
  }
196
93
  export function markFolderRead(accountId, folderId) {
197
- if (hasIPC())
198
- return getIpc().markFolderRead?.(accountId, folderId);
199
- return api(`/folder/${accountId}/${folderId}/mark-read`, { method: "POST" });
94
+ return ipc().markFolderRead?.(accountId, folderId);
200
95
  }
201
96
  export function createFolder(accountId, parentPath, name) {
202
- if (hasIPC())
203
- return getIpc().createFolder?.(accountId, parentPath, name);
204
- return api(`/folder/${accountId}`, {
205
- method: "POST",
206
- body: JSON.stringify({ parentPath, name })
207
- });
97
+ return ipc().createFolder?.(accountId, parentPath, name);
208
98
  }
209
99
  export function renameFolder(accountId, folderId, newName) {
210
- if (hasIPC())
211
- return getIpc().renameFolder?.(accountId, folderId, newName);
212
- return api(`/folder/${accountId}/${folderId}/rename`, {
213
- method: "POST",
214
- body: JSON.stringify({ newName })
215
- });
100
+ return ipc().renameFolder?.(accountId, folderId, newName);
216
101
  }
217
102
  export function deleteFolder(accountId, folderId) {
218
- if (hasIPC())
219
- return getIpc().deleteFolder?.(accountId, folderId);
220
- return api(`/folder/${accountId}/${folderId}`, { method: "DELETE" });
103
+ return ipc().deleteFolder?.(accountId, folderId);
221
104
  }
222
105
  export function emptyFolder(accountId, folderId) {
223
- if (hasIPC())
224
- return getIpc().emptyFolder?.(accountId, folderId);
225
- return api(`/folder/${accountId}/${folderId}/empty`, { method: "POST" });
106
+ return ipc().emptyFolder?.(accountId, folderId);
226
107
  }
227
108
  export function sendMessage(body) {
228
- if (hasIPC())
229
- return getIpc().sendMessage?.(body);
230
- return api("/send", { method: "POST", body: JSON.stringify(body) });
109
+ return ipc().sendMessage?.(body);
231
110
  }
232
111
  export function saveDraft(body) {
233
- if (hasIPC())
234
- return getIpc().saveDraft?.(body);
235
- return api("/draft", { method: "POST", body: JSON.stringify(body) });
112
+ return ipc().saveDraft?.(body);
236
113
  }
237
114
  const eventHandlers = [];
238
115
  export function onEvent(handler) {
239
116
  eventHandlers.push(handler);
240
117
  }
241
118
  export function connectEvents() {
242
- if (hasIPC()) {
243
- // IPC events come via mailxapi.onEvent
244
- getIpc().onEvent((event) => {
245
- for (const h of eventHandlers)
246
- h(event);
247
- });
248
- }
249
- else {
250
- // WebSocket for HTTP mode
251
- const protocol = location.protocol === "https:" ? "wss:" : "ws:";
252
- const ws = new WebSocket(`${protocol}//${location.host}`);
253
- ws.onmessage = (ev) => {
254
- try {
255
- const event = JSON.parse(ev.data);
256
- for (const h of eventHandlers)
257
- h(event);
258
- }
259
- catch { /* ignore */ }
260
- };
261
- ws.onclose = () => {
262
- setTimeout(connectEvents, 3000);
263
- };
264
- }
119
+ ipc().onEvent((event) => {
120
+ for (const h of eventHandlers)
121
+ h(event);
122
+ });
265
123
  }
266
124
  // ── Autocomplete ──
267
125
  export function autocomplete(body, signal) {
268
- if (hasIPC())
269
- return getIpc().autocomplete?.(body);
270
- return api("/autocomplete", { method: "POST", body: JSON.stringify(body), signal });
126
+ return ipc().autocomplete?.(body);
271
127
  }
272
128
  export function getAutocompleteSettings() {
273
- if (hasIPC())
274
- return getIpc().getAutocompleteSettings?.();
275
- return api("/autocomplete/settings");
129
+ return ipc().getAutocompleteSettings?.();
276
130
  }
277
131
  export function saveAutocompleteSettings(settings) {
278
- if (hasIPC())
279
- return getIpc().saveAutocompleteSettings?.(settings);
280
- return api("/autocomplete/settings", { method: "POST", body: JSON.stringify(settings) });
132
+ return ipc().saveAutocompleteSettings?.(settings);
281
133
  }
282
134
  export function getVersion() {
283
- if (hasIPC())
284
- return getIpc().getVersion();
285
- return api("/version");
135
+ return ipc().getVersion();
286
136
  }
287
137
  export function getSettings() {
288
- if (hasIPC())
289
- return getIpc().getSettings();
290
- return api("/settings");
138
+ return ipc().getSettings();
291
139
  }
292
140
  export function saveSettings(settings) {
293
- if (hasIPC())
294
- return getIpc().saveSettingsData?.(settings);
295
- return api("/settings", { method: "PUT", body: JSON.stringify(settings) });
296
- }
297
- export async function getAttachment(accountId, uid, attachmentId, folderId) {
298
- if (hasIPC())
299
- return getIpc().getAttachment(accountId, uid, attachmentId, folderId);
300
- // HTTP mode: fetch as blob
301
- const params = folderId != null ? `?folderId=${folderId}` : "";
302
- const res = await fetch(`/api/message/${accountId}/${uid}/attachment/${attachmentId}${params}`);
303
- if (!res.ok)
304
- throw new Error("Attachment not found");
305
- const blob = await res.blob();
306
- const buf = await blob.arrayBuffer();
307
- return {
308
- content: btoa(String.fromCharCode(...new Uint8Array(buf))),
309
- contentType: res.headers.get("content-type") || "application/octet-stream",
310
- filename: `attachment-${attachmentId}`,
311
- };
141
+ return ipc().saveSettingsData?.(settings);
312
142
  }
313
143
  export function repairAccounts() {
314
- if (hasIPC())
315
- return getIpc().repairAccounts?.();
316
- return api("/repair-accounts", { method: "POST" });
144
+ return ipc().repairAccounts?.();
317
145
  }
318
146
  export function deleteDraft(accountId, draftUid) {
319
- if (hasIPC())
320
- return getIpc().deleteDraft?.(accountId, draftUid);
321
- return api("/draft", { method: "DELETE", body: JSON.stringify({ accountId, draftUid }) });
147
+ return ipc().deleteDraft?.(accountId, draftUid);
322
148
  }
323
149
  export function setupAccount(name, email, password) {
324
- if (hasIPC())
325
- return getIpc().setupAccount?.(name, email, password);
326
- return api("/setup", { method: "POST", body: JSON.stringify({ name, email, password }) });
150
+ return ipc().setupAccount?.(name, email, password);
151
+ }
152
+ export async function getAttachment(accountId, uid, attachmentId, folderId) {
153
+ return ipc().getAttachment(accountId, uid, attachmentId, folderId);
327
154
  }
328
155
  export async function getDeviceAccounts() {
329
- if (hasIPC())
330
- return getIpc().getDeviceAccounts?.() ?? [];
331
- return [];
156
+ return ipc().getDeviceAccounts?.() ?? [];
332
157
  }
333
158
  // Legacy exports for backward compatibility
334
159
  export const connectWebSocket = connectEvents;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.187",
3
+ "version": "1.0.189",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -24,7 +24,7 @@
24
24
  "@bobfrankston/iflow-node": "^0.1.2",
25
25
  "@bobfrankston/miscinfo": "^1.0.8",
26
26
  "@bobfrankston/oauthsupport": "^1.0.21",
27
- "@bobfrankston/msger": "^0.1.237",
27
+ "@bobfrankston/msger": "^0.1.239",
28
28
  "@capacitor/android": "^8.3.0",
29
29
  "@capacitor/cli": "^8.3.0",
30
30
  "@capacitor/core": "^8.3.0",
@@ -238,12 +238,14 @@ class AndroidSyncManager {
238
238
  async syncAllContacts() { }
239
239
  }
240
240
  // ── OAuth credentials (same "installed" client as desktop) ──
241
+ // Same credentials as desktop mailx (iflow-credentials.json from @bobfrankston/iflow-direct)
241
242
  const OAUTH_CLIENT = {
242
- clientId: "1067464114116-ndd19nqat8lucb0mbb0mvl7i3tbhnc43.apps.googleusercontent.com",
243
- clientSecret: "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",
243
+ clientId: "884213380682-hcso64dcqmk4p98vsc7br2e6gvn7iv2u.apps.googleusercontent.com",
244
+ clientSecret: "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",
244
245
  authUri: "https://accounts.google.com/o/oauth2/auth",
245
246
  tokenUri: "https://oauth2.googleapis.com/token",
246
- redirectUri: "http://localhost",
247
+ // Reverse client ID scheme — auto-allowed for Google "installed" apps
248
+ redirectUri: "com.googleusercontent.apps.884213380682-hcso64dcqmk4p98vsc7br2e6gvn7iv2u:/oauth2callback",
247
249
  };
248
250
  const OAUTH_SCOPES = "https://mail.google.com/ https://www.googleapis.com/auth/contacts.readonly https://www.googleapis.com/auth/drive.file";
249
251
  // ── Token cache (IndexedDB) ──
@@ -362,8 +364,29 @@ function createNativeTokenProvider(email) {
362
364
  };
363
365
  }
364
366
  // ── Initialization ──
367
+ async function waitForNativeBridge(timeoutMs = 5000) {
368
+ if (window._nativeBridge)
369
+ return;
370
+ return new Promise((resolve) => {
371
+ const start = Date.now();
372
+ const check = () => {
373
+ if (window._nativeBridge || Date.now() - start > timeoutMs) {
374
+ resolve();
375
+ }
376
+ else {
377
+ setTimeout(check, 50);
378
+ }
379
+ };
380
+ // Also listen for the event C# dispatches after bridge injection
381
+ window.addEventListener("nativebridgeready", () => resolve(), { once: true });
382
+ check();
383
+ });
384
+ }
365
385
  export async function initAndroid() {
366
386
  console.log("[android] Initializing mailx...");
387
+ // Wait for C# to inject the native bridge (TCP/FS/HTTP + OAuth)
388
+ await waitForNativeBridge();
389
+ console.log(`[android] Native bridge: ${window._nativeBridge ? "ready" : "timeout"}`);
367
390
  db = new WebMailxDB("mailx");
368
391
  await db.waitReady();
369
392
  bodyStore = new WebMessageStore();