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
+33 -3
View File
@@ -20,6 +20,7 @@ import (
"gitea.dooplex.hu/admin/felhom-hub/internal/claim"
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
"gitea.dooplex.hu/admin/felhom-hub/internal/intent"
"gitea.dooplex.hu/admin/felhom-hub/internal/mailhold"
"gitea.dooplex.hu/admin/felhom-hub/internal/mailrelay"
"gitea.dooplex.hu/admin/felhom-hub/internal/notify"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
@@ -67,7 +68,9 @@ type Handler struct {
floorNotes sync.Map
// App-email passthrough (POST /api/v1/mail). nil sender = endpoint returns 503.
mailSender mailrelay.Sender
mailSender mailrelay.Sender
// mailHold is the hub-wide mail gate (MAIL-HOLD marker; internal/mailhold). nil never holds.
mailHold *mailhold.Hold
mailLimiter *mailRateLimiter
mailFromAllow map[string]bool
@@ -156,6 +159,12 @@ func New(store *store.Store, apiKey, resendAPIKey, fromEmail string, templatePro
}
}
// SetMailHold wires the hub-wide mail gate: while the MAIL-HOLD marker exists, /notify and the app-mail relay send
// nothing (internal/mailhold).
func (h *Handler) SetMailHold(m *mailhold.Hold) {
h.mailHold = m
}
// SetDispatcher sets the notification dispatcher for event-triggered emails.
func (h *Handler) SetDispatcher(d *notify.Dispatcher) {
h.dispatcher = d
@@ -2583,6 +2592,14 @@ func (h *Handler) handleNotify(w http.ResponseWriter, r *http.Request) {
return
}
// MAIL-HOLD (internal/mailhold): a restored or quarantined hub sends nothing. The mail is dropped, not queued.
if err := h.mailHold.Check("customer event "+payload.EventType, payload.CustomerID); err != nil {
h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "held", "MAIL-HOLD marker present — dropped", "customer")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok","sent":false,"reason":"mail_hold"}`))
return
}
subject, emailBody := formatNotificationEmail(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, payload.Details)
sendErr := h.sendResendEmail(prefs.Email, subject, emailBody)
if sendErr != nil {
@@ -2617,6 +2634,10 @@ func (h *Handler) handleSavePreferences(w http.ResponseWriter, r *http.Request)
Email string `json:"email"`
EnabledEvents []string `json:"enabled_events"`
CooldownHours int `json:"cooldown_hours"`
// EmailCleared (R-922, operator ruling 2026-10-09 option A) is sent true by the controller ONLY when the
// household deliberately cleared its address on the dashboard. Omitted (false) on every other push —
// including every push from a controller older than the one that emits it.
EmailCleared bool `json:"email_cleared"`
}
if err := json.Unmarshal(body, &payload); err != nil || payload.CustomerID == "" {
http.Error(w, "Invalid payload: customer_id required", http.StatusBadRequest)
@@ -2627,8 +2648,17 @@ func (h *Handler) handleSavePreferences(w http.ResponseWriter, r *http.Request)
// (e.g. an unconfigured box) must never wipe a stored non-empty address — the seeded/edited
// email is the customer's alert lifeline. Events + cooldown from the push still apply; a push
// with a non-empty email updates everything (customer edits keep working).
//
// R-922: the ONE exception is a deliberate clear — `email_cleared: true` with an empty address. The household
// removed its address, so the hub deletes it (personal data the household withdrew does not stay on our side).
// The guard cannot tell a clear from an unconfigured box by the address alone, which is why the clear travels
// as its own flag. A flag beside a NON-empty address is an ordinary update (the address wins). Pinned by
// TestSavePreferences_DeliberateClearDeletesAddress, TestSavePreferences_EmptyEmailCannotClobber and
// TestSavePreferences_ClearFlagWithAddressIsAnUpdate.
saveEmail := payload.Email
if saveEmail == "" {
if saveEmail == "" && payload.EmailCleared {
h.logger.Printf("[INFO] Notification prefs push for %s: household cleared its mail address — deleted", payload.CustomerID)
} else if saveEmail == "" {
if existing, err := h.store.GetNotificationPrefs(payload.CustomerID); err == nil && existing != nil && existing.Email != "" {
saveEmail = existing.Email
h.logger.Printf("[INFO] Notification prefs push for %s had empty email — preserving stored address", payload.CustomerID)
@@ -2641,7 +2671,7 @@ func (h *Handler) handleSavePreferences(w http.ResponseWriter, r *http.Request)
return
}
h.logger.Printf("[INFO] Notification preferences updated for %s: email=%s, events=%v", payload.CustomerID, saveEmail, payload.EnabledEvents)
h.logger.Printf("[INFO] Notification preferences updated for %s: address set=%t, events=%v", payload.CustomerID, saveEmail != "", payload.EnabledEvents) // never the address (R-922)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}
+11
View File
@@ -144,6 +144,17 @@ func (h *Handler) handleMail(w http.ResponseWriter, r *http.Request) {
return
}
// MAIL-HOLD (internal/mailhold): the relay sends nothing while the hub holds mail. 503 — a temporary refusal the
// box's shim surfaces to the app; the hub keeps no copy (held mail is dropped, never queued).
holdWho := custID
if holdWho == "" {
holdWho = "a global-key caller"
}
if err := h.mailHold.Check("app mail", holdWho); err != nil {
http.Error(w, "Mail is on hold at the hub", http.StatusServiceUnavailable)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
if err := h.mailSender.Send(ctx, req.RawMIME, req.MailFrom, req.RcptTo); err != nil {
+87
View File
@@ -0,0 +1,87 @@
package api
import (
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/mailhold"
)
// MAIL-HOLD on the two API send paths (the dispatcher's paths: internal/notify/mailhold_test.go).
// COMPANION RED-PROOF (REPORT): remove the h.mailHold.Check block from handleNotify / handleMail → these fail with the
// mail handed to Resend (the recorded transport / the fake SMTP sender).
func heldHold(t *testing.T) *mailhold.Hold {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, mailhold.FileName), nil, 0o644); err != nil {
t.Fatal(err)
}
return mailhold.New(dir, nil)
}
// recordingTransport stands in for api.resend.com: it records each request and never leaves the process.
type recordingTransport struct {
mu sync.Mutex
calls int
}
func (rt *recordingTransport) RoundTrip(r *http.Request) (*http.Response, error) {
rt.mu.Lock()
rt.calls++
rt.mu.Unlock()
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"id":"x"}`)), Header: http.Header{}}, nil
}
func TestMailHold_NotifySendsNothingWhileHeld(t *testing.T) {
h, st := newEventTestHandler(t)
rt := &recordingTransport{}
h.httpClient = &http.Client{Transport: rt}
h.resendAPIKey = "test-key"
if err := st.SaveNotificationPrefs("c1", "household@example.hu", []string{"backup_failed"}, 6); err != nil {
t.Fatal(err)
}
h.SetMailHold(heldHold(t))
rr := do(h, http.MethodPost, "/notify", "ckey", `{"customer_id":"c1","event_type":"backup_failed","severity":"error","message":"m"}`)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
}
if rt.calls != 0 {
t.Fatalf("MAIL-HOLD present but /notify handed %d mail(s) to Resend", rt.calls)
}
if !strings.Contains(rr.Body.String(), `"reason":"mail_hold"`) {
t.Fatalf("the answer must say the mail was held: %s", rr.Body.String())
}
}
func TestMailHold_AppMailRelayRefusesWhileHeld(t *testing.T) {
h, st, _ := newTestHandler(t)
withCustomer(t, st, "c1", "ckey")
fake := &fakeSender{}
h.SetMailRelay(fake, 30, []string{"felhom.eu"})
hold := heldHold(t)
h.SetMailHold(hold)
rr := do(h, "POST", "/mail", "ckey", mailBody(t, "vaultwarden@felhom.eu"))
if fake.callCount() != 0 {
t.Fatalf("MAIL-HOLD present but the relay sent %d mail(s)", fake.callCount())
}
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("held relay: status %d, want 503", rr.Code)
}
// Released: the relay sends again.
if err := hold.Release(); err != nil {
t.Fatal(err)
}
rr = do(h, "POST", "/mail", "ckey", mailBody(t, "vaultwarden@felhom.eu"))
if rr.Code != http.StatusOK || fake.callCount() != 1 {
t.Fatalf("after release: status %d, sends %d — want 200 and 1", rr.Code, fake.callCount())
}
}
@@ -65,3 +65,64 @@ func TestSavePreferences_EmptyEmailNoStoredRow(t *testing.T) {
t.Fatalf("all-off push must store the empty row unchanged, got %+v", prefs)
}
}
// TestSavePreferences_DeliberateClearDeletesAddress (R-922, operator ruling 2026-10-09 option A): when the household
// clears its address on the dashboard, the controller pushes `email_cleared: true` with an empty address, and the
// hub DELETES the stored address. Seen on Tester 1 before the fix: the push answered 200 and the old address stayed.
// Red-proof: with the `payload.EmailCleared` branch removed, this fails with email="seeded@example.hu".
func TestSavePreferences_DeliberateClearDeletesAddress(t *testing.T) {
h, st := newEventTestHandler(t)
if err := st.SaveNotificationPrefs("c1", "seeded@example.hu", []string{"node_down"}, 6); err != nil {
t.Fatalf("stored prefs: %v", err)
}
rr := do(h, http.MethodPost, "/preferences", "ckey",
`{"customer_id":"c1","email":"","email_cleared":true,"enabled_events":[],"cooldown_hours":6}`)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
}
prefs, err := st.GetNotificationPrefs("c1")
if err != nil || prefs == nil {
t.Fatalf("prefs: %+v err=%v", prefs, err)
}
if prefs.Email != "" {
t.Fatalf("a deliberate clear must delete the stored address, still stored: email=%q", prefs.Email)
}
}
// TestSavePreferences_ClearFlagWithAddressIsAnUpdate (R-922): `email_cleared: true` beside a NON-empty address is an
// ordinary update — the address in the push wins, it is never deleted.
func TestSavePreferences_ClearFlagWithAddressIsAnUpdate(t *testing.T) {
h, st := newEventTestHandler(t)
if err := st.SaveNotificationPrefs("c1", "old@example.hu", []string{"node_down"}, 6); err != nil {
t.Fatalf("stored prefs: %v", err)
}
rr := do(h, http.MethodPost, "/preferences", "ckey",
`{"customer_id":"c1","email":"new@example.hu","email_cleared":true,"enabled_events":["node_down"],"cooldown_hours":6}`)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
}
prefs, _ := st.GetNotificationPrefs("c1")
if prefs == nil || prefs.Email != "new@example.hu" {
t.Fatalf("flag + non-empty address must store the pushed address, got %+v", prefs)
}
}
// TestSavePreferences_ClearFlagFalseKeepsAddress (R-922): an explicit `email_cleared: false` with an empty address is
// NOT a clear — the F12 no-clobber guard still holds.
func TestSavePreferences_ClearFlagFalseKeepsAddress(t *testing.T) {
h, st := newEventTestHandler(t)
if err := st.SaveNotificationPrefs("c1", "seeded@example.hu", []string{"node_down"}, 6); err != nil {
t.Fatalf("stored prefs: %v", err)
}
rr := do(h, http.MethodPost, "/preferences", "ckey",
`{"customer_id":"c1","email":"","email_cleared":false,"enabled_events":["node_down"],"cooldown_hours":6}`)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
}
prefs, _ := st.GetNotificationPrefs("c1")
if prefs == nil || prefs.Email != "seeded@example.hu" {
t.Fatalf("email_cleared:false with an empty address must keep the stored address, got %+v", prefs)
}
}