@bobfrankston/rmfmail 1.0.667 → 1.0.668

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/TODO.md CHANGED
@@ -237,6 +237,8 @@ Previously shown as showstoppers; moved here because they haven't recurred on re
237
237
  Small, self-contained items. Pick them up between higher-priority blocks without asking. Bump version per fix.
238
238
 
239
239
  - ~~**Q138 — Quoted-reply image sizes lost.**~~ DONE 2026-05-11 in `client/app.ts:1000` — strip skips `<img>`. Two-pass loop handles multiple stripped attrs per tag.
240
+ - **Q140 — User-configurable holiday calendars (generalization).** Today the holiday-pull list is hardcoded to two Google calendars: `en.usa#holiday` and `en.jewish#holiday`, each with its own checkbox in the sidebar. Bob 2026-05-11: "Static calendars are OK. You can add generalization to the maybe-future list. Unlikely to do unless I want a more general audience." When that audience expands, generalize to `settings.calendar.holidayCalendars: Array<{ id: string; label: string; enabled: boolean }>`. The refresh loop iterates the array; the sidebar renders one checkbox per entry; a small editor in Settings → calendar lets the user add/remove entries by calendar ID (Google publishes `<locale>.<country>#holiday@group.v.calendar.google.com`, `en.christian#holiday`, etc.). Schema-side `calendar_id` column already partitions rows by source, so toggling one off doesn't affect the others — the existing reconcile logic carries straight over.
241
+ - **Q139 — General app-message channel from daemon to msger popups.** Bob 2026-05-11: "when an exit handler kills the popups by sending them a message — and can there be a general app message?" Today reminder popups are one-shot `showMessageBoxEx` (we have the handle for cross-platform reap). For graceful close-via-message AND other patterns (theme changes, in-flight content updates), spawn popups in `service: true` mode and use the bidirectional pipe — daemon calls `handle.send({type: "close"})` and the popup's JS listens for `_msgapiServiceEvent` events and reacts (`window.close()`, refresh content, etc.). Reuses the same wire mailx's main WebView already uses. Adapter shape: `showServicePopup(opts)` in `mailx-host` returns a `ServiceHandle` + a `result: Promise`; popup HTML's `<script>` registers `window._msgapiServiceEvent = (msg) => { if (msg.type === "close") window.close(); /* future: themeChanged, eventUpdated, … */ }`. Then `gracefulShutdown` in `bin/mailx.ts` iterates open popup handles and sends `{type:"close"}` instead of relying on the OS reaping. Pre-req: confirm msger's service mode supports the `rawHtml` content shape we use for reminders (may need a small msger PR).
240
242
  - **Q138 (original entry) — Quoted-reply image sizes lost.** Bob 2026-05-10: in the reply quote of a marketing email (Qatar Airways), the App Store / Google Play / AppGallery button images render larger than in the original. Cause: `sanitizeQuotedBody` in `client/app.ts` strips `width` / `height` attributes (along with `align`, `valign`, `bgcolor`, `cellpadding`, `cellspacing`, `border`) to flatten marketing-email layout tables. Stripping those attrs from `<img>` is collateral damage — table-only attributes there, but they also size images. Fix: in the regex, scope the attr strip to non-img tags, OR leave width/height alone everywhere (the layout flattening doesn't actually need them on tables either — `<table>→<div>` rewrites are doing the heavy lifting). ~10 line change. Low priority — visual nit, not data loss.
241
243
  - **Q136 — Server / provider capability matrix (forward backlog).** Catalog of server-specific features mailx could exploit. Detection is dynamic everywhere — IMAP via `CAPABILITY` + RFC 2971 `ID`, non-IMAP via provider-type at account setup. Each row below is its own future work item; this entry is the index.
242
244
  - **Dovecot** (IMAP — bobma's primary server): `NOTIFY` (✅ shipped 2026-05-10, see startWatching), `IDLE` (✅), `MOVE` (✅), `QRESYNC` / `CONDSTORE` (📋 Q135 — fast incremental sync with VANISHED + flag-since-modseq), `LIST-EXTENDED` (📋 single-roundtrip folder list with status counts), `METADATA` (📋 per-mailbox annotations — could hold per-folder mailx state on the server), `UNSELECT` (📋 fast folder switch without EXPUNGE), Push Framework / RFC 5423 (📋 server-side HTTP push — requires bobma config change, would let us retire IDLE).
package/bin/mailx.js CHANGED
@@ -22,8 +22,9 @@ import fs from "node:fs";
22
22
  import path from "node:path";
23
23
  import os from "node:os";
24
24
  import net from "node:net";
25
+ import { execSync } from "node:child_process";
25
26
  import { ports } from "@bobfrankston/miscinfo";
26
- import { showMessageBox, showService, setAppName, setAppIcon } from "@bobfrankston/mailx-host";
27
+ import { showMessageBox, showMessageBoxEx, showService, setAppName, setAppIcon } from "@bobfrankston/mailx-host";
27
28
  setAppName("rmfmail");
28
29
  // Prefer the .ico (Windows Explorer / taskbar-pin shortcut uses the embedded
29
30
  // icon resource of the pinned exe, or a Windows icon resource referenced
@@ -249,13 +250,32 @@ function readInstanceFile() {
249
250
  catch { /* missing or unreadable — treated as no instance */ }
250
251
  return null;
251
252
  }
252
- function writeInstanceFile(pid) {
253
+ function writeInstanceFile(pid, childPids = []) {
253
254
  try {
254
255
  fs.mkdirSync(path.dirname(__instanceFile), { recursive: true });
255
- fs.writeFileSync(__instanceFile, JSON.stringify({ pid, version: __selfVersion, startedAt: Date.now() }, null, 2));
256
+ const payload = { pid, version: __selfVersion, startedAt: Date.now(), childPids };
257
+ fs.writeFileSync(__instanceFile, JSON.stringify(payload, null, 2));
256
258
  }
257
259
  catch { /* non-fatal */ }
258
260
  }
261
+ /** Add a child PID to the daemon's instance.json so a later `-kill`
262
+ * knows to reap it. Caller is expected to remove it (via
263
+ * removeChildPid) when the child exits naturally. */
264
+ function addChildPid(pid) {
265
+ const inst = readInstanceFile();
266
+ if (!inst)
267
+ return;
268
+ const set = new Set(inst.childPids || []);
269
+ set.add(pid);
270
+ writeInstanceFile(inst.pid, [...set]);
271
+ }
272
+ function removeChildPid(pid) {
273
+ const inst = readInstanceFile();
274
+ if (!inst)
275
+ return;
276
+ const next = (inst.childPids || []).filter(p => p !== pid);
277
+ writeInstanceFile(inst.pid, next);
278
+ }
259
279
  function clearInstanceFile() {
260
280
  try {
261
281
  fs.unlinkSync(__instanceFile);
@@ -279,18 +299,17 @@ function pidAlive(pid) {
279
299
  * with "already running". Verify it's actually us. Non-Windows: fall back
280
300
  * to pidAlive — POSIX PID reuse is rare enough that we don't bother. */
281
301
  function pidIsMailx(pid) {
282
- if (!pidAlive(pid))
283
- return false;
284
- if (process.platform !== "win32")
285
- return true;
286
- try {
287
- const { execSync } = require("node:child_process");
288
- const out = execSync(`powershell -NoProfile -Command "(Get-CimInstance Win32_Process -Filter \\"ProcessId=${pid}\\").CommandLine"`, { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true }).toString();
289
- return /(?:rmfmail|mailx)[\\\/](?:bin|packages|app)|mailx-server/i.test(out);
290
- }
291
- catch {
292
- return false;
293
- }
302
+ // Cross-platform: trust instance.json as the source of identity.
303
+ // We registered this PID at startup; if it's still alive, treat it
304
+ // as ours. Earlier Windows-only check used PowerShell + CimInstance
305
+ // to grep the command line, which violated the cross-platform rule
306
+ // (see memory: feedback_cross_platform_strong_rule). The downside is
307
+ // a brief PID-reuse window after a crash: if Windows recycles the
308
+ // PID between crash and the next launch, our SIGTERM hits an
309
+ // unrelated process. Mitigation: daemon clears instance.json on
310
+ // graceful exit; stale-instance file only persists across hard
311
+ // crashes, which is rare.
312
+ return pidAlive(pid);
294
313
  }
295
314
  // Version-mismatch upgrade: if a daemon from an older version is running when
296
315
  // the user types `mailx`, kill it so the new one can take over. Without this,
@@ -322,27 +341,17 @@ if (!isDaemon && !__isCommandInvocation && !__keepOthers) {
322
341
  }
323
342
  catch { /* */ }
324
343
  }
325
- // Backstop sweep: catches stragglers not in instance.json. Slow (~2s
326
- // PowerShell+CIM startup) but only runs once per launch, and it's the
327
- // only way to guarantee no orphan windows survive.
328
- if (process.platform === "win32") {
329
- try {
330
- const { execSync } = require("node:child_process");
331
- const out = execSync(`powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"name='node.exe'\\" | Where-Object { $_.CommandLine -match '(?:rmfmail|mailx)[\\\\/](?:bin|packages)|(?:rmfmail|mailx)-server' } | Select-Object -ExpandProperty ProcessId"`, { encoding: "utf-8", windowsHide: true }).toString();
332
- for (const line of out.split("\n").map((s) => s.trim()).filter((s) => /^\d+$/.test(s))) {
333
- const pid = Number(line);
334
- if (pid === myPid)
335
- continue;
336
- try {
337
- process.kill(pid, "SIGTERM");
338
- if (!killedPids.includes(pid))
339
- killedPids.push(pid);
340
- }
341
- catch { /* */ }
342
- }
343
- }
344
- catch { /* sweep failed — proceed; instance.json kill above usually covers it */ }
345
- }
344
+ // Replace-on-launch trusts the registered PID in instance.json. The
345
+ // graceful-shutdown handler in the prior daemon (this same file, below)
346
+ // is responsible for closing its own child msger/popup processes via
347
+ // handle.close() — name-based sweeps over `node.exe` / `msgernative.exe`
348
+ // / per-app exes are fragile and ALSO not portable (taskkill is
349
+ // Windows-only). If a daemon crashed without graceful shutdown, the
350
+ // instance file gets cleared next launch via the stale-PID check and
351
+ // the orphan stays until the user resolves it manually — preferable to
352
+ // a sweep that risks killing an unrelated `mailx` / `rmfmail` process
353
+ // owned by a different user (e.g., a developer running it from a
354
+ // sibling worktree).
346
355
  // Brief wait + SIGKILL anything that didn't exit gracefully.
347
356
  if (killedPids.length) {
348
357
  const deadline = Date.now() + 2000;
@@ -408,7 +417,6 @@ function log(...msg) { if (verbose)
408
417
  function isElevated() {
409
418
  try {
410
419
  if (process.platform === "win32") {
411
- const { execSync } = require("node:child_process");
412
420
  execSync("net session >nul 2>&1", { stdio: "ignore", windowsHide: true });
413
421
  return true;
414
422
  }
@@ -444,77 +452,48 @@ if (hasFlag("kill")) {
444
452
  log("Killing rmfmail processes...");
445
453
  const { execSync } = await import("node:child_process");
446
454
  let killed = 0;
447
- // Try graceful exit first
448
- try {
449
- execSync(`curl -s -m 2 http://localhost:${PORT}/api/exit`, { stdio: "pipe" });
450
- log("Sent graceful exit");
451
- execSync("timeout /t 1 /nobreak", { stdio: "pipe" });
452
- }
453
- catch { /* server may not be responding */ }
454
- if (process.platform === "win32") {
455
- // Kill by port
455
+ // Cross-platform kill via instance.json. Daemon writes its PID +
456
+ // child PIDs (msger WebView, popups) at startup and on each spawn;
457
+ // -kill reads them and SIGTERMs each. No name-based sweeps, no
458
+ // PowerShell, no taskkill — those were Windows-only AND fragile
459
+ // (could match unrelated processes). See memory:
460
+ // feedback_cross_platform_strong_rule.
461
+ const inst = readInstanceFile();
462
+ const targets = [];
463
+ if (inst && pidAlive(inst.pid))
464
+ targets.push(inst.pid);
465
+ for (const cp of (inst?.childPids || [])) {
466
+ if (pidAlive(cp))
467
+ targets.push(cp);
468
+ }
469
+ for (const pid of targets) {
456
470
  try {
457
- const out = execSync(`netstat -ano | findstr :${PORT} | findstr LISTENING`, { encoding: "utf-8" }).trim();
458
- const pids = [...new Set(out.split("\n").map(l => l.trim().split(/\s+/).pop()).filter(Boolean))];
459
- for (const pid of pids) {
460
- try {
461
- execSync(`taskkill /F /PID ${pid}`, { stdio: "pipe" });
462
- console.log(`Killed PID ${pid} (port ${PORT})`);
463
- killed++;
464
- }
465
- catch { /* */ }
466
- }
471
+ process.kill(pid, "SIGTERM");
472
+ console.log(`SIGTERM PID ${pid}`);
473
+ killed++;
467
474
  }
468
- catch { /* no process on port */ }
469
- // Kill any node.exe running rmfmail (server or IPC service). Match
470
- // the legacy `mailx` path too — source tree dir is still
471
- // `Y:\dev\email\mailx\` and per-app exes from older installs are
472
- // under `%LOCALAPPDATA%\mailx\` until those get cleaned up.
473
- try {
474
- const ps = execSync(
475
- // Match both `\` and `/` path separators — node CommandLine
476
- // can show either depending on how it was invoked. The
477
- // earlier `\\packages` / `\\bin` variants missed forward-
478
- // slash paths and left a daemon running. The character
479
- // class [\\\\/] covers both. `(?:...)` keeps the regex
480
- // unanchored — substring match anywhere in CommandLine.
481
- `powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"name='node.exe'\\" | Where-Object { $_.CommandLine -match '(?:rmfmail|mailx)[\\\\/](?:bin|packages)|(?:rmfmail|mailx)-server' } | Select-Object -ExpandProperty ProcessId"`, { encoding: "utf-8" }).trim();
482
- for (const pid of ps.split("\n").map(s => s.trim()).filter(s => /^\d+$/.test(s))) {
483
- try {
484
- execSync(`taskkill /F /PID ${pid}`, { stdio: "pipe" });
485
- console.log(`Killed PID ${pid} (rmfmail node process)`);
486
- killed++;
487
- }
488
- catch { /* */ }
489
- }
475
+ catch (e) {
476
+ // ESRCH: stale PID, already dead. Anything else is unexpected.
477
+ if (e?.code !== "ESRCH")
478
+ console.error(` kill ${pid}: ${e?.message || e}`);
490
479
  }
491
- catch { /* */ }
492
- // Kill orphaned msgernative.exe windows. When the node process dies
493
- // without cascade-killing its WebView child (old crash, forced
494
- // taskkill, etc.), the msgernative.exe stays on screen and looks
495
- // like a live rmfmail. rmfmail -kill should leave no trace.
496
- // Scoped to exes launched for rmfmail by filtering CommandLine —
497
- // don't touch msger windows started by other apps (msga, bbs, etc.).
498
- try {
499
- const ps = execSync(`powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"name='msgernative.exe'\\" | Where-Object { $_.CommandLine -match 'rmfmail|mailx' -or $_.Path -match 'rmfmail|mailx' } | Select-Object -ExpandProperty ProcessId"`, { encoding: "utf-8" }).trim();
500
- for (const pid of ps.split("\n").map(s => s.trim()).filter(s => /^\d+$/.test(s))) {
480
+ }
481
+ // Wait briefly for graceful shutdown, then SIGKILL anything still alive.
482
+ if (targets.length) {
483
+ const deadline = Date.now() + 2000;
484
+ while (Date.now() < deadline && targets.some(p => pidAlive(p))) {
485
+ const sab = new SharedArrayBuffer(4);
486
+ Atomics.wait(new Int32Array(sab), 0, 0, 100);
487
+ }
488
+ for (const pid of targets) {
489
+ if (pidAlive(pid)) {
501
490
  try {
502
- execSync(`taskkill /F /PID ${pid}`, { stdio: "pipe" });
503
- console.log(`Killed PID ${pid} (rmfmail msgernative/WebView)`);
504
- killed++;
491
+ process.kill(pid, "SIGKILL");
492
+ console.log(`SIGKILL PID ${pid}`);
505
493
  }
506
494
  catch { /* */ }
507
495
  }
508
496
  }
509
- catch { /* */ }
510
- }
511
- else {
512
- try {
513
- execSync(`fuser -k ${PORT}/tcp`, { stdio: "pipe" });
514
- console.log(`Killed process on port ${PORT}`);
515
- killed++;
516
- }
517
- catch { /* */ }
518
497
  }
519
498
  // Clean up stale SQLite WAL/SHM files
520
499
  const mailxDir = path.join(process.env.USERPROFILE || process.env.HOME || ".", ".rmfmail");
@@ -1337,9 +1316,25 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
1337
1316
  // Inject the popup function so MailxService.showReminderPopup can spawn
1338
1317
  // an OS-level always-on-top window via mailx-host. Kept as injection
1339
1318
  // (not import) so mailx-service stays host-agnostic.
1319
+ //
1320
+ // Uses showMessageBoxEx so we get a MessageBoxHandle back with
1321
+ // `pid` and `close()`. The pid goes into instance.json's childPids
1322
+ // for the lifetime of the popup, so a `rmfmail -kill` from another
1323
+ // shell can SIGTERM it without name-based sweeps (cross-platform —
1324
+ // see memory: feedback_cross_platform_strong_rule). Remove on
1325
+ // resolution so the file doesn't accumulate stale PIDs.
1340
1326
  svc.setPopupFn(async (opts) => {
1341
- const r = await showMessageBox(opts);
1342
- return { button: r.button, closed: r.closed, dismissed: r.dismissed };
1327
+ const h = showMessageBoxEx(opts);
1328
+ if (typeof h.pid === "number")
1329
+ addChildPid(h.pid);
1330
+ try {
1331
+ const r = await h.result;
1332
+ return { button: r.button, closed: r.closed, dismissed: r.dismissed, form: r.form };
1333
+ }
1334
+ finally {
1335
+ if (typeof h.pid === "number")
1336
+ removeChildPid(h.pid);
1337
+ }
1343
1338
  });
1344
1339
  // Open msger in service mode — custom protocol serves files from client dir
1345
1340
  const clientDir = path.join(import.meta.dirname, "..", "client");
@@ -1403,17 +1398,25 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
1403
1398
  : undefined,
1404
1399
  escapeCloses: false,
1405
1400
  });
1406
- // Register ourselves as the live instance so subsequent `mailx` invocations
1407
- // can detect version-mismatch and upgrade us (see top of file). Clear on
1408
- // any of: SIGINT, SIGTERM, normal exit.
1401
+ // Register ourselves as the live instance so subsequent `rmfmail`
1402
+ // invocations can detect version-mismatch and upgrade us (see top of
1403
+ // file). Clear on any of: SIGINT, SIGTERM, normal exit.
1409
1404
  //
1410
1405
  // Critical: the SIGTERM handler must *close the WebView child process*
1411
- // (handle.close() → kills msgernative.exe) before Node exits. Without
1412
- // this, the auto-upgrade leaves the old WebView orphaned on screen and
1413
- // the user sees an apparently frozen "old mailx" while the new Node is
1414
- // trying to spawn a second one. Cascade-killing the child makes the
1415
- // version-mismatch auto-upgrade actually transparent to the user.
1416
- writeInstanceFile(process.pid);
1406
+ // (handle.close() → SIGTERM to the msger child) before Node exits.
1407
+ // Without this, the auto-upgrade leaves the old WebView orphaned on
1408
+ // screen and the user sees an apparently frozen "old rmfmail" while
1409
+ // the new Node is trying to spawn a second one. Cascade-closing the
1410
+ // child makes the version-mismatch auto-upgrade transparent.
1411
+ //
1412
+ // Child PIDs (the msger WebView host + any reminder popups) go into
1413
+ // instance.json so that a future `rmfmail -kill` can SIGTERM them
1414
+ // cross-platform without resorting to name-based process sweeps.
1415
+ const initialChildPids = [];
1416
+ const handleChild = handle?.pid ?? handle?.child?.pid;
1417
+ if (typeof handleChild === "number")
1418
+ initialChildPids.push(handleChild);
1419
+ writeInstanceFile(process.pid, initialChildPids);
1417
1420
  const __cleanupInstance = () => {
1418
1421
  // Only clear if WE are still the registered instance. Prevents the
1419
1422
  // restart-daemon sequence (clear → spawn → new daemon writes its