notification-mail-recheck: Re-check DNS on Server › Settings
test / test (push) Waiting to run

The owner, 2026-10-16: "Да, бери notification-mail-recheck". The records
of the server's own name can be checked again where they are shown: Re-
check DNS in Settings' page head (mockup 98e4dfc) posts to
POST /server/settings/dns-recheck (guard 52a9811), global role, which
runs Health's lookups - the server's address, then serverMail(true) - and
returns to /server/settings?rechecked=1, "DNS re-checked.". The
Notification mail box's end slot says when the records were checked
("The server's own name · checked 3 min ago", from the check's
CheckedAt; the old wording while nothing was looked up). A record that
has turned right comes back folded.

Tests: the page head's form and the end slot, and the Settings form
holding neither the re-check nor the right column
(TestSettingsRechecksTheMailRecords); the re-check skips the cache,
redirects and the page shows both records folded
(TestSettingsRecheckAsksForNotificationMailAgain); a domain
administrator gets 404 (globalOnlyRoutes). go vet + go test (26
packages) and make e2e on the test server for this tree.

Docs: architecture.md routes, the panel-redesign plan's route table,
guide § Settings, help (help.go word for word with the mockup's);
CHANGELOG [Unreleased]; roadmap row done. The roadmap's settings-save-row
note no longer claims the left-column form was needed for this - the
action went to the page head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-16 13:29:54 +03:00
co-authored by Claude Opus 5.5
parent 52a9811baa
commit 3f5cd9adf0
13 changed files with 114 additions and 17 deletions
+1
View File
@@ -43,6 +43,7 @@ var globalOnlyRoutes = []route{
{"GET", "/server/health/fragment", func(h *Handlers) http.HandlerFunc { return h.HandleHealthFragment }, nil},
{"GET", "/overview/fragment", func(h *Handlers) http.HandlerFunc { return h.HandleOverviewFragment }, nil},
{"POST", "/server/health/recheck", func(h *Handlers) http.HandlerFunc { return h.HandleHealthRecheck }, nil},
{"POST", "/server/settings/dns-recheck", func(h *Handlers) http.HandlerFunc { return h.HandleSettingsDNSRecheck }, nil},
{"GET", "/outbound/queue", func(h *Handlers) http.HandlerFunc { return h.HandleMailQueue }, nil},
{"GET", "/outbound/queue/fragment", func(h *Handlers) http.HandlerFunc { return h.HandleMailQueueBody }, nil},
@@ -176,7 +176,7 @@ func TestSettingsMailRecordsPublished(t *testing.T) {
out := getBody(t, h.HandleServerSettings, "/server/settings")
for _, want := range []string{
`id="notification-mail"`, `DNS records of the server&#39;s own name`,
`id="notification-mail"`, `The server&#39;s own name · checked just now`,
`<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>`,
@@ -313,3 +313,29 @@ func TestHealthRecheckAsksForNotificationMailAgain(t *testing.T) {
t.Errorf("after Re-check DNS both records should be published:\n%s", out)
}
}
// Re-check DNS on Settings asks for the same records again and returns to
// Settings, where a record that has just been published comes back folded.
func TestSettingsRecheckAsksForNotificationMailAgain(t *testing.T) {
h, zone, mail := serverMailStand(t)
if out := getBody(t, h.HandleServerSettings, "/server/settings"); 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.HandleServerSettings, "/server/settings"); 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.HandleSettingsDNSRecheck, globalPrincipal, "/server/settings/dns-recheck", nil, url.Values{})
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/server/settings?rechecked=1" {
t.Fatalf("POST /server/settings/dns-recheck = %d to %q, want 303 to /server/settings?rechecked=1", rec.Code, rec.Header().Get("Location"))
}
out := getBody(t, h.HandleServerSettings, "/server/settings?rechecked=1")
if n := strings.Count(out, `<details class="sp-record">`); n != 2 {
t.Errorf("after Re-check DNS both records should be published and folded, %d are:\n%s", n, out)
}
if !strings.Contains(out, "DNS re-checked.") || !strings.Contains(out, "· checked just now") {
t.Errorf("Settings does not say the records were just re-checked:\n%s", out)
}
}
+23 -2
View File
@@ -4,6 +4,7 @@ import (
"net/http"
"strconv"
"strings"
"time"
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/health"
@@ -57,14 +58,18 @@ func (h *Handlers) renderServerSettings(w http.ResponseWriter, r *http.Request,
on = true
}
page.WithNotifications(on, notify.From(h.cfg.Hostname))
page.WithMail(h.serverMail(false).records)
mail := h.serverMail(false)
page.WithMail(mail.records, view.FormatAge(mail.checked, time.Now()))
// The inbound field exists while there is an inbound journal to keep.
if h.cfg.InboundEnabled {
page.WithInbound(strconv.Itoa(form.inbound))
}
flash := ""
if r.URL.Query().Has("saved") {
switch {
case r.URL.Query().Has("saved"):
flash = "Settings saved."
case r.URL.Query().Has("rechecked"):
flash = "DNS re-checked."
}
page.WithResult(flash, formErr)
h.view.Render(w, status, "settings", page)
@@ -147,6 +152,8 @@ func (h *Handlers) sendLogRetentionDays() int {
type serverMailState struct {
records []view.Record
rows []view.MailRow
// checked is when the records were looked up; zero when they were not.
checked time.Time
// 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.
@@ -183,5 +190,19 @@ func (h *Handlers) serverMail(force bool) 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),
checked: check.CheckedAt,
}
}
// HandleSettingsDNSRecheck re-checks the records of the server's own name
// from Server › Settings, where they are shown, and returns there (global
// role). The server's address is looked up again too: the SPF check reads it.
// The same lookups as Re-check DNS on Health.
func (h *Handlers) HandleSettingsDNSRecheck(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
h.dns.Server(h.cfg.Hostname, true)
h.serverMail(true)
http.Redirect(w, r, "/server/settings?rechecked=1", http.StatusSeeOther)
}
+11 -2
View File
@@ -92,6 +92,7 @@ func NewSettings(m Meta, retention, level1 string) *Settings {
Title: "Settings",
Lead: Rich("What holds for the whole instance, whoever is signed in. Your own name, e-mail and password are under ",
Link("/account", "Account"), "."),
Actions: []Action{{Label: "Re-check DNS", Icon: "ti-refresh", Post: settingsDNSRecheck}},
},
Retention: Box{No: "01", Title: "Log retention", Help: topicLink(HelpSettings, "How long rows are kept")},
RetentionDays: retention,
@@ -117,10 +118,18 @@ func (s *Settings) WithNotifications(on bool, from string) *Settings {
return s
}
// settingsDNSRecheck is where Re-check DNS on Settings posts.
const settingsDNSRecheck = "/server/settings/dns-recheck"
// 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 {
// shown (ServerMailDKIM, ServerMailSPF), and how long ago they were checked
// (FormatAge; "" when they were not).
func (s *Settings) WithMail(records []Record, checked string) *Settings {
s.MailRecords = records
s.Mail.End = Plain("DNS records of the server's own name")
if checked != "" {
s.Mail.End = Plain("The server's own name · checked " + checked)
}
return s
}
+1 -1
View File
@@ -158,7 +158,7 @@ func settingsFixture() *Settings {
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."}),
})
}, "3 min ago")
}
// outDomainsFixture is the mockup's Outbound domains: three domains, the first
+1 -1
View File
@@ -256,7 +256,7 @@ var serverTopics = []HelpTopic{
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."),
"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 whether they are in place, and its Re-check DNS checks them again once they are published; Server › Health shows them too. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains does not depend on either."),
}},
}
+24 -3
View File
@@ -368,11 +368,32 @@ func TestSettingsNotificationsSwitchAndRecords(t *testing.T) {
s.WithMail([]Record{
ServerMailDKIM("sp2026", mailFixtureDKIMHost, "v=DKIM1; k=rsa; p=AAAA", DNSCheck{Status: "ok"}),
ServerMailSPF("mail.example.org", "v=spf1 a -all", DNSCheck{Status: "ok"}),
})
}, "")
out = renderSignedIn(t, "settings", s)
pageHas(t, "Settings", out, `<input type="checkbox" name="notify"> Send notification e-mails`,
`<h3>SPF</h3><span class="tag is-success is-light">published</span>`)
pageLacks(t, "Settings", out, `name="notify" checked`, `a receiver may file them as spam`)
`<h3>SPF</h3><span class="tag is-success is-light">published</span>`,
`<div class="sp-end">DNS records of the server&#39;s own name</div>`)
pageLacks(t, "Settings", out, `name="notify" checked`, `a receiver may file them as spam`, `· checked`)
}
// The records of the server's own name can be checked again from Settings,
// where they are shown: Re-check DNS in the page head, outside the form, and
// the box says when they were checked.
func TestSettingsRechecksTheMailRecords(t *testing.T) {
out := renderSignedIn(t, "settings", settingsFixture())
pageHas(t, "Settings", out,
`<div class="buttons"><form method="post" action="/server/settings/dns-recheck"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form></div>`,
`<div class="sp-end">The server&#39;s own name · checked 3 min ago</div>`)
form := out[strings.Index(out, `<form class="sp-form" method="post" action="/server/settings">`):]
form = form[:strings.Index(form, "</form>")]
for _, outside := range []string{"dns-recheck", `id="notification-mail"`, `id="rate-limits"`} {
if strings.Contains(form, outside) {
t.Errorf("the Settings form holds %s; only the fields and Save settings belong in it", outside)
}
}
if !strings.Contains(form, `name="notify"`) || !strings.Contains(form, `Save settings`) {
t.Errorf("the Settings form lost its switch or its button:\n%s", form)
}
}
// A record of the server's own name that is wrong or missing is a warning, never
+1
View File
@@ -294,6 +294,7 @@ func (s *Server) muxes() (public, authed *http.ServeMux) {
server("POST /server/users/{uid}/delete", h.HandleUserDelete)
server("GET /server/settings", h.HandleServerSettings)
server("POST /server/settings", h.HandleServerSettings)
server("POST /server/settings/dns-recheck", h.HandleSettingsDNSRecheck)
server("GET /server/components", h.HandleComponents)
return mux, authed