@bobfrankston/rmfmail 1.2.282 → 1.2.284

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/.commitmsg CHANGED
@@ -1,62 +1,37 @@
1
- Catch the spam that scores under the threshold
2
-
3
- A sextortion mail arrived that none of yesterday's checks caught ("yet this
4
- spam got through"). SpamAssassin scored it 2.7 against a 4.5 threshold and
5
- delivered it, so server-spam-verdict correctly stayed silent — the server said
6
- no. Three independent proofs of forgery were sitting in the message anyway.
7
-
8
- spf=none now counts as "not authenticated" for self-claimed mail, not just
9
- fail. bobf.frankston.com publishes no SPF record, so nothing sent as that
10
- domain can ever FAIL only come back none. Treating none as neutral left the
11
- least-protected domain the least defended, which is backwards. Checked against
12
- the real store before changing it, 50,000 messages scanned:
13
-
14
- From at one of Bob's domains 10,338
15
- no Authentication-Results 10,034 <- his own mail, still ignored
16
- spf=pass 243 <- still ignored
17
- spf=fail / softfail 33
18
- spf=none 18 <- nearly all spam
19
-
20
- The 18 were "Settle your debt", "AAA Final notice", "McAfee Protection Plan
21
- Ended" and similar. Legitimate self-mail sits in the no-header group: it never
22
- crossed a trust boundary, so there is no Authentication-Results to read, and
23
- that case is untouched. An explicit spf/dkim/dmarc=pass still silences the
24
- check.
25
-
26
- relay-auth-mismatch Bob's own catch: "note that the origin is different than
27
- the from domain in antiabuse and in source-auth".
28
-
29
- From: universe@bobf.frankston.com
30
- X-Source-Auth: admin@calebjross.com
31
- X-Source-Cap: (base64) calebjro;calebjro;box2202.bluehost.com
32
- Message-ID: <...@calebjross.com>
33
-
34
- A Bluehost cPanel account belonging to an unrelated domain authenticated and
35
- sent mail wearing his address. That stamp is written by the RELAY, downstream
36
- of whoever submitted the message, so a forger cannot remove or edit it which
37
- makes the disagreement about as close to proof as mail headers get. Compared at
38
- the registrable-domain level, since a host legitimately sending for a domain
39
- authenticates as some account AT that domain and the local part varies for
40
- ordinary reasons. Covers the cPanel/Exim, Postfix and Communigate spellings.
41
-
42
- zero-width-obfuscation — the body had ~1380 zero-width characters stuffed
43
- between letters, so "i regret to inform you" reached every word-matching filter
44
- as gibberish while rendering as clean prose. SpamAssassin saw it
45
- (UNICODE_OBFU_ZW_MANY) and still totalled only 2.7. Counted only BETWEEN TWO
46
- LATIN LETTERS, which is what obfuscation looks like and what the legitimate
47
- uses are not: an emoji ZWJ joins pictographs, Arabic and Indic ZWNJ sit between
48
- their own scripts' letters. The occurrence count guards against a stray
49
- character surviving a copy-paste; it is a sanity bound, not a sensitivity dial.
50
-
51
- The score itself is now always visible — a chip above the body reading "spam
52
- score 2.7 of 4.5" ("should we have a flag ... with the spam assassin number and
53
- a color code?"). Separate from the findings on purpose: a finding is an
54
- accusation and fires only on evidence, this is a measurement. Colour is a
55
- PROPORTION of the server's own threshold — red once flagged, amber past halfway
56
- to its line, quiet below — so it follows if the server's threshold changes
57
- rather than hardcoding a number. Viewer only for now; the message-list row
58
- would need the score stored at sync time.
59
-
60
- tests/trust.test.ts covers all three, and the negative cases with them: spf=pass
61
- and no-header both silent, a relay identity at the same organisation, an emoji
62
- ZWJ family sequence, and a stray zero-width character from a paste.
1
+ Pressing the icon while it is running raises the window instead of restarting
2
+
3
+ "Why does it show and disappear? Are you assuring only a single instance? ...
4
+ you should still put me in the running version when I press the icon on the
5
+ task bar."
6
+
7
+ Single instance was assured — by REPLACEMENT. Every launch that wasn't a
8
+ share/mailto handoff ran replace-on-launch: SIGTERM the running daemon, wait,
9
+ SIGKILL, then boot fresh. Press the taskbar icon and the window you already had
10
+ is killed, disappears, and a new one fades up seconds later. From outside it
11
+ reads as the app flickering out of existence for no reason.
12
+
13
+ Replace-on-launch was written for the upgrade case — an older or orphaned daemon
14
+ surviving a partial upgrade and leaving two windows on two versions — but it
15
+ fired on every launch, ordinary ones included.
16
+
17
+ instance.json already records the running version, so the two cases separate
18
+ without guessing:
19
+
20
+ same version the user wants the app, and it is already there — write
21
+ pending-activate.json, let the running daemon raise its
22
+ window, exit 0. Nothing is killed.
23
+ different an upgrade landed; replace as before.
24
+
25
+ The daemon answers with the same fs.watch + { _msgerWindow: "focus" } shape the
26
+ mailto and share handoffs already use that native control message exists
27
+ precisely so a background daemon can surface its window, which is exactly this
28
+ situation. It also consumes a request that arrived while it was still booting,
29
+ since fs.watch only sees files that land after it is installed.
30
+
31
+ `-another` still opts out entirely, and every handoff path still returns before
32
+ reaching any of this.
33
+
34
+ Verified against the live daemon before publishing: launching a second copy
35
+ printed "already running (PID 34012, v1.2.283) raising its window", exited 0,
36
+ wrote the request, and left the running daemon alive where the old code would
37
+ have killed it.
package/bin/mailx.js CHANGED
@@ -492,7 +492,17 @@ function readInstanceFile() {
492
492
  function writeInstanceFile(pid, childPids = []) {
493
493
  try {
494
494
  fs.mkdirSync(path.dirname(__instanceFile), { recursive: true });
495
- const payload = { pid, version: __selfVersion, startedAt: Date.now(), childPids };
495
+ // startedAt must survive a childPids update. It was recomputed on every
496
+ // write, and since every popup and popout window rewrites this file,
497
+ // the field reported the last window event rather than when the daemon
498
+ // started — actively misleading when diagnosing "how long has this been
499
+ // up" or "which of these daemons is the old one" (it cost a wrong turn
500
+ // on 2026-08-27). Keep the existing value whenever the PID is unchanged.
501
+ const prev = readInstanceFile();
502
+ const startedAt = prev && prev.pid === pid && typeof prev.startedAt === "number"
503
+ ? prev.startedAt
504
+ : Date.now();
505
+ const payload = { pid, version: __selfVersion, startedAt, childPids };
496
506
  fs.writeFileSync(__instanceFile, JSON.stringify(payload, null, 2));
497
507
  }
498
508
  catch { /* non-fatal */ }
@@ -575,6 +585,19 @@ function touchHandoffStamp() {
575
585
  }
576
586
  catch { /* */ }
577
587
  }
588
+ /** "Someone launched me while you are already running — show yourself."
589
+ *
590
+ * A file rather than a socket, for the same reason the mailto and share
591
+ * handoffs use one: the launching process is a fresh node that will exit in
592
+ * milliseconds, and the daemon is already watching this directory. The daemon
593
+ * side deletes it and sends the native focus command. */
594
+ const __activateFile = path.join(path.dirname(__instanceFile), "pending-activate.json");
595
+ function requestActivate() {
596
+ try {
597
+ fs.writeFileSync(__activateFile, JSON.stringify({ at: Date.now(), fromPid: process.pid }));
598
+ }
599
+ catch { /* */ }
600
+ }
578
601
  if (!isDaemon && !__isCommandInvocation && !__keepOthers) {
579
602
  // Handoff launches must NOT replace a live daemon. rmfshare.exe (share
580
603
  // sheet) and rmfmailto.exe (mailto links) write a pending file and spawn
@@ -604,12 +627,35 @@ if (!isDaemon && !__isCommandInvocation && !__keepOthers) {
604
627
  }
605
628
  }
606
629
  }
607
- // Replace-on-launch: any rmfmail daemon already running gets killed and
630
+ // Activate-on-launch: launching the SAME version that is already running
631
+ // raises the window that exists instead of replacing it.
632
+ //
633
+ // Replace-on-launch (below) was written for the upgrade case — an older or
634
+ // orphaned daemon surviving a partial upgrade and leaving two windows on
635
+ // two versions. But it fired on EVERY launch, including the ordinary one:
636
+ // press the taskbar icon, the new process SIGTERMs the running daemon, its
637
+ // window vanishes, and seconds later a fresh one appears. From the outside
638
+ // that reads as the app flickering out of existence for no reason (Bob
639
+ // 2026-08-26: "why does it show and disappear... you should still put me in
640
+ // the running version when I press the icon on the task bar").
641
+ //
642
+ // instance.json already records the running version, so the two cases are
643
+ // distinguishable without guessing: same version means the user wants the
644
+ // app, so surface it; a different version means an upgrade landed and the
645
+ // old daemon must go. `-another` still opts out entirely, and every handoff
646
+ // path above still returns before reaching here.
647
+ {
648
+ const inst0 = readInstanceFile();
649
+ if (inst0 && pidIsMailx(inst0.pid) && inst0.pid !== process.pid && inst0.version === __selfVersion) {
650
+ requestActivate();
651
+ console.log(`rmfmail: already running (PID ${inst0.pid}, v${inst0.version}) — raising its window`);
652
+ process.exit(0);
653
+ }
654
+ }
655
+ // Replace-on-launch: a daemon running a DIFFERENT version gets killed and
608
656
  // the new one takes over. instance.json only tracks ONE PID — older or
609
657
  // orphaned daemons can survive a partial upgrade and leave the user with
610
- // multiple windows on different versions. Sweep PowerShell for any
611
- // rmfmail-looking node process and kill it (unconditionally — version
612
- // match alone isn't enough; we always want exactly one daemon).
658
+ // multiple windows on different versions.
613
659
  const myPid = process.pid;
614
660
  const killedPids = [];
615
661
  const inst = readInstanceFile();
@@ -681,6 +727,30 @@ if (!verbose && !isDaemon && !process.argv.slice(2).some(a => /^-/.test(a))) {
681
727
  windowsHide: true,
682
728
  });
683
729
  child.unref();
730
+ // Claim the instance slot for the child NOW, before we exit.
731
+ //
732
+ // Without this there is a multi-second hole in which no instance exists on
733
+ // paper: we spawn a detached daemon and exit immediately, but the daemon
734
+ // does not register itself until it has built the store, started the
735
+ // popout server and opened its window — several seconds later. Any launch
736
+ // arriving inside that hole reads instance.json, sees nothing alive, and
737
+ // starts ANOTHER daemon. That is how three of them ended up running at
738
+ // once on 2026-08-26, spawned 4 and 5 seconds apart (PIDs 1452, 34012,
739
+ // 94964), each with its own window, only one of them in instance.json —
740
+ // which is also why nothing came to the front and why replace-on-launch
741
+ // could never clean them up: it only ever knew about the last one to
742
+ // register (Bob: "this does not look like a single instance ... but not
743
+ // one of the windows came to the front").
744
+ //
745
+ // The child is alive from the moment spawn returns, and pidIsMailx is a
746
+ // liveness check, so the claim is valid immediately. If the child dies
747
+ // during boot the existing stale-PID check clears it on the next launch,
748
+ // and when the daemon does reach its own registration it writes the same
749
+ // PID again — no conflict. A launch that lands during boot now takes the
750
+ // activate path and drops pending-activate.json, which the daemon picks up
751
+ // when it starts watching. (Claude Code 2026-08-27)
752
+ if (typeof child.pid === "number")
753
+ writeInstanceFile(child.pid, []);
684
754
  process.exit(0);
685
755
  }
686
756
  const setupMode = hasFlag("setup");
@@ -3104,6 +3174,53 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
3104
3174
  console.error(` [mailto] watch setup failed: ${e.message}`);
3105
3175
  }
3106
3176
  }
3177
+ // Activation requests. A second launch of the SAME version (taskbar icon,
3178
+ // Start menu, a double-clicked shortcut) drops pending-activate.json and
3179
+ // exits instead of replacing us; this is the half that answers it by
3180
+ // bringing the existing window to the front. Same fs.watch + native
3181
+ // control-message shape as the mailto and share handoffs below.
3182
+ //
3183
+ // Without this the launcher would have nothing to hand off TO, and
3184
+ // "already running — raising its window" would be a lie: the process would
3185
+ // exit and the user would be left staring at whatever they were looking at
3186
+ // before, which is worse than the restart it replaced.
3187
+ {
3188
+ try {
3189
+ const dir = path.dirname(__activateFile);
3190
+ const baseName = path.basename(__activateFile);
3191
+ fs.mkdirSync(dir, { recursive: true });
3192
+ // A request that landed while we were still booting — the watch
3193
+ // below can only see files that arrive after it is installed.
3194
+ if (fs.existsSync(__activateFile)) {
3195
+ try {
3196
+ fs.unlinkSync(__activateFile);
3197
+ }
3198
+ catch { /* */ }
3199
+ handle.send({ _msgerWindow: "focus" });
3200
+ }
3201
+ fs.watch(dir, (_event, filename) => {
3202
+ if (filename !== baseName)
3203
+ return;
3204
+ if (!fs.existsSync(__activateFile))
3205
+ return;
3206
+ // Delete FIRST. fs.watch fires twice for one write on Windows
3207
+ // (rename + change), and a second focus command is harmless but
3208
+ // the unlink is what makes the second pass a no-op.
3209
+ try {
3210
+ fs.unlinkSync(__activateFile);
3211
+ }
3212
+ catch {
3213
+ return;
3214
+ }
3215
+ touchHandoffStamp();
3216
+ console.log(" [activate] second launch asked for the window — raising it");
3217
+ handle.send({ _msgerWindow: "focus" });
3218
+ });
3219
+ }
3220
+ catch (e) {
3221
+ console.error(` [activate] watch setup failed: ${e.message}`);
3222
+ }
3223
+ }
3107
3224
  // Pending Windows-share handler (C46). Same two-path pickup as mailto
3108
3225
  // above: the client's startup consumePendingShare poll covers the
3109
3226
  // "rmfshare.exe spawned us" race; this fs.watch covers shares that land