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 `/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]) }