hub (unreleased): R-922 option A — a household's clear deletes its notification address (email_cleared); MAIL-HOLD — a restored hub sends no mail until released; two log lines drop the address; runbooks: mail hold is restore step 1; 07 §6.4 R-921 pre-check; R-921/R-922 narrowed
gates / gates (push) Successful in 5m25s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-09 12:37:28 +02:00
parent 2c48feb325
commit d55c590c5a
35 changed files with 861 additions and 23 deletions
+39 -1
View File
@@ -20,6 +20,7 @@ import (
"gitea.dooplex.hu/admin/felhom-hub/internal/claim"
"gitea.dooplex.hu/admin/felhom-hub/internal/gitea"
"gitea.dooplex.hu/admin/felhom-hub/internal/intent"
"gitea.dooplex.hu/admin/felhom-hub/internal/mailhold"
"gitea.dooplex.hu/admin/felhom-hub/internal/monitor"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
"gitea.dooplex.hu/admin/felhom-hub/internal/poke"
@@ -124,13 +125,20 @@ type Server struct {
// beforeCreateSave is a TEST seam: called in handleConfigCreate after the duplicate check, before the
// save. nil in production.
beforeCreateSave func(customerID string)
// mailHold is the hub-wide mail gate (internal/mailhold); nil never holds.
mailHold *mailhold.Hold
// cfAPIBase is a TEST seam for the Cloudflare token reach check (R-138 option C): "" = the real API.
cfAPIBase string
}
// New creates a new web server.
func New(store *store.Store, passwordHash, apiKey, version string, staleThreshold time.Duration, logger *log.Logger) *Server {
// srv is assigned below; template funcs that read server state close over it (they run at render time only).
var srv *Server
funcMap := template.FuncMap{
// mailHeld drives the MAIL-HOLD banner on every operator page (internal/mailhold). Read at render time, so the
// banner disappears the moment the marker is gone. Pinned by TestMailHoldBanner_*.
"mailHeld": func() bool { return srv != nil && srv.mailHold.Held() },
"timeAgo": timeAgo,
"timeAgoPtr": func(t *time.Time) string {
if t == nil {
@@ -160,7 +168,7 @@ func New(store *store.Store, passwordHash, apiKey, version string, staleThreshol
tmpl := template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html"))
return &Server{
srv = &Server{
store: store,
configPasswordHash: passwordHash,
apiKey: apiKey,
@@ -171,6 +179,7 @@ func New(store *store.Store, passwordHash, apiKey, version string, staleThreshol
sessions: make(map[string]*hubSession),
bindLimiter: newBindRateLimiter(30), // public /bind/ surface: 30 req/min/IP burst (R-27)
}
return srv
}
// effectivePasswordHash returns the operator login password bcrypt hash in force: the UI-set DB
@@ -406,6 +415,29 @@ func (s *Server) artifactChoices(ctx context.Context, pkg, file string) []artifa
}
// SetEventEmitter wires the notification dispatcher (R-604). INIT-ONLY.
// SetMailHold wires the hub-wide mail gate (internal/mailhold): the banner on every operator page and the release
// endpoint. nil = never held (no banner, the release is a no-op).
func (s *Server) SetMailHold(m *mailhold.Hold) {
s.mailHold = m
}
// handleMailHoldRelease lifts the MAIL-HOLD: it removes the marker, and the hub sends e-mail again from the next mail
// on. Mails dropped during the hold are NOT re-sent (internal/mailhold explains why). Operator-only: it sits behind
// RequireAuth and the R-135 CSRF gate in ServeHTTP like every other state-changing route.
func (s *Server) handleMailHoldRelease(w http.ResponseWriter, r *http.Request) {
if s.mailHold == nil || !s.mailHold.Held() {
http.Redirect(w, r, "/configuration?flash=mail_hold_not_held", http.StatusSeeOther)
return
}
if err := s.mailHold.Release(); err != nil {
s.logger.Printf("[ERROR] MAIL-HOLD: release failed — the marker is still in place: %v", err)
http.Redirect(w, r, "/configuration?flash=mail_hold_release_failed", http.StatusSeeOther)
return
}
s.logger.Printf("[INFO] MAIL-HOLD released by the operator from %s — the hub sends e-mail again; mail held until now was dropped", r.RemoteAddr)
http.Redirect(w, r, "/configuration?flash=mail_hold_released", http.StatusSeeOther)
}
func (s *Server) SetEventEmitter(f func(customerID, eventType, severity, message, detailsJSON, source string)) {
s.emit = f
}
@@ -668,6 +700,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case path == "/configuration/mail-hold/release":
if r.Method == http.MethodPost {
s.handleMailHoldRelease(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case path == "/configuration/password":
if r.Method == http.MethodPost {
s.handleChangePassword(w, r)