@bobfrankston/mailx 1.0.352 → 1.0.355

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 CHANGED
@@ -5,7 +5,7 @@
5
5
  import { initFolderTree, refreshFolderTree, updateFolderCounts, setFolderSynced, getFolderSynced } from "./components/folder-tree.js";
6
6
  import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, getSelectedMessages, markBodiesCached } from "./components/message-list.js";
7
7
  import { showMessage, getCurrentMessage, initViewer } from "./components/message-viewer.js";
8
- import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags, markAsSpamMessages } from "./lib/api-client.js";
8
+ import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags, markAsSpamMessages, logClientEvent, sendMessage as apiSendMessage } from "./lib/api-client.js";
9
9
  import * as messageState from "./lib/message-state.js";
10
10
  // ── New message badge (favicon + title) ──
11
11
  let baseTitle = "mailx";
@@ -524,6 +524,7 @@ document.getElementById("btn-factory-reset")?.addEventListener("click", async ()
524
524
  }
525
525
  });
526
526
  async function openCompose(mode) {
527
+ logClientEvent("openCompose-entry", { mode });
527
528
  const current = getCurrentMessage();
528
529
  // Local-first: if the row is selected we already have its headers in the
529
530
  // local DB. Populate the compose form unconditionally; the user can edit
@@ -1143,6 +1144,40 @@ if (ftFilterInput) {
1143
1144
  }
1144
1145
  // ── Open links from email body in system browser ──
1145
1146
  window.addEventListener("message", (e) => {
1147
+ // Relay traces from iframes (compose) to Node via our working bridge.
1148
+ // The iframe calls logClientEvent which tries its own bridge first; if
1149
+ // that path is broken it also posts here as backup. Tag gets a `via-relay`
1150
+ // suffix when the iframe couldn't reach its own bridge — that alone
1151
+ // diagnoses whether the iframe bridge works.
1152
+ if (e.data?.type === "mailx-trace" && typeof e.data.tag === "string") {
1153
+ const relayTag = e.data.bridged ? e.data.tag : `${e.data.tag} (via-relay)`;
1154
+ logClientEvent(relayTag, e.data.data);
1155
+ return;
1156
+ }
1157
+ // Compose-send relay: iframe posts the send request here because its own
1158
+ // bridge call to sendMessage was failing to reach Node. This window's
1159
+ // bridge is proven (getAccounts / getOutboxStatus run every few seconds
1160
+ // with no failures), so we do the IPC from here and post the result back
1161
+ // to the iframe via its source. `e.source` is the iframe's window; use it
1162
+ // so targeting works even if the iframe moves in the DOM.
1163
+ if (e.data?.type === "mailx-compose-send" && e.data.id && e.data.body) {
1164
+ const src = e.source;
1165
+ const id = e.data.id;
1166
+ logClientEvent("relay-compose-send-received", { id });
1167
+ (async () => {
1168
+ try {
1169
+ await apiSendMessage(e.data.body);
1170
+ logClientEvent("relay-compose-send-ok", { id });
1171
+ src?.postMessage({ type: "mailx-compose-send-result", id, ok: true }, "*");
1172
+ }
1173
+ catch (err) {
1174
+ const msg = err?.message || String(err);
1175
+ logClientEvent("relay-compose-send-error", { id, error: msg });
1176
+ src?.postMessage({ type: "mailx-compose-send-result", id, ok: false, error: msg }, "*");
1177
+ }
1178
+ })();
1179
+ return;
1180
+ }
1146
1181
  if (e.data?.type === "openLink" && e.data.url) {
1147
1182
  window.open(e.data.url, "_blank", "noopener,noreferrer");
1148
1183
  }
@@ -75,6 +75,14 @@ body {
75
75
  padding: var(--gap-xs) 0;
76
76
  }
77
77
 
78
+ /* HTML `hidden` attribute is display:none by UA default, but the `display:
79
+ flex` above wins specificity-wise and the row stays visible. Restore the
80
+ expected hide behavior — the Cc/Bcc toggle buttons flip `hidden` to
81
+ reveal/conceal the rows, which is pointless if CSS forces them on. */
82
+ .compose-field[hidden] {
83
+ display: none;
84
+ }
85
+
78
86
  .compose-field label {
79
87
  flex: 0 0 60px;
80
88
  font-size: var(--font-size-sm);
@@ -4,9 +4,13 @@
4
4
  * Receives init data via window.opener.postMessage or URL params.
5
5
  */
6
6
  import { createEditor } from "./editor.js";
7
- import { getSettings, getAccounts, searchContacts, sendMessage, saveDraft as apiSaveDraft, deleteDraft } from "../lib/api-client.js";
7
+ import { getSettings, getAccounts, searchContacts, saveDraft as apiSaveDraft, deleteDraft, logClientEvent } from "../lib/api-client.js";
8
+ // Very first line the iframe runs — if this doesn't reach Node, the iframe
9
+ // itself isn't loading or the bridge is completely broken.
10
+ logClientEvent("compose-module-loaded", { href: location.href, version: window.mailxVersion || "?" });
8
11
  /** Close compose window */
9
12
  function closeCompose() {
13
+ logClientEvent("compose-close");
10
14
  window.close();
11
15
  }
12
16
  // ── Load editor scripts dynamically ──
@@ -576,6 +580,11 @@ window.addEventListener("blur", () => {
576
580
  catch { /* */ }
577
581
  });
578
582
  document.getElementById("btn-send")?.addEventListener("click", () => {
583
+ // Loud tracing through the whole send pipeline. Every step ships a
584
+ // `[client] compose-send-*` event to the Node log so a "vanished message"
585
+ // report can be traced end-to-end without devtools. If the log stops
586
+ // at any step, that's where the pipeline broke.
587
+ logClientEvent("compose-send-click");
579
588
  const body = {
580
589
  from: getFromAccountId(),
581
590
  fromAddress: getFromAddress(),
@@ -587,10 +596,12 @@ document.getElementById("btn-send")?.addEventListener("click", () => {
587
596
  bodyText: editor.getText(),
588
597
  attachments: attachments.map(a => ({ filename: a.filename, mimeType: a.mimeType, dataBase64: a.dataBase64 })),
589
598
  };
599
+ logClientEvent("compose-send-body-built", { from: body.from, toCount: body.to.length, subjectLen: (body.subject || "").length, bodyHtmlLen: (body.bodyHtml || "").length, atts: body.attachments.length });
590
600
  // Local validity (one missing-To check) — must run before close so the
591
601
  // user gets an inline error instead of silent loss. Anything else (real
592
602
  // address validation, MIME assembly, disk write) happens server-side.
593
603
  if (!body.to.length) {
604
+ logClientEvent("compose-send-rejected-no-to");
594
605
  alert("Please add at least one To recipient.");
595
606
  return;
596
607
  }
@@ -610,25 +621,49 @@ document.getElementById("btn-send")?.addEventListener("click", () => {
610
621
  if (statusEl)
611
622
  statusEl.textContent = "";
612
623
  const sendStart = Date.now();
613
- let ipcPromise;
614
- try {
615
- ipcPromise = sendMessage(body);
616
- }
617
- catch (e) {
618
- const msg = e?.message || String(e);
619
- console.error(`[compose] Send threw synchronously: ${msg}`);
620
- if (sendBtn) {
621
- sendBtn.disabled = false;
622
- sendBtn.textContent = "Send";
624
+ logClientEvent("compose-send-pre-ipc");
625
+ // Parent-window relay for send. Empirical observation: Android (direct
626
+ // in-process SMTP) sends reliably; desktop (iframe → parent.mailxapi →
627
+ // msger → Node → service) has sendMessage IPCs failing to reach Node
628
+ // for reasons still unknown (iframe bridge behaves differently from the
629
+ // top frame in msger's WebView2 for this specific call). Meanwhile the
630
+ // parent window's bridge is proven — getAccounts / getOutboxStatus run
631
+ // through it every few seconds with no failures.
632
+ //
633
+ // Fix: the iframe doesn't touch the bridge at all for send. It posts
634
+ // a request to the parent, and the parent calls the real sendMessage
635
+ // from its own frame. Parent posts the result back. This bypasses
636
+ // whatever is wrong with iframe-scoped IPC.
637
+ const ipcPromise = new Promise((resolve, reject) => {
638
+ const reqId = `send-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
639
+ const timer = setTimeout(() => {
640
+ window.removeEventListener("message", onMsg);
641
+ reject(new Error("parent-relay send timeout (120s)"));
642
+ }, 120000);
643
+ const onMsg = (ev) => {
644
+ if (!ev.data || ev.data.type !== "mailx-compose-send-result" || ev.data.id !== reqId)
645
+ return;
646
+ clearTimeout(timer);
647
+ window.removeEventListener("message", onMsg);
648
+ if (ev.data.ok)
649
+ resolve();
650
+ else
651
+ reject(new Error(ev.data.error || "unknown"));
652
+ };
653
+ window.addEventListener("message", onMsg);
654
+ try {
655
+ parent.postMessage({ type: "mailx-compose-send", id: reqId, body }, "*");
656
+ logClientEvent("compose-send-ipc-invoked", { via: "parent-relay", reqId });
623
657
  }
624
- if (statusEl)
625
- statusEl.textContent = `Send failed: ${msg}`;
626
- else
627
- alert(`Send failed: ${msg}`);
628
- return;
629
- }
658
+ catch (e) {
659
+ clearTimeout(timer);
660
+ window.removeEventListener("message", onMsg);
661
+ reject(e);
662
+ }
663
+ });
630
664
  Promise.resolve(ipcPromise)
631
665
  .then(() => {
666
+ logClientEvent("compose-send-ipc-resolved", { ms: Date.now() - sendStart });
632
667
  console.log(`[compose] Send IPC returned OK in ${Date.now() - sendStart}ms`);
633
668
  // Stop autosave only after ACK — if send threw we want the draft
634
669
  // autosave to keep the message safe.
@@ -643,6 +678,7 @@ document.getElementById("btn-send")?.addEventListener("click", () => {
643
678
  })
644
679
  .catch((e) => {
645
680
  const msg = e?.message || String(e);
681
+ logClientEvent("compose-send-ipc-rejected", { error: msg, ms: Date.now() - sendStart });
646
682
  console.error(`[compose] Send IPC failed after ${Date.now() - sendStart}ms: ${msg}`);
647
683
  if (sendBtn) {
648
684
  sendBtn.disabled = false;
@@ -129,6 +129,34 @@ export function deleteFolder(accountId, folderId) {
129
129
  export function emptyFolder(accountId, folderId) {
130
130
  return ipc().emptyFolder?.(accountId, folderId);
131
131
  }
132
+ /** Ship a named event to the Node log as `[client] <tag> <data>`. Fire and
133
+ * forget — never awaits, never throws, never blocks the caller. Tries two
134
+ * paths so a broken primary channel can't swallow the trace:
135
+ * 1. Direct bridge call (self / opener / parent mailxapi).
136
+ * 2. parent.postMessage fallback — the main window listens and relays.
137
+ * The fallback matters because the whole point of tracing is to diagnose
138
+ * a broken iframe bridge; a single-path tracer that goes through that same
139
+ * bridge is useless in exactly the case we need it. */
140
+ export function logClientEvent(tag, data) {
141
+ let delivered = false;
142
+ try {
143
+ const bridge = typeof globalThis.mailxapi !== "undefined" && globalThis.mailxapi?.isApp ? globalThis.mailxapi
144
+ : window.opener?.mailxapi?.isApp ? window.opener.mailxapi
145
+ : window.parent?.mailxapi?.isApp ? window.parent.mailxapi
146
+ : null;
147
+ if (bridge?.logClientEvent) {
148
+ bridge.logClientEvent(tag, data);
149
+ delivered = true;
150
+ }
151
+ }
152
+ catch { /* never throw from tracing */ }
153
+ try {
154
+ if (window.parent && window.parent !== window) {
155
+ window.parent.postMessage({ type: "mailx-trace", tag, data, bridged: delivered }, "*");
156
+ }
157
+ }
158
+ catch { /* */ }
159
+ }
132
160
  export function sendMessage(body) {
133
161
  return ipc().sendMessage?.(body);
134
162
  }
@@ -86,6 +86,12 @@
86
86
  return callNode("undeleteMessage", { accountId: accountId, uid: uid, folderId: folderId });
87
87
  },
88
88
 
89
+ // Diagnostic tracing — callers (compose iframe, app, components) can
90
+ // ship arbitrary named events to the Node log. Surfaces as
91
+ // `[client] <tag> <data>` so a failing pipeline can be traced end
92
+ // to end in a single log file.
93
+ logClientEvent: function(tag, data) { return callNode("logClientEvent", { tag: tag, data: data }); },
94
+
89
95
  // Compose
90
96
  sendMessage: function(msg) { return callNode("sendMessage", msg); },
91
97
  saveDraft: function(params) { return callNode("saveDraft", params); },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.352",
3
+ "version": "1.0.355",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -128,6 +128,14 @@ async function dispatchAction(svc, action, p) {
128
128
  return { content: await svc.readConfigHelp(p.name) };
129
129
  case "unsubscribeOneClick":
130
130
  return await svc.unsubscribeOneClick(p.url);
131
+ // Client-side tracing — lets webview / iframe code ship events to the
132
+ // Node log so a "compose→send→vanished" report can be diagnosed without
133
+ // opening devtools. Every call shows up as `[client] <tag> <data>` in
134
+ // the main log. Keep it on the top of the switch so it's cheap + first
135
+ // to dispatch.
136
+ case "logClientEvent":
137
+ console.log(` [client] ${p.tag || "?"}${p.data ? " " + JSON.stringify(p.data).slice(0, 400) : ""}`);
138
+ return { ok: true };
131
139
  // Settings
132
140
  case "getSettings":
133
141
  return svc.getSettings();