@bobfrankston/mailx 1.0.253 → 1.0.260
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 +112 -0
- package/client/.msger-window.json +1 -1
- package/client/components/message-viewer.js +82 -7
- package/package.json +9 -8
- package/packages/mailx-imap/index.d.ts +6 -0
- package/packages/mailx-imap/index.js +244 -57
- package/packages/mailx-imap/package.json +2 -1
- package/packages/mailx-imap/providers/gmail-api.d.ts +5 -29
- package/packages/mailx-imap/providers/gmail-api.js +5 -286
- package/packages/mailx-imap/providers/types.d.ts +6 -59
- package/packages/mailx-imap/providers/types.js +5 -2
- package/packages/mailx-service/index.d.ts +0 -4
- package/packages/mailx-service/index.js +18 -62
- package/packages/mailx-store-web/android-bootstrap.js +37 -22
- package/packages/mailx-store-web/db.js +8 -7
- package/packages/mailx-store-web/gmail-api-web.d.ts +7 -33
- package/packages/mailx-store-web/gmail-api-web.js +7 -258
- package/packages/mailx-store-web/imap-web-provider.d.ts +1 -1
- package/packages/mailx-store-web/imap-web-provider.js +2 -2
- package/packages/mailx-store-web/main-thread-host.d.ts +15 -0
- package/packages/mailx-store-web/main-thread-host.js +287 -0
- package/packages/mailx-store-web/package.json +2 -1
- package/packages/mailx-store-web/provider-types.d.ts +4 -47
- package/packages/mailx-store-web/provider-types.js +3 -3
- package/packages/mailx-store-web/sync-manager.d.ts +61 -0
- package/packages/mailx-store-web/sync-manager.js +422 -0
- package/packages/mailx-store-web/web-service.d.ts +0 -4
- package/packages/mailx-store-web/web-service.js +1 -59
- package/packages/mailx-store-web/worker-entry.d.ts +8 -0
- package/packages/mailx-store-web/worker-entry.js +187 -0
- package/packages/mailx-store-web/worker-tcp-transport.d.ts +28 -0
- package/packages/mailx-store-web/worker-tcp-transport.js +98 -0
- package/packages/mailx-types/index.d.ts +14 -0
- package/packages/mailx-types/index.js +96 -1
package/bin/mailx.js
CHANGED
|
@@ -28,6 +28,92 @@ const args = process.argv.slice(2);
|
|
|
28
28
|
function hasFlag(name) { return args.includes(`-${name}`) || args.includes(`--${name}`); }
|
|
29
29
|
const verbose = hasFlag("verbose");
|
|
30
30
|
const isDaemon = hasFlag("daemon"); // internal: re-spawned detached process
|
|
31
|
+
// Read our own version once — used for the instance file + upgrade check below.
|
|
32
|
+
const __selfRoot = path.join(import.meta.dirname, "..");
|
|
33
|
+
const __selfVersion = (() => {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(fs.readFileSync(path.join(__selfRoot, "package.json"), "utf-8")).version || "unknown";
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return "unknown";
|
|
39
|
+
}
|
|
40
|
+
})();
|
|
41
|
+
const __instanceFile = path.join(process.env.USERPROFILE || process.env.HOME || ".", ".mailx", "instance.json");
|
|
42
|
+
function readInstanceFile() {
|
|
43
|
+
try {
|
|
44
|
+
const raw = fs.readFileSync(__instanceFile, "utf-8");
|
|
45
|
+
const inst = JSON.parse(raw);
|
|
46
|
+
if (typeof inst.pid === "number" && typeof inst.version === "string")
|
|
47
|
+
return inst;
|
|
48
|
+
}
|
|
49
|
+
catch { /* missing or unreadable — treated as no instance */ }
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
function writeInstanceFile(pid) {
|
|
53
|
+
try {
|
|
54
|
+
fs.mkdirSync(path.dirname(__instanceFile), { recursive: true });
|
|
55
|
+
fs.writeFileSync(__instanceFile, JSON.stringify({ pid, version: __selfVersion, startedAt: Date.now() }, null, 2));
|
|
56
|
+
}
|
|
57
|
+
catch { /* non-fatal */ }
|
|
58
|
+
}
|
|
59
|
+
function clearInstanceFile() {
|
|
60
|
+
try {
|
|
61
|
+
fs.unlinkSync(__instanceFile);
|
|
62
|
+
}
|
|
63
|
+
catch { /* ignore */ }
|
|
64
|
+
}
|
|
65
|
+
function pidAlive(pid) {
|
|
66
|
+
try {
|
|
67
|
+
process.kill(pid, 0);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Version-mismatch upgrade: if a daemon from an older version is running when
|
|
75
|
+
// the user types `mailx`, kill it so the new one can take over. Without this,
|
|
76
|
+
// a second invocation would silently no-op (daemon exists), leaving the user
|
|
77
|
+
// on an old UI with no indication that the install has been upgraded.
|
|
78
|
+
// Skip this logic for command-only flags (kill, rebuild, setup, ...) and for
|
|
79
|
+
// the internal --daemon respawn.
|
|
80
|
+
const __commandFlags = ["kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "import", "log"];
|
|
81
|
+
const __isCommandInvocation = process.argv.slice(2).some(a => __commandFlags.includes(a.replace(/^--?/, "")));
|
|
82
|
+
if (!isDaemon && !__isCommandInvocation) {
|
|
83
|
+
const inst = readInstanceFile();
|
|
84
|
+
if (inst && pidAlive(inst.pid)) {
|
|
85
|
+
if (inst.version !== __selfVersion) {
|
|
86
|
+
console.log(`mailx: upgrading running daemon (PID ${inst.pid}) from v${inst.version} → v${__selfVersion}`);
|
|
87
|
+
try {
|
|
88
|
+
process.kill(inst.pid, "SIGTERM");
|
|
89
|
+
}
|
|
90
|
+
catch { /* already gone */ }
|
|
91
|
+
// Give it ~1.5s to exit gracefully, then verify
|
|
92
|
+
const deadline = Date.now() + 2000;
|
|
93
|
+
while (Date.now() < deadline && pidAlive(inst.pid)) {
|
|
94
|
+
const sab = new SharedArrayBuffer(4);
|
|
95
|
+
Atomics.wait(new Int32Array(sab), 0, 0, 100); // 100ms nap
|
|
96
|
+
}
|
|
97
|
+
if (pidAlive(inst.pid)) {
|
|
98
|
+
try {
|
|
99
|
+
process.kill(inst.pid, "SIGKILL");
|
|
100
|
+
}
|
|
101
|
+
catch { /* */ }
|
|
102
|
+
}
|
|
103
|
+
clearInstanceFile();
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
// Same version already running — nothing to do. Print so the user
|
|
107
|
+
// knows why `mailx` seems to have done nothing.
|
|
108
|
+
console.log(`mailx v${__selfVersion} is already running (PID ${inst.pid}). Use mailx -kill to stop it.`);
|
|
109
|
+
process.exit(0);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else if (inst) {
|
|
113
|
+
// Stale instance file — PID is dead. Clean up.
|
|
114
|
+
clearInstanceFile();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
31
117
|
// Auto-detach: re-spawn as background process so terminal returns immediately
|
|
32
118
|
// Skip for: --verbose (want console), --daemon (already detached),
|
|
33
119
|
// and any command flags (setup, kill, test, etc.)
|
|
@@ -675,6 +761,24 @@ async function main() {
|
|
|
675
761
|
const home = process.env.USERPROFILE || process.env.HOME || ".";
|
|
676
762
|
const logDir = path.join(home, ".mailx", "logs");
|
|
677
763
|
fs.mkdirSync(logDir, { recursive: true });
|
|
764
|
+
// Prune logs older than LOG_RETENTION_DAYS on startup. Keep it simple:
|
|
765
|
+
// scan the dir, stat, delete. Cheap even with years of history.
|
|
766
|
+
const LOG_RETENTION_DAYS = 7;
|
|
767
|
+
const cutoff = Date.now() - LOG_RETENTION_DAYS * 86400000;
|
|
768
|
+
try {
|
|
769
|
+
for (const name of fs.readdirSync(logDir)) {
|
|
770
|
+
if (!/^mailx-\d{4}-\d{2}-\d{2}\.log$/.test(name))
|
|
771
|
+
continue;
|
|
772
|
+
const full = path.join(logDir, name);
|
|
773
|
+
try {
|
|
774
|
+
const st = fs.statSync(full);
|
|
775
|
+
if (st.mtimeMs < cutoff)
|
|
776
|
+
fs.unlinkSync(full);
|
|
777
|
+
}
|
|
778
|
+
catch { /* ignore per-file error */ }
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
catch { /* ignore — log pruning is best-effort */ }
|
|
678
782
|
const logDate = new Date().toISOString().slice(0, 10);
|
|
679
783
|
const logPath = path.join(logDir, `mailx-${logDate}.log`);
|
|
680
784
|
const logStream = fs.createWriteStream(logPath, { flags: "a" });
|
|
@@ -716,6 +820,14 @@ async function main() {
|
|
|
716
820
|
size: { width: 1400, height: 900 },
|
|
717
821
|
escapeCloses: false,
|
|
718
822
|
});
|
|
823
|
+
// Register ourselves as the live instance so subsequent `mailx` invocations
|
|
824
|
+
// can detect version-mismatch and upgrade us (see top of file). Clear on
|
|
825
|
+
// any of: SIGINT, SIGTERM, normal exit.
|
|
826
|
+
writeInstanceFile(process.pid);
|
|
827
|
+
const __cleanupInstance = () => { clearInstanceFile(); };
|
|
828
|
+
process.once("exit", __cleanupInstance);
|
|
829
|
+
process.once("SIGINT", () => { __cleanupInstance(); process.exit(0); });
|
|
830
|
+
process.once("SIGTERM", () => { __cleanupInstance(); process.exit(0); });
|
|
719
831
|
// Handle requests from WebView → dispatch to MailxService
|
|
720
832
|
// Pass server version to dispatch so getVersion returns it
|
|
721
833
|
const rootPkg = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, "..", "package.json"), "utf-8"));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"height":1344,"width":2151,"x":
|
|
1
|
+
{"height":1344,"width":2151,"x":707,"y":89}
|
|
@@ -74,6 +74,7 @@ function installPreviewControls(iframe) {
|
|
|
74
74
|
const target = e.target;
|
|
75
75
|
if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)))
|
|
76
76
|
return;
|
|
77
|
+
// Zoom is iframe-local — handle here, don't forward.
|
|
77
78
|
if (e.ctrlKey && (e.key === "=" || e.key === "+")) {
|
|
78
79
|
e.preventDefault();
|
|
79
80
|
setZoom(previewZoom + ZOOM_STEP, doc);
|
|
@@ -89,11 +90,23 @@ function installPreviewControls(iframe) {
|
|
|
89
90
|
setZoom(1, doc);
|
|
90
91
|
return;
|
|
91
92
|
}
|
|
92
|
-
|
|
93
|
+
// Forward EVERY keydown to the parent — no duplicated hotkey list.
|
|
94
|
+
// If the parent's handler calls preventDefault (because it owns the
|
|
95
|
+
// shortcut), dispatchEvent returns false, and we preventDefault on
|
|
96
|
+
// the iframe side too so the browser doesn't ALSO act on it
|
|
97
|
+
// (Ctrl+N otherwise pops a new browser window in some hosts).
|
|
98
|
+
// Single source of truth = app.ts hotkey handlers. Plain typing in
|
|
99
|
+
// the email body — letters, etc. — propagates with no parent
|
|
100
|
+
// handler matching, so dispatchEvent returns true and the iframe
|
|
101
|
+
// event is left alone.
|
|
102
|
+
const synth = new KeyboardEvent("keydown", {
|
|
93
103
|
key: e.key, code: e.code,
|
|
94
104
|
ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, metaKey: e.metaKey,
|
|
95
105
|
bubbles: true, cancelable: true,
|
|
96
|
-
})
|
|
106
|
+
});
|
|
107
|
+
const allowDefault = document.dispatchEvent(synth);
|
|
108
|
+
if (!allowDefault)
|
|
109
|
+
e.preventDefault();
|
|
97
110
|
});
|
|
98
111
|
doc.addEventListener("wheel", (e) => {
|
|
99
112
|
if (!e.ctrlKey)
|
|
@@ -412,6 +425,60 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
412
425
|
loadRemote();
|
|
413
426
|
});
|
|
414
427
|
}
|
|
428
|
+
// Body fetch error — show banner above (empty) body instead of polluting
|
|
429
|
+
// the main content area with the error text. Transient errors get a retry.
|
|
430
|
+
if (msg.bodyError) {
|
|
431
|
+
const err = String(msg.bodyError);
|
|
432
|
+
const isTransient = !!msg.bodyErrorTransient;
|
|
433
|
+
const errBanner = document.createElement("div");
|
|
434
|
+
errBanner.className = "mv-error-banner";
|
|
435
|
+
errBanner.style.cssText = "margin:1rem;padding:0.75rem 1rem;border:1px solid var(--color-border);border-left:3px solid #d33;background:var(--color-bg-surface);border-radius:4px;font-size:var(--font-size-sm)";
|
|
436
|
+
errBanner.innerHTML = `
|
|
437
|
+
<div style="font-weight:600;margin-bottom:0.25rem;color:#d33">Body unavailable</div>
|
|
438
|
+
<div style="color:var(--color-text-muted);white-space:pre-wrap;word-break:break-word">${err.replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c] || c))}</div>
|
|
439
|
+
${isTransient ? `<button id="btn-retry-body" style="margin-top:0.5rem;padding:0.25rem 0.75rem">Retry</button>` : ""}
|
|
440
|
+
`;
|
|
441
|
+
bodyEl.appendChild(errBanner);
|
|
442
|
+
if (isTransient) {
|
|
443
|
+
errBanner.querySelector("#btn-retry-body")?.addEventListener("click", async () => {
|
|
444
|
+
errBanner.remove();
|
|
445
|
+
bodyEl.innerHTML = `<div class="mv-empty">Fetching message body...</div>`;
|
|
446
|
+
try {
|
|
447
|
+
const retry = await getMessage(accountId, uid, false);
|
|
448
|
+
if (retry.bodyError) {
|
|
449
|
+
// Still failing — rebuild the error banner via recursive render.
|
|
450
|
+
showMessage(accountId, uid, folderId, specialUse).catch(() => { });
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
bodyEl.innerHTML = "";
|
|
454
|
+
if (retry.bodyHtml) {
|
|
455
|
+
const iframe = document.createElement("iframe");
|
|
456
|
+
iframe.sandbox.add("allow-same-origin");
|
|
457
|
+
iframe.sandbox.add("allow-popups");
|
|
458
|
+
iframe.sandbox.add("allow-popups-to-escape-sandbox");
|
|
459
|
+
iframe.sandbox.add("allow-top-navigation-by-user-activation");
|
|
460
|
+
iframe.sandbox.add("allow-scripts");
|
|
461
|
+
iframe.srcdoc = wrapHtmlBody(retry.bodyHtml, retry.remoteAllowed);
|
|
462
|
+
bodyEl.appendChild(iframe);
|
|
463
|
+
installPreviewControls(iframe);
|
|
464
|
+
}
|
|
465
|
+
else if (retry.bodyText) {
|
|
466
|
+
const pre = document.createElement("pre");
|
|
467
|
+
pre.style.cssText = "padding: 1rem; white-space: pre-wrap; word-break: break-word;";
|
|
468
|
+
pre.innerHTML = linkifyText(retry.bodyText);
|
|
469
|
+
bodyEl.appendChild(pre);
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
bodyEl.innerHTML = `<div class="mv-empty">No content</div>`;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
catch (e) {
|
|
476
|
+
bodyEl.innerHTML = `<div class="mv-empty">Retry failed: ${e.message}</div>`;
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
415
482
|
// Body in sandboxed iframe
|
|
416
483
|
if (msg.bodyHtml) {
|
|
417
484
|
const iframe = document.createElement("iframe");
|
|
@@ -455,11 +522,19 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
455
522
|
e.preventDefault();
|
|
456
523
|
try {
|
|
457
524
|
const data = await getAttachment(accountId, uid, i, msg.folderId);
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
525
|
+
const bridge = window._nativeBridge;
|
|
526
|
+
if (bridge?.openAttachment) {
|
|
527
|
+
// Android: blob URLs don't work in WebView. Pass base64
|
|
528
|
+
// to native bridge which saves to Downloads and opens
|
|
529
|
+
// with the system viewer.
|
|
530
|
+
await bridge.openAttachment(att.filename, data.contentType, data.content);
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
const bytes = Uint8Array.from(atob(data.content), c => c.charCodeAt(0));
|
|
534
|
+
const blob = new Blob([bytes], { type: data.contentType });
|
|
535
|
+
const url = URL.createObjectURL(blob);
|
|
536
|
+
window.open(url, "_blank");
|
|
537
|
+
}
|
|
463
538
|
}
|
|
464
539
|
catch (err) {
|
|
465
540
|
console.error(`Attachment download failed: ${err.message}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.260",
|
|
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,11 +20,11 @@
|
|
|
20
20
|
"postinstall": "node bin/postinstall.js"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
23
|
+
"@bobfrankston/iflow-direct": "^0.1.19",
|
|
24
24
|
"@bobfrankston/iflow-node": "^0.1.5",
|
|
25
25
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
26
|
-
"@bobfrankston/oauthsupport": "^1.0.
|
|
27
|
-
"@bobfrankston/msger": "^0.1.
|
|
26
|
+
"@bobfrankston/oauthsupport": "^1.0.24",
|
|
27
|
+
"@bobfrankston/msger": "^0.1.316",
|
|
28
28
|
"@capacitor/android": "^8.3.0",
|
|
29
29
|
"@capacitor/cli": "^8.3.0",
|
|
30
30
|
"@capacitor/core": "^8.3.0",
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
"sql.js": "^1.14.1",
|
|
38
38
|
"@bobfrankston/tcp-transport": "^0.1.3",
|
|
39
39
|
"@bobfrankston/node-tcp-transport": "^0.1.1",
|
|
40
|
-
"@bobfrankston/smtp-direct": "^0.1.2"
|
|
40
|
+
"@bobfrankston/smtp-direct": "^0.1.2",
|
|
41
|
+
"@bobfrankston/mailx-sync": "file:../../../MailApps/mailx-sync"
|
|
41
42
|
},
|
|
42
43
|
"devDependencies": {
|
|
43
44
|
"@types/mailparser": "^3.4.6"
|
|
@@ -80,11 +81,11 @@
|
|
|
80
81
|
},
|
|
81
82
|
".transformedSnapshot": {
|
|
82
83
|
"dependencies": {
|
|
83
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
84
|
+
"@bobfrankston/iflow-direct": "^0.1.19",
|
|
84
85
|
"@bobfrankston/iflow-node": "^0.1.5",
|
|
85
86
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
86
|
-
"@bobfrankston/oauthsupport": "^1.0.
|
|
87
|
-
"@bobfrankston/msger": "^0.1.
|
|
87
|
+
"@bobfrankston/oauthsupport": "^1.0.24",
|
|
88
|
+
"@bobfrankston/msger": "^0.1.316",
|
|
88
89
|
"@capacitor/android": "^8.3.0",
|
|
89
90
|
"@capacitor/cli": "^8.3.0",
|
|
90
91
|
"@capacitor/core": "^8.3.0",
|
|
@@ -147,7 +147,13 @@ export declare class ImapManager extends EventEmitter {
|
|
|
147
147
|
* stale row locally and keep going. Only unrelated errors (network,
|
|
148
148
|
* auth, rate limits) count against the error budget, and the budget is
|
|
149
149
|
* generous so a few transient failures don't kill the whole run. */
|
|
150
|
+
/** Guard against concurrent prefetchBodies for the same account — mirror of
|
|
151
|
+
* `sendingAccounts`. Without this, every periodic-sync tick spawns a new
|
|
152
|
+
* prefetch session alongside any still in flight, blowing through Gmail's
|
|
153
|
+
* per-minute quota and racing on disk writes. One prefetch per account. */
|
|
154
|
+
private prefetchingAccounts;
|
|
150
155
|
private prefetchBodies;
|
|
156
|
+
private _prefetchBodies;
|
|
151
157
|
/** Get the body store for direct access */
|
|
152
158
|
getBodyStore(): FileMessageStore;
|
|
153
159
|
/** Bulk trash messages — local-first, single IMAP connection for all */
|