@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.
Files changed (34) hide show
  1. package/bin/mailx.js +112 -0
  2. package/client/.msger-window.json +1 -1
  3. package/client/components/message-viewer.js +82 -7
  4. package/package.json +9 -8
  5. package/packages/mailx-imap/index.d.ts +6 -0
  6. package/packages/mailx-imap/index.js +244 -57
  7. package/packages/mailx-imap/package.json +2 -1
  8. package/packages/mailx-imap/providers/gmail-api.d.ts +5 -29
  9. package/packages/mailx-imap/providers/gmail-api.js +5 -286
  10. package/packages/mailx-imap/providers/types.d.ts +6 -59
  11. package/packages/mailx-imap/providers/types.js +5 -2
  12. package/packages/mailx-service/index.d.ts +0 -4
  13. package/packages/mailx-service/index.js +18 -62
  14. package/packages/mailx-store-web/android-bootstrap.js +37 -22
  15. package/packages/mailx-store-web/db.js +8 -7
  16. package/packages/mailx-store-web/gmail-api-web.d.ts +7 -33
  17. package/packages/mailx-store-web/gmail-api-web.js +7 -258
  18. package/packages/mailx-store-web/imap-web-provider.d.ts +1 -1
  19. package/packages/mailx-store-web/imap-web-provider.js +2 -2
  20. package/packages/mailx-store-web/main-thread-host.d.ts +15 -0
  21. package/packages/mailx-store-web/main-thread-host.js +287 -0
  22. package/packages/mailx-store-web/package.json +2 -1
  23. package/packages/mailx-store-web/provider-types.d.ts +4 -47
  24. package/packages/mailx-store-web/provider-types.js +3 -3
  25. package/packages/mailx-store-web/sync-manager.d.ts +61 -0
  26. package/packages/mailx-store-web/sync-manager.js +422 -0
  27. package/packages/mailx-store-web/web-service.d.ts +0 -4
  28. package/packages/mailx-store-web/web-service.js +1 -59
  29. package/packages/mailx-store-web/worker-entry.d.ts +8 -0
  30. package/packages/mailx-store-web/worker-entry.js +187 -0
  31. package/packages/mailx-store-web/worker-tcp-transport.d.ts +28 -0
  32. package/packages/mailx-store-web/worker-tcp-transport.js +98 -0
  33. package/packages/mailx-types/index.d.ts +14 -0
  34. 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":380,"y":73}
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
- document.dispatchEvent(new KeyboardEvent("keydown", {
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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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
- // Create blob URL and open
459
- const bytes = Uint8Array.from(atob(data.content), c => c.charCodeAt(0));
460
- const blob = new Blob([bytes], { type: data.contentType });
461
- const url = URL.createObjectURL(blob);
462
- window.open(url, "_blank");
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.253",
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.15",
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.23",
27
- "@bobfrankston/msger": "^0.1.314",
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.15",
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.23",
87
- "@bobfrankston/msger": "^0.1.314",
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 */