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