panel-notifications: Account, Server > Settings, Server > Health, help; e2e
test / test (push) Canceled after 0s

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 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-12 16:21:48 +03:00
co-authored by Claude Opus 5.5
parent c9027381e5
commit b905f3e560
22 changed files with 1003 additions and 59 deletions
+134
View File
@@ -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...)
}
+13 -3
View File
@@ -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)
}
@@ -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&#39;s own name`,
`<h3>DKIM</h3><span class="tag is-success is-light">published</span>`,
`selector <code>sp2026</code>`, `value="sp2026._domainkey.mail.example.org"`,
`<h3>SPF</h3><span class="tag is-success is-light">published</span>`,
`For the server&#39;s own name, not for your domains`,
`value="v=spf1 a -all"`,
`<span class="sp-mono">postmaster@mail.example.org</span>`,
} {
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{
`<h3>DKIM</h3><span class="tag is-warning is-light">missing</span>`,
`<h3>SPF</h3><span class="tag is-warning is-light">missing</span>`,
`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{`<span class="tag is-warning is-light">mismatch</span>`, `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"`, `<h3>SPF</h3><span class="tag is-light">not checked</span>`, `name="notify"`} {
if !strings.Contains(out, want) {
t.Errorf("settings page is missing %q", want)
}
}
if strings.Contains(out, "<h3>DKIM</h3>") {
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"`, `<h2>Notification mail</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div>`,
`DKIM<br><span class="sp-muted sp-mono sp-small">sp2026._domainkey.mail.example.org</span></td><td><span class="tag is-success is-light">published</span>`,
`SPF<br><span class="sp-muted sp-mono sp-small">mail.example.org</span></td><td><span class="tag is-warning is-light">missing</span>`,
`Sent from <span class="sp-mono">postmaster@mail.example.org</span>`,
`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"`,
`<h2>Notification mail</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div>`,
`<td><span class="tag is-success is-light">published</span></td>`,
} {
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)
}
}
+67 -3
View File
@@ -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),
}
}
@@ -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
}
+12 -2
View File
@@ -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.