@bobfrankston/mailx 1.0.156 → 1.0.157
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/bin/mailx.js +2 -1
- package/client/app.js +2 -19
- package/client/components/message-list.js +4 -2
- package/client/components/message-viewer.js +3 -3
- package/client/lib/mailxapi.js +22 -1
- package/package.json +2 -2
- package/packages/mailx-imap/index.d.ts +4 -0
- package/packages/mailx-imap/index.js +97 -58
package/bin/mailx.js
CHANGED
|
@@ -703,8 +703,9 @@ async function main() {
|
|
|
703
703
|
imapManager.on("accountError", (accountId, error, hint, isOAuth) => {
|
|
704
704
|
handle.send({ _event: "accountError", type: "accountError", accountId, error, hint, isOAuth });
|
|
705
705
|
});
|
|
706
|
-
// Wait for WebView2 initialization
|
|
706
|
+
// Wait for WebView2 initialization, then signal readiness
|
|
707
707
|
await new Promise(r => setTimeout(r, 2000));
|
|
708
|
+
handle.send({ _event: "ready", type: "ready" });
|
|
708
709
|
// Register all accounts (OAuth may open browser for Gmail — event loop stays free for IPC)
|
|
709
710
|
for (const account of settings.accounts) {
|
|
710
711
|
if (!account.enabled)
|
package/client/app.js
CHANGED
|
@@ -899,25 +899,8 @@ optAutocomplete?.addEventListener("change", () => {
|
|
|
899
899
|
}).catch(() => { });
|
|
900
900
|
});
|
|
901
901
|
const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
|
|
902
|
-
//
|
|
903
|
-
|
|
904
|
-
// Wait for IPC to be established (first getAccounts succeeds around cbid 3)
|
|
905
|
-
await new Promise(r => setTimeout(r, 3000));
|
|
906
|
-
for (let i = 0; i < 5; i++) {
|
|
907
|
-
try {
|
|
908
|
-
const result = await Promise.race([
|
|
909
|
-
getVersion(),
|
|
910
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 5000))
|
|
911
|
-
]);
|
|
912
|
-
return result;
|
|
913
|
-
}
|
|
914
|
-
catch {
|
|
915
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
|
-
return { version: "?", storage: {} };
|
|
919
|
-
}
|
|
920
|
-
const versionPromise = getVersionWithRetry();
|
|
902
|
+
// Wait for server ready signal, then fetch version
|
|
903
|
+
const versionPromise = getVersion();
|
|
921
904
|
versionPromise.then((d) => {
|
|
922
905
|
const el = document.getElementById("app-version");
|
|
923
906
|
const storage = d.storage || {};
|
|
@@ -102,8 +102,7 @@ export async function loadUnifiedInbox(autoSelect = true) {
|
|
|
102
102
|
const result = await getUnifiedInbox(1);
|
|
103
103
|
totalMessages = result.total;
|
|
104
104
|
if (result.items.length === 0) {
|
|
105
|
-
body.innerHTML = `<div class="ml-empty">
|
|
106
|
-
clearViewer();
|
|
105
|
+
body.innerHTML = `<div class="ml-empty">${result.total > 0 ? `${result.total} messages syncing...` : "Syncing — messages will appear shortly"}</div>`;
|
|
107
106
|
return;
|
|
108
107
|
}
|
|
109
108
|
// Build new rows into a fragment, then swap atomically (no flash)
|
|
@@ -187,6 +186,9 @@ export async function loadSearchResults(query, scope = "all", accountId = "", fo
|
|
|
187
186
|
}
|
|
188
187
|
}
|
|
189
188
|
export async function loadMessages(accountId, folderId, page = 1, specialUse = "", autoSelect = true) {
|
|
189
|
+
// Clear viewer when navigating to a new folder (not on reloads)
|
|
190
|
+
if (autoSelect)
|
|
191
|
+
clearViewer();
|
|
190
192
|
searchMode = false;
|
|
191
193
|
unifiedMode = false;
|
|
192
194
|
showToInsteadOfFrom = ["sent", "drafts", "outbox"].includes(specialUse) ||
|
|
@@ -19,8 +19,8 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
19
19
|
const headerEl = document.getElementById("mv-header");
|
|
20
20
|
const bodyEl = document.getElementById("mv-body");
|
|
21
21
|
const attEl = document.getElementById("mv-attachments");
|
|
22
|
-
bodyEl.innerHTML = `<div class="mv-empty">
|
|
23
|
-
|
|
22
|
+
bodyEl.innerHTML = `<div class="mv-empty">Fetching message body...</div>`;
|
|
23
|
+
// Don't hide the header — keep previous header visible until new one loads
|
|
24
24
|
attEl.hidden = true;
|
|
25
25
|
try {
|
|
26
26
|
const msg = await getMessage(accountId, uid, false, folderId);
|
|
@@ -102,7 +102,7 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
102
102
|
draftFolderId: msg.folderId,
|
|
103
103
|
};
|
|
104
104
|
sessionStorage.setItem("composeInit", JSON.stringify(init));
|
|
105
|
-
window.open("
|
|
105
|
+
window.open("compose/compose.html", "_blank", "width=800,height=600,menubar=no,toolbar=no,status=no");
|
|
106
106
|
};
|
|
107
107
|
}
|
|
108
108
|
else {
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
var _callbacks = {};
|
|
13
13
|
var _callbackId = 0;
|
|
14
14
|
var _eventHandlers = [];
|
|
15
|
+
var _ready = false;
|
|
16
|
+
var _pendingCalls = []; // buffered until server sends "ready"
|
|
15
17
|
|
|
16
18
|
function callNode(action, params) {
|
|
17
19
|
var id = String(++_callbackId);
|
|
@@ -22,10 +24,14 @@
|
|
|
22
24
|
}, 120000);
|
|
23
25
|
_callbacks[id] = { resolve: resolve, reject: reject, timer: timer };
|
|
24
26
|
var msg = Object.assign({ _action: action, _cbid: id }, params || {});
|
|
27
|
+
if (!_ready) {
|
|
28
|
+
// Buffer until server is ready (early calls are lost in the pipe)
|
|
29
|
+
_pendingCalls.push(msg);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
25
32
|
if (window.ipc && window.ipc.postMessage) {
|
|
26
33
|
window.ipc.postMessage(JSON.stringify(msg));
|
|
27
34
|
} else {
|
|
28
|
-
// Fallback: should not happen in WebView
|
|
29
35
|
clearTimeout(timer);
|
|
30
36
|
delete _callbacks[id];
|
|
31
37
|
reject(new Error("No IPC channel available"));
|
|
@@ -33,6 +39,16 @@
|
|
|
33
39
|
});
|
|
34
40
|
}
|
|
35
41
|
|
|
42
|
+
function flushPending() {
|
|
43
|
+
_ready = true;
|
|
44
|
+
var pending = _pendingCalls.splice(0);
|
|
45
|
+
for (var i = 0; i < pending.length; i++) {
|
|
46
|
+
if (window.ipc && window.ipc.postMessage) {
|
|
47
|
+
window.ipc.postMessage(JSON.stringify(pending[i]));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
36
52
|
// Called by Rust to resolve promises
|
|
37
53
|
window._mailxapiResolve = function(id, value) {
|
|
38
54
|
var cb = _callbacks[id];
|
|
@@ -53,6 +69,11 @@
|
|
|
53
69
|
|
|
54
70
|
// Called by Rust to push events (new mail, sync progress, etc.)
|
|
55
71
|
window._mailxapiEvent = function(event) {
|
|
72
|
+
// "ready" signal from server — flush buffered IPC calls
|
|
73
|
+
if (event && event.type === "ready") {
|
|
74
|
+
flushPending();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
56
77
|
for (var i = 0; i < _eventHandlers.length; i++) {
|
|
57
78
|
try { _eventHandlers[i](event); } catch(e) { /* ignore */ }
|
|
58
79
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.157",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"@bobfrankston/iflow": "^1.0.53",
|
|
24
24
|
"@bobfrankston/miscinfo": "^1.0.7",
|
|
25
25
|
"@bobfrankston/oauthsupport": "^1.0.20",
|
|
26
|
-
"@bobfrankston/msger": "^0.1.
|
|
26
|
+
"@bobfrankston/msger": "^0.1.207",
|
|
27
27
|
"@capacitor/android": "^8.3.0",
|
|
28
28
|
"@capacitor/cli": "^8.3.0",
|
|
29
29
|
"@capacitor/core": "^8.3.0",
|
|
@@ -79,6 +79,10 @@ export declare class ImapManager extends EventEmitter {
|
|
|
79
79
|
/** Sync all folders for all accounts */
|
|
80
80
|
syncAll(): Promise<void>;
|
|
81
81
|
private _syncAll;
|
|
82
|
+
/** Sync a single account — manages its own connection lifecycle */
|
|
83
|
+
private syncAccount;
|
|
84
|
+
/** Kill and recreate the persistent ops connection */
|
|
85
|
+
private reconnectOps;
|
|
82
86
|
/** Handle sync errors — classify and emit appropriate UI events */
|
|
83
87
|
private handleSyncError;
|
|
84
88
|
/** Sync just INBOX for each account (fast check for new mail) */
|
|
@@ -378,7 +378,10 @@ export class ImapManager extends EventEmitter {
|
|
|
378
378
|
this.db.upsertFolder(accountId, folder.path, folder.name || folder.path.split(folder.delimiter || "/").pop() || folder.path, specialUse, folder.delimiter || "/");
|
|
379
379
|
}
|
|
380
380
|
this.emit("syncProgress", accountId, "folders", 100);
|
|
381
|
-
|
|
381
|
+
// Notify UI that folder structure changed — triggers tree re-render
|
|
382
|
+
const dbFolders = this.db.getFolders(accountId);
|
|
383
|
+
this.emit("folderCountsChanged", accountId, {});
|
|
384
|
+
return dbFolders;
|
|
382
385
|
}
|
|
383
386
|
/** Sync messages for a specific folder */
|
|
384
387
|
async syncFolder(accountId, folderId, client) {
|
|
@@ -595,59 +598,86 @@ export class ImapManager extends EventEmitter {
|
|
|
595
598
|
}
|
|
596
599
|
async _syncAll() {
|
|
597
600
|
const priorityOrder = ["sent", "drafts", "archive", "junk", "trash"];
|
|
598
|
-
// Sync
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
601
|
+
// Sync all accounts in parallel — each manages its own connection
|
|
602
|
+
const syncPromises = [...this.configs.keys()].map(accountId => this.syncAccount(accountId, priorityOrder));
|
|
603
|
+
await Promise.allSettled(syncPromises);
|
|
604
|
+
}
|
|
605
|
+
/** Sync a single account — manages its own connection lifecycle */
|
|
606
|
+
async syncAccount(accountId, priorityOrder) {
|
|
607
|
+
try {
|
|
608
|
+
// Step 1: Get folder list (fast — <1s typically)
|
|
609
|
+
let client = await this.getOpsClient(accountId);
|
|
610
|
+
const t0 = Date.now();
|
|
611
|
+
const folders = await this.syncFolders(accountId, client);
|
|
612
|
+
console.log(` [timing] ${accountId}: folder list ${Date.now() - t0}ms (${folders.length} folders)`);
|
|
613
|
+
// Step 2: Sync INBOX first
|
|
614
|
+
const inbox = folders.find(f => f.specialUse === "inbox");
|
|
615
|
+
if (inbox) {
|
|
616
|
+
try {
|
|
617
|
+
client = await this.getOpsClient(accountId);
|
|
618
|
+
await this.syncFolder(accountId, inbox.id, client);
|
|
619
|
+
}
|
|
620
|
+
catch (e) {
|
|
621
|
+
console.error(` Inbox sync error for ${accountId}: ${e.message}`);
|
|
622
|
+
await this.reconnectOps(accountId);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
// Step 3: Sync remaining folders
|
|
626
|
+
const remaining = folders.filter(f => f.specialUse !== "inbox");
|
|
627
|
+
remaining.sort((a, b) => {
|
|
628
|
+
const pa = priorityOrder.indexOf(a.specialUse || "") >= 0 ? priorityOrder.indexOf(a.specialUse || "") : 5;
|
|
629
|
+
const pb = priorityOrder.indexOf(b.specialUse || "") >= 0 ? priorityOrder.indexOf(b.specialUse || "") : 5;
|
|
630
|
+
return pa - pb;
|
|
631
|
+
});
|
|
632
|
+
let consecutiveErrors = 0;
|
|
633
|
+
for (const folder of remaining) {
|
|
634
|
+
const isTrashChild = folder.path.includes("/") && folder.path.toLowerCase().startsWith("trash");
|
|
635
|
+
const highestUid = this.db.getHighestUid(accountId, folder.id);
|
|
636
|
+
if (isTrashChild && highestUid === 0)
|
|
637
|
+
continue;
|
|
638
|
+
try {
|
|
639
|
+
client = await this.getOpsClient(accountId);
|
|
640
|
+
await this.syncFolder(accountId, folder.id, client);
|
|
641
|
+
consecutiveErrors = 0;
|
|
642
|
+
}
|
|
643
|
+
catch (e) {
|
|
644
|
+
consecutiveErrors++;
|
|
645
|
+
if (e.responseText?.includes("doesn't exist")) {
|
|
646
|
+
this.db.deleteFolder(folder.id);
|
|
615
647
|
}
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
const pb = priorityOrder.indexOf(b.specialUse || "") >= 0 ? priorityOrder.indexOf(b.specialUse || "") : 5;
|
|
621
|
-
return pa - pb;
|
|
622
|
-
});
|
|
623
|
-
for (const folder of remaining) {
|
|
624
|
-
const isTrashChild = folder.path.includes("/") && folder.path.toLowerCase().startsWith("trash");
|
|
625
|
-
const highestUid = this.db.getHighestUid(accountId, folder.id);
|
|
626
|
-
if (isTrashChild && highestUid === 0)
|
|
627
|
-
continue; // defer trash subfolders on first sync
|
|
628
|
-
try {
|
|
629
|
-
await this.syncFolder(accountId, folder.id, client);
|
|
630
|
-
}
|
|
631
|
-
catch (e) {
|
|
632
|
-
if (e.responseText?.includes("doesn't exist")) {
|
|
633
|
-
this.db.deleteFolder(folder.id);
|
|
634
|
-
}
|
|
635
|
-
else {
|
|
636
|
-
console.error(` Skipping folder ${folder.path}: ${e.message}`);
|
|
637
|
-
}
|
|
638
|
-
}
|
|
648
|
+
else {
|
|
649
|
+
console.error(` Skipping ${folder.path}: ${e.message}`);
|
|
650
|
+
// Connection is probably dead — reconnect
|
|
651
|
+
await this.reconnectOps(accountId);
|
|
639
652
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
653
|
+
// Too many consecutive errors = connection fundamentally broken
|
|
654
|
+
if (consecutiveErrors >= 3) {
|
|
655
|
+
console.error(` [sync] ${accountId}: ${consecutiveErrors} consecutive errors — aborting sync`);
|
|
656
|
+
break;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
643
659
|
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
660
|
+
this.accountErrorShown.delete(accountId);
|
|
661
|
+
this.emit("syncComplete", accountId);
|
|
662
|
+
}
|
|
663
|
+
catch (e) {
|
|
664
|
+
const errMsg = imapError(e);
|
|
665
|
+
this.emit("syncError", accountId, errMsg);
|
|
666
|
+
console.error(`Sync error for ${accountId}: ${errMsg}`);
|
|
667
|
+
this.handleSyncError(accountId, errMsg);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
/** Kill and recreate the persistent ops connection */
|
|
671
|
+
async reconnectOps(accountId) {
|
|
672
|
+
const old = this.opsClients.get(accountId);
|
|
673
|
+
this.opsClients.delete(accountId);
|
|
674
|
+
if (old) {
|
|
675
|
+
try {
|
|
676
|
+
await (old._realLogout || old.logout)();
|
|
649
677
|
}
|
|
678
|
+
catch { /* */ }
|
|
650
679
|
}
|
|
680
|
+
console.log(` [conn] ${accountId}: reconnecting`);
|
|
651
681
|
}
|
|
652
682
|
/** Handle sync errors — classify and emit appropriate UI events */
|
|
653
683
|
handleSyncError(accountId, errMsg) {
|
|
@@ -848,19 +878,28 @@ export class ImapManager extends EventEmitter {
|
|
|
848
878
|
if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
|
|
849
879
|
return this.bodyStore.getMessage(accountId, folderId, uid);
|
|
850
880
|
}
|
|
881
|
+
// Body fetch uses a fresh connection — never waits behind background sync
|
|
882
|
+
let client = null;
|
|
851
883
|
try {
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
884
|
+
client = this.newClient(accountId);
|
|
885
|
+
const msg = await client.fetchMessageByUid(folder.path, uid, { source: true });
|
|
886
|
+
await client.logout();
|
|
887
|
+
client = null;
|
|
888
|
+
if (!msg?.source)
|
|
889
|
+
return null;
|
|
890
|
+
const raw = Buffer.from(msg.source, "utf-8");
|
|
891
|
+
const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
|
|
892
|
+
this.db.updateBodyPath(accountId, uid, bodyPath);
|
|
893
|
+
return raw;
|
|
861
894
|
}
|
|
862
895
|
catch (e) {
|
|
863
896
|
console.error(` Body fetch error (${accountId}/${uid}): ${e.message}`);
|
|
897
|
+
if (client) {
|
|
898
|
+
try {
|
|
899
|
+
await client.logout();
|
|
900
|
+
}
|
|
901
|
+
catch { /* */ }
|
|
902
|
+
}
|
|
864
903
|
return null;
|
|
865
904
|
}
|
|
866
905
|
});
|