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