From b905f3e560241972c93acfce11b1a56f56906f43 Mon Sep 17 00:00:00 2001 From: Mix Date: Mon, 12 Oct 2026 16:21:48 +0300 Subject: [PATCH] panel-notifications: Account, Server > Settings, Server > Health, help; e2e The pages of the mockups accepted in 87fa0a4, built from the kit's partials (written by a Sonnet agent into the working tree, reviewed here): - Account: "Send me notifications" in the Profile form, saved with the profile; an unticked box is off. - Server > Settings: the instance switch (box 03), and box 04 Notification mail with the DKIM and SPF records of the server's own name, drawn with the record partial of a domain's DNS page and checked in DNS. Sending rate limits are box 02 in the left column. A record that is not in place is tagged as a warning, not a failure: notifications are sent without it. - Server > Health: card 06 Notification mail with the state of each record and a link to Settings, beside Configuration (07); part of the polled fragment; Re-check DNS forces it too. Overview does not run this check. - help.go: the Notifications topic and the clauses in Settings and Your account, word for word with help.html. If the server's key cannot be read the pages still render: the DKIM record is left out and SPF is shown unchecked. e2e: the administrator gets a mailbox behind the sink and the server's DKIM record from Settings is published; the level-2 refusal of the next step must then arrive as a notification from postmaster@mail.e2e.test, signed as that name with the key Settings shows, and must not be a row of the Outbound log. The rotation step now waits for Postfix to be running: it follows a step that restarts the container, and logrotate's `postfix reload` failed in between. On the test server, for this tree: go vet clean, go test ./... 23 packages ok, make e2e ok (147 s); the notification reached the sink 28 s after the refusal. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + docs/plans/panel-notifications.md | 4 +- docs/roadmap.md | 2 +- internal/web/handlers/fakedns_test.go | 134 ++++++++ internal/web/handlers/handlers_account.go | 16 +- .../handlers/handlers_notifications_test.go | 315 ++++++++++++++++++ internal/web/handlers/handlers_settings.go | 70 +++- .../web/handlers/handlers_settings_test.go | 7 +- internal/web/handlers/handlers_status.go | 14 +- internal/web/view/account.go | 34 ++ internal/web/view/fixtures_test.go | 22 +- internal/web/view/health.go | 46 ++- internal/web/view/help.go | 15 +- internal/web/view/outbound.go | 35 ++ internal/web/view/pages_test.go | 123 +++++-- internal/web/view/templates/account.html | 2 + internal/web/view/templates/health.html | 22 +- internal/web/view/templates/health_body.html | 21 +- internal/web/view/templates/settings.html | 21 +- test/e2e/delivery_log_test.go | 10 + test/e2e/main_test.go | 6 + test/e2e/notifications_test.go | 137 ++++++++ 22 files changed, 1003 insertions(+), 59 deletions(-) create mode 100644 internal/web/handlers/fakedns_test.go create mode 100644 internal/web/handlers/handlers_notifications_test.go create mode 100644 test/e2e/notifications_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f18d512..b0927c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version `SELFPOST_HOSTNAME` and OpenDKIM signs mail submitted locally as that name (`non_smtpd_milters`), so that notifications arrive. The name is not a sending domain: no application can send as it. +- **Notifications in the panel.** Account has *Send me notifications* beside + the e-mail; Server › Settings has the switch for everyone and the two DNS + records of the server's own name, DKIM and SPF, with the values to publish; + Server › Health has a *Notification mail* card with the state of each. Help + has a Notifications topic. Every user starts with notifications on, and so + does the instance. ## [2.2.0] - 2026-10-12 diff --git a/docs/plans/panel-notifications.md b/docs/plans/panel-notifications.md index f1f242e..edc534e 100644 --- a/docs/plans/panel-notifications.md +++ b/docs/plans/panel-notifications.md @@ -112,8 +112,8 @@ to the owner before any code ([panel-redesign.md](panel-redesign.md) - [x] Sending: local submission from `postmaster@`, message text (`sendmail` as the panel user; signed through `non_smtpd_milters` with a key of the server's own name; no link in the text - the panel does not know its own address) - [x] Background checker: DNS, certificate, queue; the rules of *When a message is sent* - [x] Level-2 refusal event (from the `rejected` rows of the send log, looked at every minute) -- [ ] Panel: Account, Settings, Health check, help +- [x] Panel: Account, Settings, Health card, help - [x] guide.md, architecture.md, security.md, schema-migrations.md -- [ ] `go vet`, `go test`, e2e: an event reaches a mailbox once and clears once +- [x] `go vet`, `go test`, e2e: a level-2 refusal reaches a mailbox as a signed notification (on the test server: vet ok, 23 packages ok, e2e ok; "once" and "clears" are held by the unit tests of `internal/notify`) - [ ] Technical review - [ ] Security review diff --git a/docs/roadmap.md b/docs/roadmap.md index cb2d801..86ace11 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -35,7 +35,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md). | template-data-typing | Typed structs for template data instead of `map[string]any` | candidate | — | — | | structured-logging | `log/slog` with levels and fields | candidate | — | — | | review-2026-08-followups | Remaining findings of the 2026-08-19 code review | candidate | — | — | -| panel-notifications | E-mail notifications about important events | in progress | 6/10 | [plans/panel-notifications.md](plans/panel-notifications.md) | +| panel-notifications | E-mail notifications about important events | in progress | 8/10 | [plans/panel-notifications.md](plans/panel-notifications.md) | | delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | shipped `2.2.0` | 8/8 | [plans/delivery-log-storage.md](plans/delivery-log-storage.md) | | password-reset | Password reset by e-mail | candidate | — | — | | phone-desktop-mode | A phone browser in "desktop site" mode gets the phone navigation | candidate | — | — | diff --git a/internal/web/handlers/fakedns_test.go b/internal/web/handlers/fakedns_test.go new file mode 100644 index 0000000..68019f7 --- /dev/null +++ b/internal/web/handlers/fakedns_test.go @@ -0,0 +1,134 @@ +package handlers + +import ( + "encoding/binary" + "net" + "strings" + "sync" + "testing" +) + +// fakeZone is a DNS server on the loopback that answers from a table, so that a +// page can be rendered against records that are published, wrong or absent +// without leaving the machine. Names are lower case without the trailing dot; +// a name the zone does not hold is NXDOMAIN, which is how a record that is not +// published looks to the checker. +type fakeZone struct { + mu sync.Mutex + txt map[string][]string + addr map[string]string // name -> IPv4 address +} + +// newFakeZone starts the server for the length of the test and returns the +// resolver address to give dnscheck.New. +func newFakeZone(t *testing.T) (*fakeZone, string) { + t.Helper() + conn, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("fake DNS: listen: %v", err) + } + z := &fakeZone{txt: map[string][]string{}, addr: map[string]string{}} + t.Cleanup(func() { _ = conn.Close() }) + go func() { + buf := make([]byte, 1500) + for { + n, from, err := conn.ReadFrom(buf) + if err != nil { + return + } + if resp := z.answer(buf[:n]); resp != nil { + _, _ = conn.WriteTo(resp, from) + } + } + }() + return z, conn.LocalAddr().String() +} + +// publishTXT sets the TXT record of a name; publishA its IPv4 address. +func (z *fakeZone) publishTXT(name, value string) { + z.mu.Lock() + defer z.mu.Unlock() + z.txt[strings.ToLower(name)] = append(z.txt[strings.ToLower(name)], value) +} + +func (z *fakeZone) publishA(name, ip string) { + z.mu.Lock() + defer z.mu.Unlock() + z.addr[strings.ToLower(name)] = ip +} + +// answer builds the reply to one query: the question is copied back, then the +// records of the asked type if the zone has any. A name with nothing of the +// asked type but known under another type is an empty answer (NOERROR), an +// unknown name is NXDOMAIN. +func (z *fakeZone) answer(q []byte) []byte { + if len(q) < 12 || binary.BigEndian.Uint16(q[4:6]) != 1 { + return nil + } + i, labels := 12, []string{} + for { + if i >= len(q) { + return nil + } + l := int(q[i]) + if l == 0 { + i++ + break + } + if i+1+l > len(q) { + return nil + } + labels = append(labels, string(q[i+1:i+1+l])) + i += 1 + l + } + if i+4 > len(q) { + return nil + } + name, qtype := strings.ToLower(strings.Join(labels, ".")), binary.BigEndian.Uint16(q[i:i+2]) + question := q[12 : i+4] + + z.mu.Lock() + txt, ip := z.txt[name], z.addr[name] + z.mu.Unlock() + + var answers [][]byte + switch { + case qtype == 16: + for _, v := range txt { + var rdata []byte + for len(v) > 255 { + rdata = append(rdata, 255) + rdata = append(rdata, v[:255]...) + v = v[255:] + } + rdata = append(rdata, byte(len(v))) + rdata = append(rdata, v...) + answers = append(answers, record(16, rdata)) + } + case qtype == 1 && ip != "": + answers = append(answers, record(1, net.ParseIP(ip).To4())) + } + rcode := uint16(0) + if len(txt) == 0 && ip == "" { + rcode = 3 + } + resp := make([]byte, 12, 512) + copy(resp, q[:2]) + binary.BigEndian.PutUint16(resp[2:], 0x8180|rcode) + binary.BigEndian.PutUint16(resp[4:], 1) + binary.BigEndian.PutUint16(resp[6:], uint16(len(answers))) + resp = append(resp, question...) + for _, a := range answers { + resp = append(resp, a...) + } + return resp +} + +// record is one answer record named by the question (a pointer to offset 12), +// class IN, with a TTL of a minute. +func record(rtype uint16, rdata []byte) []byte { + r := []byte{0xC0, 0x0C, 0, 0, 0, 1, 0, 0, 0, 60, 0, 0} + binary.BigEndian.PutUint16(r[2:], rtype) + binary.BigEndian.PutUint16(r[10:], uint16(len(rdata))) + return append(r, rdata...) +} diff --git a/internal/web/handlers/handlers_account.go b/internal/web/handlers/handlers_account.go index c06c4c7..99084a4 100644 --- a/internal/web/handlers/handlers_account.go +++ b/internal/web/handlers/handlers_account.go @@ -26,6 +26,7 @@ type accountForm struct { Err string Username string Email string + Notify bool } func (h *Handlers) accountUser(w http.ResponseWriter, r *http.Request) (store.User, bool) { @@ -44,7 +45,7 @@ func (h *Handlers) accountUser(w http.ResponseWriter, r *http.Request) (store.Us } func formOf(u store.User) accountForm { - return accountForm{Username: u.Username, Email: u.Email} + return accountForm{Username: u.Username, Email: u.Email, Notify: u.Notify} } // HandleAccount shows the Account page. @@ -58,7 +59,7 @@ func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) { func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, u store.User, f accountForm) { page := view.NewAccount(h.shellMeta(r), string(u.Role)) - page.Username, page.Email = f.Username, f.Email + page.Username, page.Email, page.Notify = f.Username, f.Email, f.Notify page.WithResult(accountFlash(r), f.Err) h.view.Render(w, status, "account", page) } @@ -90,6 +91,8 @@ func (h *Handlers) HandleAccountProfile(w http.ResponseWriter, r *http.Request) } f.Username = strings.TrimSpace(r.PostFormValue("username")) f.Email = strings.TrimSpace(r.PostFormValue("email")) + // An unticked checkbox is not sent at all: absent is off. + f.Notify = r.PostFormValue("notify") != "" if f.Username == "" { f.Username = u.Username } @@ -112,7 +115,14 @@ func (h *Handlers) HandleAccountProfile(w http.ResponseWriter, r *http.Request) fail(http.StatusInternalServerError, "Could not save the profile. Please check the logs and try again.") return } - logf("panel: user %d profile updated (username: %t, e-mail: %t)", u.ID, f.Username != u.Username, f.Email != u.Email) + if f.Notify != u.Notify { + if err := h.store.SetUserNotify(u.ID, f.Notify); err != nil { + logf("panel: account: set notifications of user %d: %v", u.ID, err) + fail(http.StatusInternalServerError, "Could not save the profile. Please check the logs and try again.") + return + } + } + logf("panel: user %d profile updated (username: %t, e-mail: %t, notifications: %t)", u.ID, f.Username != u.Username, f.Email != u.Email, f.Notify) http.Redirect(w, r, "/account?done=profile", http.StatusSeeOther) } diff --git a/internal/web/handlers/handlers_notifications_test.go b/internal/web/handlers/handlers_notifications_test.go new file mode 100644 index 0000000..01393c9 --- /dev/null +++ b/internal/web/handlers/handlers_notifications_test.go @@ -0,0 +1,315 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/mixeme/selfpost/internal/dnscheck" + "github.com/mixeme/selfpost/internal/domain" + "github.com/mixeme/selfpost/internal/health" + "github.com/mixeme/selfpost/internal/web/auth" +) + +// The panel pages of notifications (plans/panel-notifications.md): the user's +// checkbox on Account, the instance's switch and the DNS records of the server's +// own name on Server › Settings, and the state of those records on Server › +// Health. What is sent and when is internal/notify's and tested there. + +const notifyHost = "mail.example.org" + +// ---- Account + +// The checkbox under the e-mail is the user's own switch: the page shows what is +// stored, Save profile writes it, and an unchecked box is a form without the +// field, which means off. Saving it changes nothing else of the profile. +func TestAccountNotifyCheckbox(t *testing.T) { + h, _ := settingsServer(t) + notify := func() bool { + u, _ := h.store.GetUser(globalPrincipal.ID) + return u.Notify + } + save := func(extra url.Values) { + t.Helper() + form := url.Values{"username": {"admin"}, "email": {"mix@example.org"}} + for k, v := range extra { + form[k] = v + } + if rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil, form); rec.Code != http.StatusSeeOther { + t.Fatalf("POST /account/profile %v = %d:\n%s", extra, rec.Code, rec.Body.String()) + } + } + + save(url.Values{"notify": {"on"}}) + if !notify() { + t.Error("a ticked box did not turn notifications on") + } + if out := getBody(t, h.HandleAccount, "/account"); !strings.Contains(out, `name="notify" checked`) { + t.Errorf("a user who has them on is shown an unticked box:\n%s", out) + } + + save(nil) + if notify() { + t.Error("a form without the field did not turn notifications off") + } + if out := getBody(t, h.HandleAccount, "/account"); strings.Contains(out, `name="notify" checked`) || !strings.Contains(out, `name="notify"`) { + t.Errorf("a user who has them off is shown a ticked box, or none:\n%s", out) + } + if u, _ := h.store.GetUser(globalPrincipal.ID); u.Email != "mix@example.org" { + t.Errorf("saving the box lost the e-mail: %q", u.Email) + } +} + +// A profile that is refused saves nothing, the box included, and shows the form +// again as it was submitted. +func TestAccountNotifyIsNotSavedWithARefusedProfile(t *testing.T) { + h, _ := settingsServer(t) + if err := h.store.SetUserNotify(globalPrincipal.ID, false); err != nil { + t.Fatal(err) + } + rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil, + url.Values{"username": {"admin"}, "email": {"not-an-address"}, "notify": {"on"}}) + if rec.Code != http.StatusBadRequest { + t.Fatalf("POST /account/profile with a bad e-mail = %d, want 400", rec.Code) + } + if u, _ := h.store.GetUser(globalPrincipal.ID); u.Notify { + t.Error("a refused profile turned notifications on") + } + if !strings.Contains(rec.Body.String(), `name="notify" checked`) { + t.Error("the form shown again with the error lost the box the user ticked") + } +} + +// ---- Server › Settings + +// The switch is saved with the rest of the page and only a global user can save +// it: a domain administrator is told the page does not exist, and nothing moves. +func TestSettingsNotificationsSwitch(t *testing.T) { + h, _ := settingsServer(t) + on := func() bool { + v, err := h.store.NotificationsEnabled() + if err != nil { + t.Fatal(err) + } + return v + } + save := func(p auth.Principal, notify bool) *httptest.ResponseRecorder { + form := url.Values{"send_log_retention_days": {"60"}} + if notify { + form.Set("notify", "on") + } + return postFormAs(h.HandleServerSettings, p, "/server/settings", nil, form) + } + + if !on() { + t.Fatal("a fresh instance has notifications off") + } + if rec := save(globalPrincipal, false); rec.Code != http.StatusSeeOther || on() { + t.Errorf("an unticked box = %d, switch on: %v; want 303 and off", rec.Code, on()) + } + if out := getBody(t, h.HandleServerSettings, "/server/settings"); strings.Contains(out, `name="notify" checked`) || !strings.Contains(out, `name="notify"`) { + t.Errorf("the page shows the switch on while it is off:\n%s", out) + } + if rec := save(globalPrincipal, true); rec.Code != http.StatusSeeOther || !on() { + t.Errorf("a ticked box = %d, switch on: %v; want 303 and on", rec.Code, on()) + } + if out := getBody(t, h.HandleServerSettings, "/server/settings"); !strings.Contains(out, `name="notify" checked`) { + t.Errorf("the page shows the switch off while it is on:\n%s", out) + } + + d, err := h.store.AddDomain("example.com", "mail") + if err != nil { + t.Fatal(err) + } + ops := domainAdmin(t, h.store, "ops", d.ID) + if rec := save(ops, false); rec.Code != http.StatusNotFound || !on() { + t.Errorf("a domain administrator turning the switch off = %d, switch on: %v; want 404 and on", rec.Code, on()) + } +} + +// A page that is refused (here: a retention out of range) leaves the switch as +// it was, like every other setting on it. +func TestSettingsRefusalLeavesTheNotificationsSwitch(t *testing.T) { + h, _ := settingsServer(t) + rec := postFormAs(h.HandleServerSettings, globalPrincipal, "/server/settings", nil, url.Values{"send_log_retention_days": {"3"}}) + if rec.Code != http.StatusBadRequest { + t.Fatalf("POST /server/settings = %d, want 400", rec.Code) + } + if on, _ := h.store.NotificationsEnabled(); !on { + t.Error("a refused page turned notifications off") + } +} + +// serverMailStand is a panel that knows its own name, has the key notifications +// are signed with, and asks a DNS server that answers from a table: the address +// of the server's name is there, everything else is for the test to publish. +func serverMailStand(t *testing.T) (*Handlers, *fakeZone, domain.ServerMail) { + t.Helper() + h, _ := settingsServer(t) + zone, addr := newFakeZone(t) + zone.publishA(notifyHost, "192.0.2.10") + h.dns = dnscheck.New([]string{addr}) + h.cfg.Hostname = notifyHost + h.machine = &health.MachineSampler{} + h.domains = domain.NewService(h.store, domain.NewOpenDKIM(t.TempDir()), nil, "sp2026") + h.domains.SetServerName(notifyHost) + mail, err := h.domains.ServerMail() + if err != nil { + t.Fatalf("ServerMail: %v", err) + } + return h, zone, mail +} + +// publishMail puts both records of the server's own name into the zone. +func publishMail(zone *fakeZone, mail domain.ServerMail) { + zone.publishTXT(mail.Record.Name, mail.Record.Value) + zone.publishTXT(notifyHost, dnscheck.ServerMailSPF) +} + +// With both records in DNS, Settings gives each with the value to publish and +// the state "published", and no warning about what is missing. +func TestSettingsMailRecordsPublished(t *testing.T) { + h, zone, mail := serverMailStand(t) + publishMail(zone, mail) + + out := getBody(t, h.HandleServerSettings, "/server/settings") + for _, want := range []string{ + `id="notification-mail"`, `DNS records of the server's own name`, + `

DKIM

published`, + `selector sp2026`, `value="sp2026._domainkey.mail.example.org"`, + `

SPF

published`, + `For the server's own name, not for your domains`, + `value="v=spf1 a -all"`, + `postmaster@mail.example.org`, + } { + if !strings.Contains(out, want) { + t.Errorf("settings page is missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "may file them as spam") { + t.Error("the warning about a missing SPF record is shown while it is published") + } +} + +// With nothing published the records are the same and the states say so, as a +// warning and not as the red of a domain: notifications are sent without them. +// The page says what that costs under SPF and what is missing under DKIM. +func TestSettingsMailRecordsMissing(t *testing.T) { + h, _, _ := serverMailStand(t) + + out := getBody(t, h.HandleServerSettings, "/server/settings") + for _, want := range []string{ + `

DKIM

missing`, + `

SPF

missing`, + `value="sp2026._domainkey.mail.example.org"`, `value="v=spf1 a -all"`, + `Notifications are sent without it, and a receiver may file them as spam.`, + `No TXT record is published at sp2026._domainkey.mail.example.org.`, + } { + if !strings.Contains(out, want) { + t.Errorf("settings page is missing %q:\n%s", want, out) + } + } + if strings.Contains(out, `is-danger is-light">missing`) { + t.Error("a record of the server's own name that is missing is drawn as an error") + } +} + +// A DKIM record that holds some other key is a mismatch, and the page shows what +// DNS holds next to what to publish. +func TestSettingsMailDKIMWithAnotherKey(t *testing.T) { + h, zone, mail := serverMailStand(t) + zone.publishTXT(mail.Record.Name, "v=DKIM1; k=rsa; p=AAAAB3NzaC1yc2EAAAADAQABAAABAQDhWrongKeyEntirely") + + out := getBody(t, h.HandleServerSettings, "/server/settings") + for _, want := range []string{`mismatch`, `In DNS now`, `WrongKeyEntirely`} { + if !strings.Contains(out, want) { + t.Errorf("settings page is missing %q:\n%s", want, out) + } + } +} + +// If the key of the server's own name cannot be read the page is still there: +// the DKIM record is left out and SPF is shown unchecked. +func TestSettingsWithoutAServerKeyStillRenders(t *testing.T) { + h, _ := settingsServer(t) // its domain service does not know the server's name + out := getBody(t, h.HandleServerSettings, "/server/settings") + for _, want := range []string{`id="notification-mail"`, `

SPF

not checked`, `name="notify"`} { + if !strings.Contains(out, want) { + t.Errorf("settings page is missing %q", want) + } + } + if strings.Contains(out, "

DKIM

") { + t.Error("a DKIM record is shown for a key that could not be read") + } +} + +// ---- Server › Health + +// Health has the card with the state of each record, the host under its name, +// and the way to the values on Settings. A record that is missing makes the +// card a warning. +func TestHealthNotificationMailCard(t *testing.T) { + h, zone, mail := serverMailStand(t) + zone.publishTXT(mail.Record.Name, mail.Record.Value) // DKIM only + + out := getBody(t, h.HandleHealth, "/server/health") + for _, want := range []string{ + `id="notification-mail"`, `

Notification mail

warn
`, + `DKIM
sp2026._domainkey.mail.example.orgpublished`, + `SPF
mail.example.orgmissing`, + `Sent from postmaster@mail.example.org`, + `href="/server/settings#notification-mail"`, + } { + if !strings.Contains(out, want) { + t.Errorf("health page is missing %q:\n%s", want, out) + } + } +} + +// With both records in place the card is green. The card is part of the polled +// fragment, swapped in by its id like the second row. +func TestHealthNotificationMailCardIsGreenAndPolled(t *testing.T) { + h, zone, mail := serverMailStand(t) + publishMail(zone, mail) + + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/server/health/fragment", nil) + h.HandleHealthFragment(rec, auth.RequestWithPrincipal(req, globalPrincipal)) + out := rec.Body.String() + for _, want := range []string{ + `id="health-mail" hx-swap-oob="true"`, + `

Notification mail

ok
`, + `published`, + } { + if !strings.Contains(out, want) { + t.Errorf("health fragment is missing %q:\n%s", want, out) + } + } + if strings.Contains(out[strings.Index(out, `id="health-mail"`):], "is-warning") { + t.Error("the card warns while both records are published") + } +} + +// The DNS answers are cached for a few minutes; Re-check DNS on Health asks for +// the records of the server's own name again as well as for its address. +func TestHealthRecheckAsksForNotificationMailAgain(t *testing.T) { + h, zone, mail := serverMailStand(t) + if out := getBody(t, h.HandleHealth, "/server/health"); strings.Count(out, `is-warning is-light">missing`) != 2 { + t.Fatalf("before publishing, both records should be missing:\n%s", out) + } + + publishMail(zone, mail) + if out := getBody(t, h.HandleHealth, "/server/health"); strings.Contains(out, `is-success is-light">published`) { + t.Fatal("the cached answer was not used: the test cannot tell a re-check from a fresh read") + } + + rec := postFormAs(h.HandleHealthRecheck, globalPrincipal, "/server/health/recheck", nil, url.Values{}) + if rec.Code != http.StatusSeeOther { + t.Fatalf("POST /server/health/recheck = %d, want 303", rec.Code) + } + if out := getBody(t, h.HandleHealth, "/server/health"); strings.Count(out, `is-success is-light">published`) != 2 { + t.Errorf("after Re-check DNS both records should be published:\n%s", out) + } +} diff --git a/internal/web/handlers/handlers_settings.go b/internal/web/handlers/handlers_settings.go index d562b5b..ad4a569 100644 --- a/internal/web/handlers/handlers_settings.go +++ b/internal/web/handlers/handlers_settings.go @@ -5,13 +5,18 @@ import ( "strconv" "strings" + "github.com/mixeme/selfpost/internal/dnscheck" + "github.com/mixeme/selfpost/internal/health" + "github.com/mixeme/selfpost/internal/notify" "github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/web/view" ) // Server › Settings holds what is true of the whole instance, whoever is // signed in: log retention (the outbound log's, and the inbound log's while the -// inbound relay is on) and the read-only view of the rate limits. A +// inbound relay is on), the read-only view of the rate limits, the switch for +// notification e-mails and the DNS records of the server's own name those are +// sent from. A // user's own name, e-mail, password and DMARC default are on Account // (handlers_account.go). Before placing a setting here, ask whose it is. @@ -47,6 +52,12 @@ func (h *Handlers) currentRetentions() retentions { func (h *Handlers) renderServerSettings(w http.ResponseWriter, r *http.Request, status int, formErr string, form retentions) { page := view.NewSettings(h.shellMeta(r), strconv.Itoa(form.outbound), view.FormatRate(h.l1Messages(), h.l1Window())) + on, err := h.store.NotificationsEnabled() + if err != nil { + logf("panel: settings: read the notifications switch: %v", err) + } + page.WithNotifications(on, notify.From(h.cfg.Hostname)) + page.WithMail(h.serverMail(false).records) // The inbound field exists while there is an inbound journal to keep. if h.cfg.InboundEnabled { page.WithInbound(strconv.Itoa(form.inbound)) @@ -87,12 +98,20 @@ func (h *Handlers) submitServerSettings(w http.ResponseWriter, r *http.Request) return } } + // An unticked checkbox is not sent at all: absent is off. + notifications := r.PostFormValue("notify") != "" if err := h.store.SetSendLogRetentionDays(days); err != nil { logf("panel: settings: set send-log retention failed: %v", err) h.renderServerSettings(w, r, http.StatusInternalServerError, "Could not save send log retention. Please check the logs and try again.", form) return } + if err := h.store.SetNotificationsEnabled(notifications); err != nil { + logf("panel: settings: set the notifications switch failed: %v", err) + h.renderServerSettings(w, r, http.StatusInternalServerError, + "Could not save the notifications switch. Please check the logs and try again.", form) + return + } if h.cfg.InboundEnabled { if err := h.store.SetInboundSpamLogRetentionDays(form.inbound); err != nil { logf("panel: settings: set inbound-log retention failed: %v", err) @@ -100,9 +119,9 @@ func (h *Handlers) submitServerSettings(w http.ResponseWriter, r *http.Request) "Could not save inbound log retention. Please check the logs and try again.", form) return } - logf("panel: instance settings updated (send-log retention: %d days, inbound-log retention: %d days)", days, form.inbound) + logf("panel: instance settings updated (send-log retention: %d days, inbound-log retention: %d days, notifications: %t)", days, form.inbound, notifications) } else { - logf("panel: instance settings updated (send-log retention: %d days)", days) + logf("panel: instance settings updated (send-log retention: %d days, notifications: %t)", days, notifications) } http.Redirect(w, r, "/server/settings?saved=1", http.StatusSeeOther) } @@ -119,3 +138,48 @@ func (h *Handlers) sendLogRetentionDays() int { } return days } + +// serverMailState is the server's own name as a sender of notifications: the +// records to publish for it, as Settings draws them, and their state, as +// Health lists it. +type serverMailState struct { + records []view.Record + rows []view.MailRow + // status is the verdict of Health's card: ok, warn or unknown. A record + // that is not in place is a warning, never an error: without them the + // notifications are still sent. + status string +} + +// serverMail reads the records of the server's own name and checks them in DNS +// (cached like a domain's; force skips the cache, for Re-check DNS). When the +// DKIM key cannot be read the page is still shown: the DKIM record is left out, +// the SPF record is shown unchecked, and the card does not claim it is fine. +func (h *Handlers) serverMail(force bool) serverMailState { + host := h.cfg.Hostname + spfOf := func(c view.DNSCheck) (view.Record, view.MailRow) { + return view.ServerMailSPF(host, dnscheck.ServerMailSPF, c), view.NewMailRow("SPF", host, c) + } + mail, err := h.domains.ServerMail() + if err != nil { + logf("panel: server mail: %v", err) + rec, row := spfOf(view.DNSCheck{Status: string(health.StatusUnknown)}) + return serverMailState{records: []view.Record{rec}, rows: []view.MailRow{row}, status: string(health.StatusUnknown)} + } + srv := h.dns.Server(host, false) + check := h.dns.ServerMail(dnscheck.Query{ + Name: host, Selector: mail.Selector, ExpectedDKIM: mail.Record.Value, + Hostname: srv.Hostname, ServerIPs: srv.IPs, + }, force) + dkim := dnsCheck(check.DKIM) + spfRec, spfRow := spfOf(dnsCheck(check.SPF)) + status := check.Overall + if status == health.StatusError { + status = health.StatusWarn + } + return serverMailState{ + records: []view.Record{view.ServerMailDKIM(mail.Selector, mail.Record.Name, mail.Record.Value, dkim), spfRec}, + rows: []view.MailRow{view.NewMailRow("DKIM", mail.Record.Name, dkim), spfRow}, + status: string(status), + } +} diff --git a/internal/web/handlers/handlers_settings_test.go b/internal/web/handlers/handlers_settings_test.go index 591dcf9..57b8c4b 100644 --- a/internal/web/handlers/handlers_settings_test.go +++ b/internal/web/handlers/handlers_settings_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/mixeme/selfpost/internal/dnscheck" + "github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/web/auth" "golang.org/x/crypto/bcrypt" @@ -120,6 +121,10 @@ func settingsServerAt(t *testing.T, dbPath string) (*Handlers, string) { view: v, auth: a, dns: dnscheck.New(nil), - cfg: Config{SendLogRetentionEnvDefault: 90}, + // A domain service that does not know the server's own name, so that + // the records of Settings and Health come out as "cannot be read"; + // the tests of those records use serverMailStand. + domains: domain.NewService(st, domain.NewOpenDKIM(t.TempDir()), nil, "selfpost"), + cfg: Config{SendLogRetentionEnvDefault: 90}, }, password } diff --git a/internal/web/handlers/handlers_status.go b/internal/web/handlers/handlers_status.go index 0bb0728..6214157 100644 --- a/internal/web/handlers/handlers_status.go +++ b/internal/web/handlers/handlers_status.go @@ -10,6 +10,7 @@ import ( "github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/health" + "github.com/mixeme/selfpost/internal/notify" "github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/web/view" ) @@ -114,7 +115,7 @@ func (h *Handlers) HandleHealth(w http.ResponseWriter, r *http.Request) { if _, ok := h.requireGlobal(w, r); !ok { return } - h.view.Render(w, http.StatusOK, "health", healthPage(h.shellMeta(r), h.healthChecks(), statusFlash(r))) + h.view.Render(w, http.StatusOK, "health", h.healthView(r, statusFlash(r))) } // HandleHealthFragment is the polled part of Health: the same two rows of @@ -123,7 +124,7 @@ func (h *Handlers) HandleHealthFragment(w http.ResponseWriter, r *http.Request) if _, ok := h.requireGlobal(w, r); !ok { return } - page := healthPage(h.shellMeta(r), h.healthChecks(), "") + page := h.healthView(r, "") page.Refresh = true h.view.RenderFragment(w, http.StatusOK, "health_body", page) } @@ -134,9 +135,18 @@ func (h *Handlers) HandleHealthRecheck(w http.ResponseWriter, r *http.Request) { return } h.dns.Server(h.cfg.Hostname, true) + h.serverMail(true) http.Redirect(w, r, "/server/health?rechecked=1", http.StatusSeeOther) } +// healthView is the Health page for the request: the checks behind the cards +// of Overview and the Notification mail card, which only Health shows. +func (h *Handlers) healthView(r *http.Request, flash string) *view.Health { + page := healthPage(h.shellMeta(r), h.healthChecks(), flash) + mail := h.serverMail(false) + return page.WithNotificationMail(notify.From(h.cfg.Hostname), mail.rows, mail.status) +} + // healthPage turns one reading of the checks (healthChecks) into the Health // page: a table row per resource, process and socket, and the verdict of each // box in its head. diff --git a/internal/web/view/account.go b/internal/web/view/account.go index 409c0e1..9dfcb88 100644 --- a/internal/web/view/account.go +++ b/internal/web/view/account.go @@ -19,6 +19,9 @@ type Account struct { Profile Box Username string Email string + // Notify is whether the panel writes to this user: the checkbox under the + // e-mail. + Notify bool Password Box } @@ -63,6 +66,18 @@ type Settings struct { RateLimits Box RateFacts []Fact + + // Notifications is the instance switch for notification e-mails; Notify is + // its state and NotifyNote the line under it, which says from where and to + // whom. + Notifications Box + Notify bool + NotifyNote Text + + // Mail holds the DNS records of the server's own name, which notifications + // are signed and authorised with, drawn like a domain's. + Mail Box + MailRecords []Record } // NewSettings builds the page. retention is what the field shows (the stored @@ -87,9 +102,28 @@ func NewSettings(m Meta, retention, level1 string) *Settings { {Label: "Level 2 · domain, application", Value: Rich("Set on each ", Link("/outbound/domains", "domain"), " and on each application of a domain.")}, }, + Notifications: Box{No: "03", Title: "Notifications", ID: "notifications", Help: topicLink(HelpNotifications, "What is sent and when")}, + Mail: Box{No: "04", Title: "Notification mail", ID: "notification-mail", End: Plain("DNS records of the server's own name"), + Help: topicLink(HelpNotifications, "What the panel sends and what makes it arrive")}, } } +// WithNotifications sets the instance switch: on is whether notification +// e-mails are sent at all, from is the address they come from. +func (s *Settings) WithNotifications(on bool, from string) *Settings { + s.Notify = on + s.NotifyNote = Rich("From ", Mono(from), ", to every user who has an e-mail and has not turned them off under ", + Link("/account", "Account"), ".") + return s +} + +// WithMail sets the records of the server's own name, in the order they are +// shown (ServerMailDKIM, ServerMailSPF). +func (s *Settings) WithMail(records []Record) *Settings { + s.MailRecords = records + return s +} + // WithInbound adds the retention of the Inbound log to the Log retention box. // days is what the field shows, like retention in NewSettings. The field exists // only while the inbound relay is on: with it off there is no journal to keep. diff --git a/internal/web/view/fixtures_test.go b/internal/web/view/fixtures_test.go index 1a6b279..31243d4 100644 --- a/internal/web/view/fixtures_test.go +++ b/internal/web/view/fixtures_test.go @@ -2,6 +2,7 @@ package view import ( "strconv" + "strings" "time" ) @@ -119,6 +120,10 @@ func healthFixture(refresh bool) *Health { {Label: "Hostname", Value: Plain("mail.example.org"), Mono: true}, {Label: "Lookup", Value: Rich("→ 203.0.113.25", Br(), "← mail.example.org"), Mono: true}, } + h.WithNotificationMail("postmaster@mail.example.org", []MailRow{ + NewMailRow("DKIM", mailFixtureDKIMHost, DNSCheck{Status: "ok"}), + NewMailRow("SPF", "mail.example.org", DNSCheck{Status: "error"}), + }, "warn") return h } @@ -126,14 +131,25 @@ func healthFixture(refresh bool) *Health { // and e-mail. func accountFixture() *Account { a := NewAccount(admin(), "global") - a.Username, a.Email = "admin", "mix@example.org" + a.Username, a.Email, a.Notify = "admin", "mix@example.org", true return a } +// mailFixtureDKIMHost is where the mockup publishes the DKIM key of the server's +// own name. +const mailFixtureDKIMHost = "sp2026._domainkey.mail.example.org" + // settingsFixture is the mockup's Settings: thirty days of log, a limit of 600 -// messages an hour. +// messages an hour, notifications on, and the records of the server's own name +// with the DKIM one published and the SPF one missing. func settingsFixture() *Settings { - return NewSettings(admin(), "30", FormatRate(600, 3600)).WithInbound("90") + key := "v=DKIM1; k=rsa; p=" + strings.Repeat("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A", 10) + "IDAQAB" + return NewSettings(admin(), "30", FormatRate(600, 3600)).WithInbound("90"). + WithNotifications(true, "postmaster@mail.example.org"). + WithMail([]Record{ + ServerMailDKIM("sp2026", mailFixtureDKIMHost, key, DNSCheck{Status: "ok"}), + ServerMailSPF("mail.example.org", "v=spf1 a -all", DNSCheck{Status: "error", Detail: "No SPF record is published for mail.example.org."}), + }) } // outDomainsFixture is the mockup's Outbound domains: three domains, the first diff --git a/internal/web/view/health.go b/internal/web/view/health.go index c31fb0b..963bf97 100644 --- a/internal/web/view/health.go +++ b/internal/web/view/health.go @@ -30,10 +30,19 @@ type Health struct { CertDetail string CertProblem bool - Sockets Box - SocketRows []SocketRow - Hostname Box - HostFacts []Fact + Sockets Box + SocketRows []SocketRow + Hostname Box + HostFacts []Fact + + // NotifyMail is the card of the DNS records notifications are signed and + // authorised with (their values are under Server › Settings); NotifyNote + // is the line under its table. It is polled with the rest, so Refresh swaps + // it into place on its own. + NotifyMail Box + NotifyRows []MailRow + NotifyNote Text + Configuration Box Reload Action } @@ -87,6 +96,20 @@ type SocketRow struct { Detail string } +// MailRow is one record of the Notification mail table: its name, the host it +// is published at and its state as a tag. +type MailRow struct { + Name string + Host string + State Tag +} + +// NewMailRow is the row of one record of the server's own name, from what the +// DNS check found. A record that is not in place is a warning, as on Settings. +func NewMailRow(name, host string, c DNSCheck) MailRow { + return MailRow{Name: name, Host: host, State: mailTag(c)} +} + // NewHealth builds the page around its content. The handler fills the tables; // the boxes, the head and the reload action are the page's own. func NewHealth(m Meta, flash string) *Health { @@ -104,7 +127,9 @@ func NewHealth(m Meta, flash string) *Health { Certificate: Box{No: "03", Title: "TLS certificate", ID: "certificate", Help: topicLink(HelpCertificate, "When it warns and what to do")}, Sockets: Box{No: "04", Title: "Milter sockets", ID: "sockets"}, Hostname: Box{No: "05", Title: "Hostname and reverse DNS", ID: "hostname", Help: topicLink(HelpRDNS, "Forward and reverse DNS")}, - Configuration: Box{No: "06", Title: "Configuration", ID: "configuration", + NotifyMail: Box{No: "06", Title: "Notification mail", ID: "notification-mail", + Help: topicLink(HelpNotifications, "What the panel sends and what makes it arrive")}, + Configuration: Box{No: "07", Title: "Configuration", ID: "configuration", End: Plain("Safe to run at any time")}, Reload: Action{Label: "Reload configuration", Icon: "ti-reload", Post: "/server/health/reload"}, } @@ -114,5 +139,16 @@ func NewHealth(m Meta, flash string) *Health { return h } +// WithNotificationMail fills the Notification mail card: from is the address +// notifications come from, rows the state of each record, and status the +// card's verdict for its head. +func (h *Health) WithNotificationMail(from string, rows []MailRow, status string) *Health { + h.NotifyMail.End = Verdict(status) + h.NotifyRows = rows + h.NotifyNote = Rich("Sent from ", Mono(from), ". What to publish is under ", + Link("/server/settings#notification-mail", "Settings"), ".") + return h +} + // Verdict puts a check's status in the end slot of its box, as the mockup does. func Verdict(status string) Text { return Text{TagOf(status, "")} } diff --git a/internal/web/view/help.go b/internal/web/view/help.go index 03d7615..2f0d0c4 100644 --- a/internal/web/view/help.go +++ b/internal/web/view/help.go @@ -59,6 +59,9 @@ const ( HelpBackup = "backup" HelpUsers = "users" HelpSettings = "settings" + // HelpNotifications is what the panel writes to its users and what makes + // that mail arrive; Account, Settings and Health link to it. + HelpNotifications = "notifications" // Account HelpAccount = "account" ) @@ -121,6 +124,7 @@ func NewHelp(m Meta, inbound bool) *Help { {Label: "Backup and moving a domain", Icon: "ti-archive", Href: "#" + HelpBackup}, {Label: "Users and roles", Icon: "ti-users", Href: "#" + HelpUsers}, {Label: "Settings", Icon: "ti-settings", Href: "#" + HelpSettings}, + {Label: "Notifications", Icon: "ti-mail", Href: "#" + HelpNotifications}, }) } add("Account", accountTopics, []MenuItem{ @@ -233,12 +237,19 @@ var serverTopics = []HelpTopic{ Rich("Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast ", Code("/data"), " grows. With the inbound relay on there is a second number, for the Inbound log, with the same range. It does not change the rotation of ", Code("mail.log"), ", which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in ", - Code(".env"), ": changing it needs a restart, and no level-2 limit can exceed it."), + Code(".env"), ": changing it needs a restart, and no level-2 limit can exceed it. Notifications are turned on or off here for everyone."), + }}, + {ID: HelpNotifications, Title: "Notifications", Body: []Text{ + Rich("The panel writes to its users when something needs them, from ", Code("postmaster@"), " the server's hostname. Four things are reported. A sending domain's SPF, DKIM or DMARC record stops matching: sent once two hourly checks in a row have found it so, and once more when the record matches again. A level-2 rate limit refuses mail: sent at the first refusal, then at most once a day for the same limit. The TLS certificate is 14 days from expiry, and again at 3 days if it is still the same certificate. A message has waited in the queue for more than three days, with one more message when none is left. A global user is told all of it; a domain administrator is told about the DNS records and limits of their own domains.", + Br(), Br(), + "Each user turns notifications on or off under Account and needs an e-mail there; Server › Settings turns them off for everyone. They are the panel's own mail: not rows of the Outbound log, and not counted against any limit.", + Br(), Br(), + "For that mail to arrive, the server's own name needs two DNS records of its own, as each of your domains does: a DKIM key, which the panel generates and signs notifications with, and an SPF record, ", Code("v=spf1 a -all"), ". Server › Settings gives both with the values to publish, and Server › Health shows whether they are in place. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains does not depend on either."), }}, } var accountTopics = []HelpTopic{ {ID: HelpAccount, Title: "Your account", Body: []Text{ - Plain("Account is your own, whatever your role: your username and e-mail, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected."), + Plain("Account is your own, whatever your role: your username and e-mail, whether the panel sends you notifications, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected."), }}, } diff --git a/internal/web/view/outbound.go b/internal/web/view/outbound.go index b9bfd5a..e229203 100644 --- a/internal/web/view/outbound.go +++ b/internal/web/view/outbound.go @@ -178,6 +178,41 @@ func SPFRecord(domain, value string, c DNSCheck) Record { return r } +// mailTag is the tag of a record of the server's own name. The records are not +// needed for notifications to be sent, only for them to arrive, so a record +// that is missing, wrong or weak is a warning here and never the red a +// sending domain's would be; published and not checked keep their meaning. +func mailTag(c DNSCheck) Tag { + t := DNSStatusTag(c.Status, len(c.Found) > 0) + if tagLevel(t.Status) == LevelFail { + t.Status = "warn" + } + return t +} + +// ServerMailDKIM is the DKIM record of the server's own name, which the panel +// signs its notifications with: a domain's DKIM record in every other respect. +func ServerMailDKIM(selector, host, value string, c DNSCheck) Record { + r := DKIMRecord(selector, host, value, c) + r.Status = mailTag(c) + return r +} + +// ServerMailSPF is the SPF record of the server's own name. Without it +// notifications are still sent, which is said under the value while it is not +// in place. +func ServerMailSPF(host, value string, c DNSCheck) Record { + r := Record{Name: "SPF", Note: Plain("For the server's own name, not for your domains"), + Host: host, Type: "TXT", Value: value} + var standing Text + if c.Status != "ok" { + standing = Plain("Notifications are sent without it, and a receiver may file them as spam.") + } + applyCheck(&r, c, standing, nil) + r.Status = mailTag(c) + return r +} + // DMARCRecordInput is what a DMARC record needs besides its check: where the // reports go in words (Source: the address, or "no reports") and the page that changes it, // the page of the reports themselves ("" when the panel has none), and whether diff --git a/internal/web/view/pages_test.go b/internal/web/view/pages_test.go index d056a07..e816bbb 100644 --- a/internal/web/view/pages_test.go +++ b/internal/web/view/pages_test.go @@ -148,7 +148,7 @@ func TestHealthShowsEveryCheckAndBothActions(t *testing.T) { // the two actions: Re-check DNS in the head, Reload configuration under the tables `action="/server/health/recheck"`, `action="/server/health/reload"`, // the anchors the Overview cards lead to - `id="machine"`, `id="processes"`, `id="certificate"`, `id="sockets"`, `id="hostname"`, `id="configuration"`, + `id="machine"`, `id="processes"`, `id="certificate"`, `id="sockets"`, `id="hostname"`, `id="notification-mail"`, `id="configuration"`, // the machine card: bars carry their reading in an attribute (the CSP rules out sizing them with a style) ``, `value="41"`, `↓ 18.0 KiB/s`, `eth0: 2.1 GiB in, 6.4 GiB out`, @@ -158,8 +158,8 @@ func TestHealthShowsEveryCheckAndBothActions(t *testing.T) { `hx-get="/server/health/fragment"`, ) pageLacks(t, "Health", out, `hx-swap-oob`, `/overview/fragment`) - if got := strings.Count(out, `class="columns"`); got != 2 { - t.Errorf("Health has %d rows of boxes, want 2", got) + if got := strings.Count(out, `class="columns"`); got != 3 { + t.Errorf("Health has %d rows of boxes, want 3", got) } // The Configuration box is outside the polled rows: a refresh must not re-render a control. if strings.Index(out, `id="configuration"`) < strings.Index(out, `id="health-checks"`) || @@ -169,29 +169,54 @@ func TestHealthShowsEveryCheckAndBothActions(t *testing.T) { } // The fragment is the polled part of the page and nothing else: the same rows, -// the second one marked to be swapped in by its id, no shell and no actions. +// the second one marked to be swapped in by its id, and the Notification mail +// card, a column of the last row, swapped in the same way. No shell and no +// actions. func TestHealthFragmentIsThePolledRows(t *testing.T) { page := renderSignedIn(t, "health", healthFixture(false)) frag := renderFragment(t, "health_body", healthFixture(true)) + const card = `
a { + return s[a:b] + } + return s[a:] + } + // Same markup, apart from the out-of-band marks and what follows the card on the page. + oob := func(s string) string { return strings.ReplaceAll(strings.TrimSpace(s), ` hx-swap-oob="true"`, "") } + if got, want := oob(rows(frag)), oob(rows(page)); !strings.HasPrefix(want, got) { t.Errorf("the fragment is not the page's polled rows\nfragment: %.400s\npage: %.400s", got, want) } - pageHas(t, "fragment", frag, `id="health-machine" data-poll`, `id="health-checks" hx-swap-oob="true"`) + if got, want := oob(mail(frag)), oob(mail(page)); !strings.HasPrefix(want, got) { + t.Errorf("the fragment's Notification mail card is not the page's\nfragment: %.400s\npage: %.400s", got, want) + } + pageHas(t, "fragment", frag, `id="health-machine" data-poll`, `id="health-checks" hx-swap-oob="true"`, + `id="health-mail" hx-swap-oob="true"`, `id="notification-mail"`) pageLacks(t, "fragment", frag, `Notification mail
warn
`, + `href="/help#notifications"`, + `DKIM
sp2026._domainkey.mail.example.orgpublished`, + `SPF
mail.example.orgmissing`, + `Sent from postmaster@mail.example.org`, + `href="/server/settings#notification-mail"`, + ) +} + // A machine whose counters could not be read keeps its rows: the figure becomes // a dash and the reason stays in the detail column. func TestHealthMachineWithoutReadings(t *testing.T) { @@ -289,15 +314,17 @@ func TestAccountEscapesTheFormValues(t *testing.T) { pageLacks(t, "Account", out, `