package notify import ( "bytes" "log" "os" "path/filepath" "regexp" "strings" "sync" "testing" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/mailhold" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // MAIL-HOLD — a restored hub starts quiet. Evidence: the 2026-10-09 restore drill // (documentation/audits/dooplex-survival-2026-10-09/partD-05-checks.txt): a hub started on a restored database mailed // households pending kernel notices within a minute, and `notifications.operator_enabled: false` did not stop it. // // COMPANION RED-PROOF (REPORT): make deliver() skip the mailHold.Check call (the pre-fix shape: sendEmailFn is reached // directly) → TestMailHold_DispatcherSendsNothingWhileHeld fails with every path's mail SENT. type holdRecorder struct { mu sync.Mutex sent []string // subjects } func (r *holdRecorder) fn(to, subject, body string, headers map[string]string) error { r.mu.Lock() defer r.mu.Unlock() r.sent = append(r.sent, subject) return nil } func (r *holdRecorder) count() int { r.mu.Lock() defer r.mu.Unlock() return len(r.sent) } // holdDispatcher returns a dispatcher whose household c1 has a registered address, notification prefs with // backup_failed on, the operator channel ON, the MAIL-HOLD marker present, and a log captured in buf. func holdDispatcher(t *testing.T) (*Dispatcher, *store.Store, *holdRecorder, *mailhold.Hold, *bytes.Buffer) { t.Helper() st := newDispStore(t) if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "k", RetrievalPassword: "p", Email: "household@example.hu"}); err != nil { t.Fatal(err) } if err := st.SaveNotificationPrefs("c1", "household@example.hu", []string{"backup_failed"}, 6); err != nil { t.Fatal(err) } buf := &bytes.Buffer{} logger := log.New(buf, "", 0) d := NewDispatcher(st, "test-key", "from@felhom.eu", "op@felhom.eu", true, logger) rec := &holdRecorder{} d.sendEmailFn = rec.fn d.afterFn = func(time.Duration, func()) {} // a retry must never fire in these tests dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, mailhold.FileName), nil, 0o644); err != nil { t.Fatal(err) } hold := mailhold.New(dir, logger) d.SetMailHold(hold) return d, st, rec, hold, buf } // The consequence asserted: with the marker present, NOT ONE mail reaches the sender, on any dispatcher path — // household event, operator event, recovery, test mail, kernel notice, claim code, self-bind link. func TestMailHold_DispatcherSendsNothingWhileHeld(t *testing.T) { d, st, rec, _, buf := holdDispatcher(t) d.ProcessEvent("c1", "backup_failed", "error", "Backup failed", "", "box") // operator + household d.ProcessEvent("c1", "test", "info", "test", "", "operator") // test mail, both channels if _, err := d.SendKernelNotice("c1", "6.8.12-1"); err == nil { t.Errorf("kernel notice: a held mail must report an error (the caller then runs no kernel step)") } if err := d.SendClaimEmail("claim", "c1", "household@example.hu", "c1.example", "CODE-1234"); err == nil { t.Errorf("claim mail: a held mail must report an error") } if err := d.SendSelfBindEmail("c1", "household@example.hu", "https://hub.example/bind/x"); err == nil { t.Errorf("self-bind mail: a held mail must report an error") } if n := rec.count(); n != 0 { t.Fatalf("MAIL-HOLD present but %d mail(s) were SENT: %v", n, rec.sent) } out := buf.String() if got := strings.Count(out, "[WARN] MAIL-HOLD: not sending"); got < 7 { t.Errorf("each held mail is logged once: want >= 7 MAIL-HOLD lines, got %d\n%s", got, out) } if strings.Contains(out, "household@example.hu") || strings.Contains(out, "op@felhom.eu") { t.Errorf("the hold's log must carry no address:\n%s", out) } if strings.Contains(out, "CODE-1234") { t.Errorf("the hold's log must carry no mail body") } // The record says what happened: held, not sent and not failed. if _, ok, _ := st.LastCustomerSentAt("c1", []string{"backup_failed"}); ok { t.Errorf("a held household mail was recorded as SENT") } } // After the release the hub mails again — and the very same event key mails at once: the cooldown a held mail armed is // given back, so the first real alarm after the release is not silenced by a mail that was never sent. func TestMailHold_ReleaseResumesSends(t *testing.T) { d, _, rec, hold, _ := holdDispatcher(t) d.ProcessEvent("c1", "backup_failed", "error", "Backup failed", "", "box") if rec.count() != 0 { t.Fatalf("held: %d mail(s) sent", rec.count()) } if err := hold.Release(); err != nil { t.Fatalf("release: %v", err) } if hold.Held() { t.Fatalf("the marker is still there after Release") } d.ProcessEvent("c1", "backup_failed", "error", "Backup failed", "", "box") if n := rec.count(); n != 2 { t.Fatalf("after the release the operator AND the household mail must go out: sent=%d %v", n, rec.sent) } // Held mails are DROPPED, not re-sent: exactly the two fresh ones, no backlog. } // Belt for the "one gate" claim: in dispatcher.go the sender seam is called in exactly ONE place, inside deliver(). // A new send path that calls sendEmailFn directly would bypass the hold; this fails when one appears. func TestMailHold_EverySendPathIsGated(t *testing.T) { src, err := os.ReadFile("dispatcher.go") if err != nil { t.Fatal(err) } calls := regexp.MustCompile(`d\.sendEmailFn\(`).FindAllIndex(src, -1) if len(calls) != 1 { t.Fatalf("dispatcher.go calls d.sendEmailFn( %d times; every send must go through deliver() (the MAIL-HOLD gate)", len(calls)) } body := string(src) i := strings.Index(body, "func (d *Dispatcher) deliver(") if i < 0 || calls[0][0] < i || calls[0][0] > i+600 { t.Fatalf("the one d.sendEmailFn( call is not inside deliver()") } }