@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,
|
|
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
|
|
262
|
+
await restartServer();
|
|
263
263
|
}
|
|
264
|
-
catch { /*
|
|
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
|
-
//
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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"
|
|
387
|
+
<div id="setup-device-accounts">${checkingHtml}</div>
|
|
384
388
|
<div id="setup-cloud-status"></div>
|
|
385
|
-
<p id="setup-form-intro"
|
|
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
|
|
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
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/client/lib/api-client.js
CHANGED
|
@@ -1,24 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* API client —
|
|
3
|
-
*
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
return getIpc().getAccounts();
|
|
58
|
-
return api("/accounts");
|
|
31
|
+
return ipc().getAccounts();
|
|
59
32
|
}
|
|
60
33
|
export function getFolders(accountId) {
|
|
61
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
return getIpc().syncAll();
|
|
113
|
-
return api("/sync", { method: "POST" });
|
|
54
|
+
return ipc().syncAll();
|
|
114
55
|
}
|
|
115
56
|
export function syncAccount(accountId) {
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
return getIpc().getSyncPending();
|
|
128
|
-
return api("/sync/pending");
|
|
63
|
+
return ipc().getSyncPending();
|
|
129
64
|
}
|
|
130
65
|
export function searchContacts(query) {
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
274
|
-
return getIpc().getAutocompleteSettings?.();
|
|
275
|
-
return api("/autocomplete/settings");
|
|
129
|
+
return ipc().getAutocompleteSettings?.();
|
|
276
130
|
}
|
|
277
131
|
export function saveAutocompleteSettings(settings) {
|
|
278
|
-
|
|
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
|
-
|
|
284
|
-
return getIpc().getVersion();
|
|
285
|
-
return api("/version");
|
|
135
|
+
return ipc().getVersion();
|
|
286
136
|
}
|
|
287
137
|
export function getSettings() {
|
|
288
|
-
|
|
289
|
-
return getIpc().getSettings();
|
|
290
|
-
return api("/settings");
|
|
138
|
+
return ipc().getSettings();
|
|
291
139
|
}
|
|
292
140
|
export function saveSettings(settings) {
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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: "
|
|
243
|
-
clientSecret: "GOCSPX-
|
|
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
|
-
|
|
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();
|