@bobfrankston/rmfmail 1.2.80 → 1.2.81
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 +16 -1
- package/bin/mailx.js.map +1 -1
- package/bin/mailx.ts +15 -1
- package/bin/popout-server.js +147 -0
- package/bin/popout-server.js.map +1 -0
- package/bin/popout-server.ts +149 -0
- package/client/app.bundle.js +17 -0
- package/client/app.bundle.js.map +2 -2
- package/client/app.js +2 -1
- package/client/app.js.map +1 -1
- package/client/app.ts +2 -1
- package/client/components/message-viewer.js +22 -0
- package/client/components/message-viewer.js.map +1 -1
- package/client/components/message-viewer.ts +18 -0
- package/client/index.html +1 -0
- package/package.json +1 -1
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
+
}
|
package/client/app.bundle.js
CHANGED
|
@@ -2413,6 +2413,22 @@ function popOutCurrentMessage() {
|
|
|
2413
2413
|
}
|
|
2414
2414
|
spawnDesktopPopout(currentMessage, currentAccountId);
|
|
2415
2415
|
}
|
|
2416
|
+
function popOutToWindow() {
|
|
2417
|
+
if (!currentMessage)
|
|
2418
|
+
return;
|
|
2419
|
+
const p = window.__rmfPopout;
|
|
2420
|
+
if (!p?.base || !p?.token) {
|
|
2421
|
+
popOutCurrentMessage();
|
|
2422
|
+
return;
|
|
2423
|
+
}
|
|
2424
|
+
const a = currentAccountId;
|
|
2425
|
+
const u = currentMessage.uid;
|
|
2426
|
+
const f = currentMessage.folderId;
|
|
2427
|
+
const url = `${p.base}/v?a=${encodeURIComponent(a)}&u=${u}&f=${f ?? ""}&t=${encodeURIComponent(p.token)}`;
|
|
2428
|
+
const w = window.open(url, `rmfmsg-${a}-${u}`, "width=820,height=900,resizable=yes,scrollbars=yes");
|
|
2429
|
+
if (!w)
|
|
2430
|
+
popOutCurrentMessage();
|
|
2431
|
+
}
|
|
2416
2432
|
function printCurrentMessage() {
|
|
2417
2433
|
if (!currentMessage)
|
|
2418
2434
|
return;
|
|
@@ -7884,6 +7900,7 @@ var backToList = (e) => {
|
|
|
7884
7900
|
document.getElementById("btn-back")?.addEventListener("click", backToList);
|
|
7885
7901
|
document.getElementById("btn-back")?.addEventListener("touchend", backToList);
|
|
7886
7902
|
document.getElementById("mv-popout")?.addEventListener("click", () => popOutCurrentMessage());
|
|
7903
|
+
document.getElementById("mv-popout-window")?.addEventListener("click", () => popOutToWindow());
|
|
7887
7904
|
document.getElementById("btn-print")?.addEventListener("click", () => printCurrentMessage());
|
|
7888
7905
|
document.getElementById("folder-tree")?.addEventListener("click", (e) => {
|
|
7889
7906
|
if (window.innerWidth <= 768 && e.target.closest(".ft-folder")) {
|