@bobfrankston/rmfmail 1.2.128 → 1.2.130

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.
@@ -29,6 +29,11 @@ import { dispatch } from "@bobfrankston/mailx-service/jsonrpc.js";
29
29
  export interface PopoutServerInfo {
30
30
  port: number;
31
31
  token: string;
32
+ /** Agent tokens (written to ~/.rmfmail/agent.json for third-party apps
33
+ * and AI agents). `triage` = reads + flagging; `full` = everything the
34
+ * UI can do. Sending remains a human act by convention — the MCP layer
35
+ * exposes drafts, not send. */
36
+ agentTokens: { triage: string; full: string };
32
37
  /** Push a daemon event to every connected popout/secondary window
33
38
  * (SSE). The main window gets events over the msger service channel;
34
39
  * popout-served pages subscribe to GET /events and receive the same
@@ -49,6 +54,18 @@ export interface PopoutAction {
49
54
  * window. Returns null if the server can't bind (popout disabled). */
50
55
  export async function startPopoutServer(svc: any, onAction?: (act: PopoutAction) => void): Promise<PopoutServerInfo | null> {
51
56
  const token = crypto.randomBytes(18).toString("hex");
57
+ const agentTokens = { triage: crypto.randomBytes(18).toString("hex"), full: crypto.randomBytes(18).toString("hex") };
58
+ // Actions a TRIAGE-scoped agent may call: reads plus flag/read-state.
59
+ // Deliberately excludes send/move/delete/settings and every popout/window
60
+ // action. Full-scope agents get the whole dispatcher.
61
+ const TRIAGE_ACTIONS = new Set([
62
+ "getVersion", "getAccounts", "getFolders", "getMessages", "getUnifiedInbox",
63
+ "getMessage", "searchMessages", "getThreadMessages", "getAttachment",
64
+ "getMessageSource", "searchContacts", "listContacts", "getCalendarEvents",
65
+ "getCalendars", "getTasks", "getOutboxStatus", "getSyncPending",
66
+ "getDiagnostics", "getPriorityLists", "hasCcHistoryTo", "hasBccHistoryTo",
67
+ "updateFlags", "markFolderRead", "logClientEvent",
68
+ ]);
52
69
  // Static root for /app/<token>/... — the app package root, so pages under
53
70
  // client/ can reach ../styles, ../../node_modules etc. exactly as they do
54
71
  // through msger's custom protocol.
@@ -69,12 +86,18 @@ export async function startPopoutServer(svc: any, onAction?: (act: PopoutAction)
69
86
  res.writeHead(403).end("forbidden");
70
87
  return;
71
88
  }
72
- await serveStatic(appRoot, staticMatch[2], token, res);
89
+ await serveStatic(appRoot, staticMatch[2], token, res, svc);
73
90
  return;
74
91
  }
75
92
 
76
93
  // Token gate on every request — loopback TCP is locally reachable.
77
- if (url.searchParams.get("t") !== token) {
94
+ // Three tokens: the per-launch window token (full surface), and two
95
+ // agent tokens published in ~/.rmfmail/agent.json.
96
+ const presented = url.searchParams.get("t");
97
+ const isWindowToken = presented === token;
98
+ const isFullAgent = presented === agentTokens.full;
99
+ const isTriageAgent = presented === agentTokens.triage;
100
+ if (!isWindowToken && !isFullAgent && !isTriageAgent) {
78
101
  res.writeHead(403).end("forbidden");
79
102
  return;
80
103
  }
@@ -109,6 +132,11 @@ export async function startPopoutServer(svc: any, onAction?: (act: PopoutAction)
109
132
  res.writeHead(400).end("bad rpc request");
110
133
  return;
111
134
  }
135
+ if (isTriageAgent && !TRIAGE_ACTIONS.has(rpcReq._action)) {
136
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
137
+ res.end(JSON.stringify({ _cbid: rpcReq._cbid, error: `action "${rpcReq._action}" requires the full agent token (triage scope is reads + flags)` }));
138
+ return;
139
+ }
112
140
  const rpcRes = await dispatch(svc, rpcReq);
113
141
  res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
114
142
  res.end(JSON.stringify(rpcRes));
@@ -183,7 +211,7 @@ export async function startPopoutServer(svc: any, onAction?: (act: PopoutAction)
183
211
  const port = typeof addr === "object" && addr ? addr.port : 0;
184
212
  if (port) {
185
213
  console.log(` [popout] window server on http://127.0.0.1:${port} (loopback, token-gated)`);
186
- resolve({ port, token, broadcastEvent });
214
+ resolve({ port, token, agentTokens, broadcastEvent });
187
215
  } else {
188
216
  resolve(null);
189
217
  }
@@ -210,7 +238,7 @@ const STATIC_MIME: Record<string, string> = {
210
238
  * the IPC-over-HTTP bridge injected at the top of <head>: config + bridge
211
239
  * (window.ipc → POST /rpc) + the REAL mailxapi.js — so the page runs the
212
240
  * byte-identical API surface the main WebView gets via initScript. */
213
- async function serveStatic(appRoot: string, relRaw: string, token: string, res: http.ServerResponse): Promise<void> {
241
+ async function serveStatic(appRoot: string, relRaw: string, token: string, res: http.ServerResponse, svc?: any): Promise<void> {
214
242
  const rel = decodeURIComponent(relRaw);
215
243
  const abs = path.resolve(appRoot, rel);
216
244
  // Traversal guard — resolved path must stay inside the app root.
@@ -237,7 +265,15 @@ async function serveStatic(appRoot: string, relRaw: string, token: string, res:
237
265
  // after parse but in DOCUMENT ORDER, so the bridge still executes
238
266
  // before the page's own module (compose.bundle.js) makes any RPC.
239
267
  // mailxapi.js stays classic — it's the same file every host injects.
240
- const inject = `<script>window.__rmfPopoutRpc={url:"/rpc?t=${token}",events:"/events?t=${token}"};</script>`
268
+ //
269
+ // Settings snapshot baked into the page: popout windows live on a
270
+ // per-port origin whose localStorage caches (editor type, font size)
271
+ // start EMPTY on every daemon restart — a reply opened in Quill when
272
+ // TinyMCE was selected (Bob 2026-07-12). Pages prefer this snapshot
273
+ // over their localStorage caches; ui subset keeps the payload small.
274
+ let bootUi: any = null;
275
+ try { bootUi = (await svc?.getSettings?.())?.ui ?? null; } catch { /* page falls back to defaults */ }
276
+ const inject = `<script>window.__rmfPopoutRpc={url:"/rpc?t=${token}",events:"/events?t=${token}"};window.__rmfBootSettings=${JSON.stringify({ ui: bootUi })};</script>`
241
277
  + `<script type="module" src="/app/${token}/client/lib/popout-bridge.js"></script>`
242
278
  + `<script src="/app/${token}/client/lib/mailxapi.js"></script>`;
243
279
  const html = data.toString("utf-8").replace(/<head([^>]*)>/i, (m) => m + inject);
@@ -3851,10 +3851,15 @@ async function loadEditorAssets(type) {
3851
3851
  }
3852
3852
  var editorType = "quill";
3853
3853
  var appSettings = null;
3854
- try {
3855
- const cached = localStorage.getItem("mailx-editor-type");
3856
- if (cached === "tiptap" || cached === "quill" || cached === "tinymce") editorType = cached;
3857
- } catch {
3854
+ var __bootUi = window.__rmfBootSettings?.ui || null;
3855
+ if (__bootUi?.editor === "tiptap" || __bootUi?.editor === "quill" || __bootUi?.editor === "tinymce") {
3856
+ editorType = __bootUi.editor;
3857
+ } else {
3858
+ try {
3859
+ const cached = localStorage.getItem("mailx-editor-type");
3860
+ if (cached === "tiptap" || cached === "quill" || cached === "tinymce") editorType = cached;
3861
+ } catch {
3862
+ }
3858
3863
  }
3859
3864
  var COMPOSE_FONT_MIN_PX = 8;
3860
3865
  var COMPOSE_FONT_MAX_PX = 32;
@@ -3864,7 +3869,8 @@ function applyComposeFontSize(px) {
3864
3869
  document.documentElement.style.setProperty("--compose-font-size", `${px}px`);
3865
3870
  }
3866
3871
  try {
3867
- applyComposeFontSize(Number(localStorage.getItem("mailx-compose-font-size")));
3872
+ const bootFont = Number(__bootUi?.composeFontSize);
3873
+ applyComposeFontSize(Number.isFinite(bootFont) && bootFont > 0 ? bootFont : Number(localStorage.getItem("mailx-compose-font-size")));
3868
3874
  } catch {
3869
3875
  applyComposeFontSize(COMPOSE_FONT_DEFAULT_PX);
3870
3876
  }