@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 +2 -0
- package/bin/mailx.js +114 -111
- package/bin/mailx.js.map +1 -1
- package/bin/mailx.ts +117 -95
- package/client/app.bundle.js +98 -24
- package/client/app.bundle.js.map +2 -2
- package/client/components/alarms.js +106 -29
- package/client/components/alarms.js.map +1 -1
- package/client/components/alarms.ts +109 -31
- package/client/components/calendar-sidebar.js +19 -12
- package/client/components/calendar-sidebar.js.map +1 -1
- package/client/components/calendar-sidebar.ts +21 -12
- package/client/compose/compose.bundle.js.map +1 -1
- package/client/index.html +3 -0
- package/client/lib/api-client.ts +1 -1
- package/package.json +1 -1
- package/packages/mailx-host/index.d.ts +5 -0
- package/packages/mailx-host/index.d.ts.map +1 -1
- package/packages/mailx-host/index.js +5 -0
- package/packages/mailx-host/index.js.map +1 -1
- package/packages/mailx-host/index.ts +5 -0
- package/packages/mailx-host/package.json +1 -1
- package/packages/mailx-service/index.d.ts +1 -0
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +68 -36
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +67 -37
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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
//
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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 {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
503
|
-
console.log(`
|
|
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
|
|
1342
|
-
|
|
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 `
|
|
1407
|
-
// can detect version-mismatch and upgrade us (see top of
|
|
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() →
|
|
1412
|
-
// this, the auto-upgrade leaves the old WebView orphaned on
|
|
1413
|
-
// the user sees an apparently frozen "old
|
|
1414
|
-
// trying to spawn a second one. Cascade-
|
|
1415
|
-
// version-mismatch auto-upgrade
|
|
1416
|
-
|
|
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
|