@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.
- 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 +18 -9
- package/client/app.bundle.js.map +2 -2
- package/client/app.js +7 -1
- package/client/app.js.map +1 -1
- package/client/app.ts +7 -1
- package/client/components/message-viewer.js +39 -11
- package/client/components/message-viewer.js.map +1 -1
- package/client/components/message-viewer.ts +35 -11
- 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
|
@@ -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.
|
|
2539
|
-
|
|
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.
|
|
2542
|
+
b.className = "tb-btn";
|
|
2543
|
+
b.textContent = glyph;
|
|
2542
2544
|
b.title = title;
|
|
2543
|
-
b.style.cssText = "padding:4px
|
|
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("
|
|
2551
|
-
toolbar.appendChild(mkPopoutBtn("
|
|
2552
|
-
toolbar.appendChild(mkPopoutBtn("
|
|
2553
|
-
toolbar.appendChild(mkPopoutBtn("
|
|
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) => {
|