Files
felhom-controller/controller/internal/web/recovery_older_mail.go
T
admin a40729a698
gates / gates (push) Successful in 59s
R-304 option C: operator mail when a household's code opens or may open an older package (once a day); R-298 side fix (no eject/format on a backup-target drive); R-717 comments
Unreleased; ships with tomorrow's release (needs the hub of the same day).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 10:07:03 +02:00

83 lines
3.6 KiB
Go

package web
import (
"os"
"path/filepath"
"strings"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
// R-304 option C (operator ruling 2026-10-08 09:04, `09` §3 decision 183): when a household's recovery code OPENS an
// older sealed package (the agent's 422), or MAY open one (424 — not every older package was tried), the OPERATOR gets
// one mail: retention is an operator-only capability (R-312), so „contact support" on the screen is only true in
// practice if the operator already knows. Never the code, never a password — the class and the package's date only.
//
// At most ONCE PER DAY per box: the day (UTC, YYYY-MM-DD) of the last send is kept in `<data>/recovery-older-mail.day`,
// so a controller restart does not mail twice. A household retyping its code ten times sends one mail.
// The event type `recovery_older_package` is operator-only at the hub (`notify.operatorOnlyEvents`, same day's hub).
// Pinned by TestR304_OlderPackageMail_* (recovery_older_mail_test.go).
const recoveryOlderMailFile = "recovery-older-mail.day"
// recoveryOlderPush sends the event; recoveryOlderPushFn is the test seam (nil → the notifier).
// The severity is a literal on purpose: the hub's severity vocabulary is checked statically
// (TestR329_EveryEmittedSeverityIsInTheHubVocabulary).
func (s *Server) recoveryOlderPush(message string, details map[string]string) {
if s.recoveryOlderPushFn != nil {
s.recoveryOlderPushFn("recovery_older_package", "warning", message, details)
return
}
if s.notifier != nil {
s.notifier.PushEvent("recovery_older_package", "warning", message, details)
}
}
func (s *Server) recoveryOlderMailPath() string {
if s.cfg == nil || s.cfg.Paths.DataDir == "" {
return ""
}
return filepath.Join(s.cfg.Paths.DataDir, recoveryOlderMailFile)
}
// notifyRecoveryOlderPackage sends the operator mail for a 422/424 unlock, at most once per day.
func (s *Server) notifyRecoveryOlderPackage(class agentapi.RecoveryFailure, supersededAt string) {
today := s.recoveryNow().UTC().Format("2006-01-02")
p := s.recoveryOlderMailPath()
s.recoveryOlderMu.Lock()
defer s.recoveryOlderMu.Unlock()
last := s.recoveryOlderLastDay
if p != "" {
if b, err := os.ReadFile(p); err == nil {
last = strings.TrimSpace(string(b))
}
}
if last == today {
s.logger.Printf("[INFO] [web] recovery: older-package operator mail already sent today — not again (R-304)")
return
}
when := supersededAt
if when == "" {
when = "unknown"
}
var msg string
switch class {
case agentapi.RecoveryCodeOpensRetained:
msg = "A household's recovery code OPENED an older sealed escrow package (superseded " + when + ") — not the current one. " +
"Their old off-site history may be what they need; reopening it is operator-only (R-312). The screen told them to contact support."
default:
msg = "A household's recovery code did not open the current sealed escrow package, and NOT every older package was tried " +
"(newest older package superseded " + when + ") — the code may belong to one of them. The screen told them to contact support."
}
s.recoveryOlderPush(msg, map[string]string{
"class": class.String(), "superseded_at": supersededAt,
})
s.recoveryOlderLastDay = today
if p != "" {
if err := os.WriteFile(p, []byte(today+"\n"), 0o600); err != nil {
s.logger.Printf("[WARN] [web] recovery: could not record today's older-package mail (%v) — a restart may send one more today", err)
}
}
s.logger.Printf("[INFO] [web] recovery: operator told — the code %s an older package (R-304)", map[bool]string{true: "opens", false: "may open"}[class == agentapi.RecoveryCodeOpensRetained])
}