@bobfrankston/rmfmail 1.2.80 → 1.2.82

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.
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Popout server — a tiny loopback HTTP server that renders a single message in
3
+ * its own OS window (`window.open` from the client points a real browser window
4
+ * at it). msger's custom protocol doesn't propagate to child windows, so a
5
+ * separate window can't use the normal IPC client; this serves a self-contained
6
+ * read view instead.
7
+ *
8
+ * Transport choice (Bob 2026-06-29): loopback TCP on an OS-assigned EPHEMERAL
9
+ * port (`listen(0)`) — no hardcoded port to collide, identical on every
10
+ * platform. A browser window can't load HTTP over a named pipe / Unix socket,
11
+ * so pipes aren't an option here (they'd suit a CLI/IPC client). Loopback TCP is
12
+ * reachable by any local process, so every route is gated on a per-launch random
13
+ * token. Future: the `msgapi` channel could serve this without HTTP at all.
14
+ *
15
+ * Cut 1 = read + links (links POST back here → svc.openExternal → OS browser).
16
+ * Toolbar actions (reply/forward/delete/flag) are a follow-up.
17
+ */
18
+ import http from "node:http";
19
+ import crypto from "node:crypto";
20
+ /** svc must expose getMessage(accountId, uid, allowRemote, folderId) and
21
+ * openExternal(url). Returns null if the server can't bind (popout disabled). */
22
+ export async function startPopoutServer(svc) {
23
+ const token = crypto.randomBytes(18).toString("hex");
24
+ const server = http.createServer(async (req, res) => {
25
+ try {
26
+ const url = new URL(req.url || "/", "http://127.0.0.1");
27
+ // Token gate on every request — loopback TCP is locally reachable.
28
+ if (url.searchParams.get("t") !== token) {
29
+ res.writeHead(403).end("forbidden");
30
+ return;
31
+ }
32
+ const a = url.searchParams.get("a") || "";
33
+ const u = Number(url.searchParams.get("u"));
34
+ const fRaw = url.searchParams.get("f");
35
+ const f = fRaw != null && fRaw !== "" ? Number(fRaw) : undefined;
36
+ if (req.method === "GET" && url.pathname === "/v") {
37
+ const msg = await svc.getMessage(a, u, false, f);
38
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
39
+ res.end(renderPage(msg, a, u, f, token));
40
+ return;
41
+ }
42
+ if (req.method === "GET" && url.pathname === "/body") {
43
+ const msg = await svc.getMessage(a, u, false, f);
44
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
45
+ res.end(renderBody(msg));
46
+ return;
47
+ }
48
+ if (req.method === "POST" && url.pathname === "/open") {
49
+ let body = "";
50
+ for await (const chunk of req)
51
+ body += chunk;
52
+ let link = "";
53
+ try {
54
+ link = (JSON.parse(body || "{}").url) || "";
55
+ }
56
+ catch { /* */ }
57
+ if (link && /^https?:\/\//i.test(link)) {
58
+ await svc.openExternal(link).catch(() => { });
59
+ }
60
+ res.writeHead(204).end();
61
+ return;
62
+ }
63
+ res.writeHead(404).end("not found");
64
+ }
65
+ catch (e) {
66
+ try {
67
+ res.writeHead(500).end(String(e?.message || e));
68
+ }
69
+ catch { /* */ }
70
+ }
71
+ });
72
+ return new Promise((resolve) => {
73
+ server.once("error", (e) => {
74
+ console.error(` [popout] server failed to start: ${e?.message || e}`);
75
+ resolve(null);
76
+ });
77
+ server.listen(0, "127.0.0.1", () => {
78
+ const addr = server.address();
79
+ const port = typeof addr === "object" && addr ? addr.port : 0;
80
+ if (port) {
81
+ console.log(` [popout] window server on http://127.0.0.1:${port} (loopback, token-gated)`);
82
+ resolve({ port, token });
83
+ }
84
+ else {
85
+ resolve(null);
86
+ }
87
+ });
88
+ });
89
+ }
90
+ function esc(s) {
91
+ return (s || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
92
+ }
93
+ function addrLine(list) {
94
+ if (!Array.isArray(list))
95
+ return "";
96
+ return list.map((x) => esc(x?.name ? `${x.name} <${x.address}>` : (x?.address || ""))).filter(Boolean).join(", ");
97
+ }
98
+ /** The outer popout page: header + sandboxed body iframe. A small script relays
99
+ * the iframe's link clicks back to /open so they land in the OS browser. */
100
+ function renderPage(msg, a, u, f, token) {
101
+ const subject = esc(msg?.subject || "(no subject)");
102
+ const from = esc(msg?.from?.name ? `${msg.from.name} <${msg.from.address}>` : (msg?.from?.address || ""));
103
+ const to = addrLine(msg?.to);
104
+ const date = msg?.date ? esc(new Date(msg.date).toLocaleString()) : "";
105
+ const q = `a=${encodeURIComponent(a)}&u=${u}&f=${f ?? ""}&t=${encodeURIComponent(token)}`;
106
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${subject}</title>
107
+ <style>
108
+ html,body{margin:0;height:100%;font-family:system-ui,Segoe UI,sans-serif;color:#111;background:#fff;}
109
+ body{display:flex;flex-direction:column;}
110
+ .hdr{padding:10px 14px;border-bottom:1px solid #ddd;flex:0 0 auto;}
111
+ .subj{font-size:1.15em;font-weight:600;margin-bottom:4px;}
112
+ .meta{font-size:0.85em;color:#555;line-height:1.4;}
113
+ iframe{flex:1 1 auto;border:none;width:100%;}
114
+ </style></head><body>
115
+ <div class="hdr">
116
+ <div class="subj">${subject}</div>
117
+ <div class="meta">${from ? "From: " + from + "<br>" : ""}${to ? "To: " + to + "<br>" : ""}${date}</div>
118
+ </div>
119
+ <iframe src="/body?${q}" sandbox="allow-scripts allow-same-origin"></iframe>
120
+ <script>
121
+ var T=${JSON.stringify(token)};
122
+ window.addEventListener("message", function(e){
123
+ if(e.data && e.data.type==="popout-link" && e.data.url){
124
+ fetch("/open?t="+encodeURIComponent(T),{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({url:e.data.url})});
125
+ }
126
+ });
127
+ </script>
128
+ </body></html>`;
129
+ }
130
+ /** The body document loaded into the sandboxed iframe. Body HTML is already
131
+ * server-sanitized (sanitizeHtml); the inline script intercepts link clicks and
132
+ * hands them to the parent page (→ /open → OS browser) instead of navigating. */
133
+ function renderBody(msg) {
134
+ const body = (msg?.bodyHtml) || (msg?.bodyText ? `<pre style="white-space:pre-wrap">${esc(msg.bodyText)}</pre>` : "<em>(no content)</em>");
135
+ return `<!doctype html><html><head><meta charset="utf-8">
136
+ <style>body{margin:12px;font-family:system-ui,Segoe UI,sans-serif;color:#111;}img{max-width:100%;height:auto;}</style>
137
+ </head><body>${body}
138
+ <script>
139
+ document.addEventListener("click",function(e){
140
+ var t=e.target; var a=t&&t.closest?t.closest("a[href]"):null;
141
+ if(!a||!a.href)return;
142
+ e.preventDefault();
143
+ try{parent.postMessage({type:"popout-link",url:a.href},"*");}catch(_){}
144
+ },true);
145
+ </script></body></html>`;
146
+ }
147
+ //# sourceMappingURL=popout-server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"popout-server.js","sourceRoot":"","sources":["popout-server.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Popout server — a tiny loopback HTTP server that renders a single message in
3
+ * its own OS window (`window.open` from the client points a real browser window
4
+ * at it). msger's custom protocol doesn't propagate to child windows, so a
5
+ * separate window can't use the normal IPC client; this serves a self-contained
6
+ * read view instead.
7
+ *
8
+ * Transport choice (Bob 2026-06-29): loopback TCP on an OS-assigned EPHEMERAL
9
+ * port (`listen(0)`) — no hardcoded port to collide, identical on every
10
+ * platform. A browser window can't load HTTP over a named pipe / Unix socket,
11
+ * so pipes aren't an option here (they'd suit a CLI/IPC client). Loopback TCP is
12
+ * reachable by any local process, so every route is gated on a per-launch random
13
+ * token. Future: the `msgapi` channel could serve this without HTTP at all.
14
+ *
15
+ * Cut 1 = read + links (links POST back here → svc.openExternal → OS browser).
16
+ * Toolbar actions (reply/forward/delete/flag) are a follow-up.
17
+ */
18
+ import http from "node:http";
19
+ import crypto from "node:crypto";
20
+
21
+ export interface PopoutServerInfo {
22
+ port: number;
23
+ token: string;
24
+ }
25
+
26
+ /** svc must expose getMessage(accountId, uid, allowRemote, folderId) and
27
+ * openExternal(url). Returns null if the server can't bind (popout disabled). */
28
+ export async function startPopoutServer(svc: any): Promise<PopoutServerInfo | null> {
29
+ const token = crypto.randomBytes(18).toString("hex");
30
+
31
+ const server = http.createServer(async (req, res) => {
32
+ try {
33
+ const url = new URL(req.url || "/", "http://127.0.0.1");
34
+ // Token gate on every request — loopback TCP is locally reachable.
35
+ if (url.searchParams.get("t") !== token) {
36
+ res.writeHead(403).end("forbidden");
37
+ return;
38
+ }
39
+ const a = url.searchParams.get("a") || "";
40
+ const u = Number(url.searchParams.get("u"));
41
+ const fRaw = url.searchParams.get("f");
42
+ const f = fRaw != null && fRaw !== "" ? Number(fRaw) : undefined;
43
+
44
+ if (req.method === "GET" && url.pathname === "/v") {
45
+ const msg = await svc.getMessage(a, u, false, f);
46
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
47
+ res.end(renderPage(msg, a, u, f, token));
48
+ return;
49
+ }
50
+ if (req.method === "GET" && url.pathname === "/body") {
51
+ const msg = await svc.getMessage(a, u, false, f);
52
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
53
+ res.end(renderBody(msg));
54
+ return;
55
+ }
56
+ if (req.method === "POST" && url.pathname === "/open") {
57
+ let body = "";
58
+ for await (const chunk of req) body += chunk;
59
+ let link = "";
60
+ try { link = (JSON.parse(body || "{}").url) || ""; } catch { /* */ }
61
+ if (link && /^https?:\/\//i.test(link)) {
62
+ await svc.openExternal(link).catch(() => { /* best-effort */ });
63
+ }
64
+ res.writeHead(204).end();
65
+ return;
66
+ }
67
+ res.writeHead(404).end("not found");
68
+ } catch (e: any) {
69
+ try { res.writeHead(500).end(String(e?.message || e)); } catch { /* */ }
70
+ }
71
+ });
72
+
73
+ return new Promise((resolve) => {
74
+ server.once("error", (e: any) => {
75
+ console.error(` [popout] server failed to start: ${e?.message || e}`);
76
+ resolve(null);
77
+ });
78
+ server.listen(0, "127.0.0.1", () => {
79
+ const addr = server.address();
80
+ const port = typeof addr === "object" && addr ? addr.port : 0;
81
+ if (port) {
82
+ console.log(` [popout] window server on http://127.0.0.1:${port} (loopback, token-gated)`);
83
+ resolve({ port, token });
84
+ } else {
85
+ resolve(null);
86
+ }
87
+ });
88
+ });
89
+ }
90
+
91
+ function esc(s: string): string {
92
+ return (s || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
93
+ }
94
+
95
+ function addrLine(list: any): string {
96
+ if (!Array.isArray(list)) return "";
97
+ return list.map((x: any) => esc(x?.name ? `${x.name} <${x.address}>` : (x?.address || ""))).filter(Boolean).join(", ");
98
+ }
99
+
100
+ /** The outer popout page: header + sandboxed body iframe. A small script relays
101
+ * the iframe's link clicks back to /open so they land in the OS browser. */
102
+ function renderPage(msg: any, a: string, u: number, f: number | undefined, token: string): string {
103
+ const subject = esc(msg?.subject || "(no subject)");
104
+ const from = esc(msg?.from?.name ? `${msg.from.name} <${msg.from.address}>` : (msg?.from?.address || ""));
105
+ const to = addrLine(msg?.to);
106
+ const date = msg?.date ? esc(new Date(msg.date).toLocaleString()) : "";
107
+ const q = `a=${encodeURIComponent(a)}&u=${u}&f=${f ?? ""}&t=${encodeURIComponent(token)}`;
108
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${subject}</title>
109
+ <style>
110
+ html,body{margin:0;height:100%;font-family:system-ui,Segoe UI,sans-serif;color:#111;background:#fff;}
111
+ body{display:flex;flex-direction:column;}
112
+ .hdr{padding:10px 14px;border-bottom:1px solid #ddd;flex:0 0 auto;}
113
+ .subj{font-size:1.15em;font-weight:600;margin-bottom:4px;}
114
+ .meta{font-size:0.85em;color:#555;line-height:1.4;}
115
+ iframe{flex:1 1 auto;border:none;width:100%;}
116
+ </style></head><body>
117
+ <div class="hdr">
118
+ <div class="subj">${subject}</div>
119
+ <div class="meta">${from ? "From: " + from + "<br>" : ""}${to ? "To: " + to + "<br>" : ""}${date}</div>
120
+ </div>
121
+ <iframe src="/body?${q}" sandbox="allow-scripts allow-same-origin"></iframe>
122
+ <script>
123
+ var T=${JSON.stringify(token)};
124
+ window.addEventListener("message", function(e){
125
+ if(e.data && e.data.type==="popout-link" && e.data.url){
126
+ fetch("/open?t="+encodeURIComponent(T),{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({url:e.data.url})});
127
+ }
128
+ });
129
+ </script>
130
+ </body></html>`;
131
+ }
132
+
133
+ /** The body document loaded into the sandboxed iframe. Body HTML is already
134
+ * server-sanitized (sanitizeHtml); the inline script intercepts link clicks and
135
+ * hands them to the parent page (→ /open → OS browser) instead of navigating. */
136
+ function renderBody(msg: any): string {
137
+ const body = (msg?.bodyHtml) || (msg?.bodyText ? `<pre style="white-space:pre-wrap">${esc(msg.bodyText)}</pre>` : "<em>(no content)</em>");
138
+ return `<!doctype html><html><head><meta charset="utf-8">
139
+ <style>body{margin:12px;font-family:system-ui,Segoe UI,sans-serif;color:#111;}img{max-width:100%;height:auto;}</style>
140
+ </head><body>${body}
141
+ <script>
142
+ document.addEventListener("click",function(e){
143
+ var t=e.target; var a=t&&t.closest?t.closest("a[href]"):null;
144
+ if(!a||!a.href)return;
145
+ e.preventDefault();
146
+ try{parent.postMessage({type:"popout-link",url:a.href},"*");}catch(_){}
147
+ },true);
148
+ </script></body></html>`;
149
+ }
@@ -2535,22 +2535,26 @@ function spawnDesktopPopout(msg, accountId) {
2535
2535
  wrapper.style.right = `${20 + existing * 28}px`;
2536
2536
  }
2537
2537
  const toolbar = document.createElement("div");
2538
- toolbar.style.cssText = "display:flex;gap:6px;padding:6px 12px;border-bottom:1px solid var(--color-border, #ddd);flex-shrink:0;";
2539
- const mkPopoutBtn = (label, title, onClick) => {
2538
+ toolbar.className = "mv-toolbar";
2539
+ toolbar.style.cssText = "display:flex;gap:4px;padding:6px 12px;border-bottom:1px solid var(--color-border, #ddd);flex-shrink:0;";
2540
+ const mkPopoutBtn = (glyph, title, onClick) => {
2540
2541
  const b = document.createElement("button");
2541
- b.textContent = label;
2542
+ b.className = "tb-btn";
2543
+ b.textContent = glyph;
2542
2544
  b.title = title;
2543
- b.style.cssText = "padding:4px 10px;font-size:13px;cursor:pointer;border:1px solid var(--color-border, #ccc);border-radius:4px;background:var(--color-bg, #fff);color:var(--color-text, #000);";
2545
+ b.style.cssText = "padding:4px 9px;font-size:15px;cursor:pointer;border:1px solid var(--color-border, #ccc);border-radius:4px;background:var(--color-bg, #fff);color:var(--color-text, #000);";
2544
2546
  b.addEventListener("click", onClick);
2545
2547
  return b;
2546
2548
  };
2547
2549
  const firePopoutAction = (action) => {
2548
2550
  document.dispatchEvent(new CustomEvent("mailx-popout-action", { detail: { action, msg, accountId } }));
2549
2551
  };
2550
- toolbar.appendChild(mkPopoutBtn("Reply", "Reply", () => firePopoutAction("reply")));
2551
- toolbar.appendChild(mkPopoutBtn("Reply All", "Reply to all", () => firePopoutAction("replyAll")));
2552
- toolbar.appendChild(mkPopoutBtn("Forward", "Forward", () => firePopoutAction("forward")));
2553
- toolbar.appendChild(mkPopoutBtn("Delete", "Delete this message", () => {
2552
+ toolbar.appendChild(mkPopoutBtn("\u21A9", "Reply (Ctrl+R)", () => firePopoutAction("reply")));
2553
+ toolbar.appendChild(mkPopoutBtn("\u21A9\u21A9", "Reply All (Ctrl+Shift+R)", () => firePopoutAction("replyAll")));
2554
+ toolbar.appendChild(mkPopoutBtn("\u2192", "Forward", () => firePopoutAction("forward")));
2555
+ toolbar.appendChild(mkPopoutBtn("\u29C9", "Edit as new message", () => firePopoutAction("editAsNew")));
2556
+ toolbar.appendChild(mkPopoutBtn("\u2605", "Flag", () => firePopoutAction("flag")));
2557
+ toolbar.appendChild(mkPopoutBtn("\u{1F5D1}", "Delete", () => {
2554
2558
  firePopoutAction("delete");
2555
2559
  wrapper.remove();
2556
2560
  }));
@@ -11555,10 +11559,15 @@ ${detail}`;
11555
11559
  document.addEventListener("mailx-popout-action", ((e) => {
11556
11560
  const { action, msg, accountId } = e.detail || {};
11557
11561
  if (!msg) return;
11558
- if (action === "reply" || action === "replyAll" || action === "forward") {
11562
+ if (action === "reply" || action === "replyAll" || action === "forward" || action === "editAsNew") {
11559
11563
  openCompose(action, msg, accountId);
11560
11564
  } else if (action === "delete") {
11561
11565
  deleteMessage(accountId, msg.uid).catch((err) => console.error(`[popout] delete failed: ${err?.message || err}`));
11566
+ } else if (action === "flag") {
11567
+ const wasFlagged = flaggedOf(msg);
11568
+ setFlagged(msg, !wasFlagged);
11569
+ updateFlags(accountId, msg.uid, msg.flags, msg.folderId).catch((err) => console.error(`[popout] flag failed: ${err?.message || err}`));
11570
+ setRowFlagged(accountId, msg.uid, !wasFlagged);
11562
11571
  }
11563
11572
  }));
11564
11573
  document.addEventListener("mailx-popout-message", (async (e) => {