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
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:
@@ -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"}`))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user