@bobfrankston/mailx 1.0.156 → 1.0.158
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 +26 -1
- package/package.json +3 -3
- package/packages/mailx-imap/index.d.ts +4 -0
- package/packages/mailx-imap/index.js +104 -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,20 @@
|
|
|
33
39
|
});
|
|
34
40
|
}
|
|
35
41
|
|
|
42
|
+
function flushPending() {
|
|
43
|
+
_ready = true;
|
|
44
|
+
var pending = _pendingCalls.splice(0);
|
|
45
|
+
// Send diagnostic so it shows in Node.js IPC log
|
|
46
|
+
if (window.ipc && window.ipc.postMessage) {
|
|
47
|
+
window.ipc.postMessage(JSON.stringify({ _action: "_debug", _cbid: "0", info: "flush " + pending.length + " calls: " + pending.map(function(m) { return m._action; }).join(", ") }));
|
|
48
|
+
}
|
|
49
|
+
for (var i = 0; i < pending.length; i++) {
|
|
50
|
+
if (window.ipc && window.ipc.postMessage) {
|
|
51
|
+
window.ipc.postMessage(JSON.stringify(pending[i]));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
36
56
|
// Called by Rust to resolve promises
|
|
37
57
|
window._mailxapiResolve = function(id, value) {
|
|
38
58
|
var cb = _callbacks[id];
|
|
@@ -53,6 +73,11 @@
|
|
|
53
73
|
|
|
54
74
|
// Called by Rust to push events (new mail, sync progress, etc.)
|
|
55
75
|
window._mailxapiEvent = function(event) {
|
|
76
|
+
// "ready" signal from server — flush buffered IPC calls
|
|
77
|
+
if (event && event.type === "ready") {
|
|
78
|
+
flushPending();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
56
81
|
for (var i = 0; i < _eventHandlers.length; i++) {
|
|
57
82
|
try { _eventHandlers[i](event); } catch(e) { /* ignore */ }
|
|
58
83
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.158",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -20,10 +20,10 @@
|
|
|
20
20
|
"postinstall": "node bin/postinstall.js"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@bobfrankston/iflow": "^1.0.
|
|
23
|
+
"@bobfrankston/iflow": "^1.0.54",
|
|
24
24
|
"@bobfrankston/miscinfo": "^1.0.7",
|
|
25
25
|
"@bobfrankston/oauthsupport": "^1.0.20",
|
|
26
|
-
"@bobfrankston/msger": "^0.1.
|
|
26
|
+
"@bobfrankston/msger": "^0.1.208",
|
|
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) {
|
|
@@ -391,6 +394,7 @@ export class ImapManager extends EventEmitter {
|
|
|
391
394
|
this.emit("syncProgress", accountId, `sync:${folder.path}`, 0);
|
|
392
395
|
// Get the highest UID we already have for this folder
|
|
393
396
|
const highestUid = this.db.getHighestUid(accountId, folderId);
|
|
397
|
+
console.log(` [sync] ${accountId}/${folder.path}: highestUid=${highestUid}, fetching...`);
|
|
394
398
|
let messages;
|
|
395
399
|
const firstSync = highestUid === 0;
|
|
396
400
|
const historyDays = getHistoryDays(accountId);
|
|
@@ -595,59 +599,92 @@ export class ImapManager extends EventEmitter {
|
|
|
595
599
|
}
|
|
596
600
|
async _syncAll() {
|
|
597
601
|
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
|
-
|
|
602
|
+
// Sync all accounts in parallel — each manages its own connection
|
|
603
|
+
const syncPromises = [...this.configs.keys()].map(accountId => this.syncAccount(accountId, priorityOrder));
|
|
604
|
+
await Promise.allSettled(syncPromises);
|
|
605
|
+
}
|
|
606
|
+
/** Sync a single account — manages its own connection lifecycle */
|
|
607
|
+
async syncAccount(accountId, priorityOrder) {
|
|
608
|
+
try {
|
|
609
|
+
// Step 1: Get folder list (fast — <1s typically)
|
|
610
|
+
let client = await this.getOpsClient(accountId);
|
|
611
|
+
const t0 = Date.now();
|
|
612
|
+
const folders = await this.syncFolders(accountId, client);
|
|
613
|
+
console.log(` [timing] ${accountId}: folder list ${Date.now() - t0}ms (${folders.length} folders)`);
|
|
614
|
+
// Step 2: Sync INBOX first
|
|
615
|
+
const inbox = folders.find(f => f.specialUse === "inbox");
|
|
616
|
+
if (inbox) {
|
|
617
|
+
console.log(` [sync] ${accountId}: starting INBOX sync (folder ${inbox.id})`);
|
|
618
|
+
try {
|
|
619
|
+
client = await this.getOpsClient(accountId);
|
|
620
|
+
console.log(` [sync] ${accountId}: got client, calling syncFolder for INBOX`);
|
|
621
|
+
await this.syncFolder(accountId, inbox.id, client);
|
|
622
|
+
console.log(` [sync] ${accountId}: INBOX sync complete`);
|
|
623
|
+
}
|
|
624
|
+
catch (e) {
|
|
625
|
+
console.error(` Inbox sync error for ${accountId}: ${e.message}`);
|
|
626
|
+
await this.reconnectOps(accountId);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
else {
|
|
630
|
+
console.log(` [sync] ${accountId}: no INBOX folder found`);
|
|
631
|
+
}
|
|
632
|
+
// Step 3: Sync remaining folders
|
|
633
|
+
const remaining = folders.filter(f => f.specialUse !== "inbox");
|
|
634
|
+
remaining.sort((a, b) => {
|
|
635
|
+
const pa = priorityOrder.indexOf(a.specialUse || "") >= 0 ? priorityOrder.indexOf(a.specialUse || "") : 5;
|
|
636
|
+
const pb = priorityOrder.indexOf(b.specialUse || "") >= 0 ? priorityOrder.indexOf(b.specialUse || "") : 5;
|
|
637
|
+
return pa - pb;
|
|
638
|
+
});
|
|
639
|
+
let consecutiveErrors = 0;
|
|
640
|
+
for (const folder of remaining) {
|
|
641
|
+
const isTrashChild = folder.path.includes("/") && folder.path.toLowerCase().startsWith("trash");
|
|
642
|
+
const highestUid = this.db.getHighestUid(accountId, folder.id);
|
|
643
|
+
if (isTrashChild && highestUid === 0)
|
|
644
|
+
continue;
|
|
645
|
+
try {
|
|
646
|
+
client = await this.getOpsClient(accountId);
|
|
647
|
+
await this.syncFolder(accountId, folder.id, client);
|
|
648
|
+
consecutiveErrors = 0;
|
|
649
|
+
}
|
|
650
|
+
catch (e) {
|
|
651
|
+
consecutiveErrors++;
|
|
652
|
+
if (e.responseText?.includes("doesn't exist")) {
|
|
653
|
+
this.db.deleteFolder(folder.id);
|
|
615
654
|
}
|
|
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
|
-
}
|
|
655
|
+
else {
|
|
656
|
+
console.error(` Skipping ${folder.path}: ${e.message}`);
|
|
657
|
+
// Connection is probably dead — reconnect
|
|
658
|
+
await this.reconnectOps(accountId);
|
|
639
659
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
660
|
+
// Too many consecutive errors = connection fundamentally broken
|
|
661
|
+
if (consecutiveErrors >= 3) {
|
|
662
|
+
console.error(` [sync] ${accountId}: ${consecutiveErrors} consecutive errors — aborting sync`);
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
643
666
|
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
667
|
+
this.accountErrorShown.delete(accountId);
|
|
668
|
+
this.emit("syncComplete", accountId);
|
|
669
|
+
}
|
|
670
|
+
catch (e) {
|
|
671
|
+
const errMsg = imapError(e);
|
|
672
|
+
this.emit("syncError", accountId, errMsg);
|
|
673
|
+
console.error(`Sync error for ${accountId}: ${errMsg}`);
|
|
674
|
+
this.handleSyncError(accountId, errMsg);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
/** Kill and recreate the persistent ops connection */
|
|
678
|
+
async reconnectOps(accountId) {
|
|
679
|
+
const old = this.opsClients.get(accountId);
|
|
680
|
+
this.opsClients.delete(accountId);
|
|
681
|
+
if (old) {
|
|
682
|
+
try {
|
|
683
|
+
await (old._realLogout || old.logout)();
|
|
649
684
|
}
|
|
685
|
+
catch { /* */ }
|
|
650
686
|
}
|
|
687
|
+
console.log(` [conn] ${accountId}: reconnecting`);
|
|
651
688
|
}
|
|
652
689
|
/** Handle sync errors — classify and emit appropriate UI events */
|
|
653
690
|
handleSyncError(accountId, errMsg) {
|
|
@@ -848,19 +885,28 @@ export class ImapManager extends EventEmitter {
|
|
|
848
885
|
if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
|
|
849
886
|
return this.bodyStore.getMessage(accountId, folderId, uid);
|
|
850
887
|
}
|
|
888
|
+
// Body fetch uses a fresh connection — never waits behind background sync
|
|
889
|
+
let client = null;
|
|
851
890
|
try {
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
891
|
+
client = this.newClient(accountId);
|
|
892
|
+
const msg = await client.fetchMessageByUid(folder.path, uid, { source: true });
|
|
893
|
+
await client.logout();
|
|
894
|
+
client = null;
|
|
895
|
+
if (!msg?.source)
|
|
896
|
+
return null;
|
|
897
|
+
const raw = Buffer.from(msg.source, "utf-8");
|
|
898
|
+
const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
|
|
899
|
+
this.db.updateBodyPath(accountId, uid, bodyPath);
|
|
900
|
+
return raw;
|
|
861
901
|
}
|
|
862
902
|
catch (e) {
|
|
863
903
|
console.error(` Body fetch error (${accountId}/${uid}): ${e.message}`);
|
|
904
|
+
if (client) {
|
|
905
|
+
try {
|
|
906
|
+
await client.logout();
|
|
907
|
+
}
|
|
908
|
+
catch { /* */ }
|
|
909
|
+
}
|
|
864
910
|
return null;
|
|
865
911
|
}
|
|
866
912
|
});
|