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
+9
View File
@@ -22,6 +22,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
the button stood under both columns, after a long gap whenever the
records of the server's own name were open.
### Added
- **Re-check DNS on Server › Settings** (notification-mail-recheck): the
records of the server's own name can be checked again where they are
shown, as a domain's can on its page; the Notification mail box says when
they were checked. Before, the only way was Re-check DNS on Server ›
Health, which nothing on Settings pointed to. `POST
/server/settings/dns-recheck`, global role.
## [2.8.0] - 2026-10-16
The panel signs outgoing mail with DKIM itself, and OpenDKIM - a 2018 beta
+1 -1
View File
@@ -797,7 +797,7 @@ only reads (the delete confirmation at `GET …/delete` renders a form).
| Outbound › DMARC reports | `GET /outbound/dmarc` · `…/dmarc/domains/{id}` · `…/dmarc/reports/{id}` — registered only with `DMARC_REPORTS_ENABLE=true` |
| Inbound › Domains | `GET, POST /inbound/domains` · `/inbound/domains/{id}` · `POST …/dns-recheck`, `…/upstream`, `…/recipients` · `GET, POST …/delete` — registered only with `INBOUND_RELAY_ENABLE=true` |
| Inbound › Log, Filter lists | `GET /inbound/log` · `GET, POST /inbound/filter-lists` · `POST /inbound/filter-lists/{id}/delete` — registered only with `INBOUND_RELAY_ENABLE=true` |
| Server | `GET /server/health` (+ `/fragment`) · `POST /server/health/recheck`, `/server/health/reload` · `GET /server/log` (+ `/fragment`) · `GET, POST /server/backup` · `POST /server/backup/import` · `/server/users`, `…/new`, `…/{uid}`, `…/{uid}/delete` · `GET, POST /server/settings` · `GET /server/components` |
| Server | `GET /server/health` (+ `/fragment`) · `POST /server/health/recheck`, `/server/health/reload` · `GET /server/log` (+ `/fragment`) · `GET, POST /server/backup` · `POST /server/backup/import` · `/server/users`, `…/new`, `…/{uid}`, `…/{uid}/delete` · `GET, POST /server/settings` · `POST /server/settings/dns-recheck` · `GET /server/components` |
| Account | `GET /account` · `POST /account/profile`, `/account/password` |
| User menu | `GET /help` · `POST /logout` (public mux) |
+3 -1
View File
@@ -551,7 +551,9 @@ prune cycle without a container restart), and, read-only, the level-1 rate
limit that comes from `.env` ([Rate limiting — level 1](#rate-limiting--level-1-ip-backstop)).
There is no panel-wide DMARC report address: each domain has its own. The page
also has the switch for [notifications](#notifications) and the two DNS records
that make them arrive.
that make them arrive, with when they were last checked; **Re-check DNS** at
the top of the page checks them again once they are published (results are
otherwise kept a few minutes).
### Notifications
+1 -1
View File
@@ -182,7 +182,7 @@ list, an action under the thing it changes.
| system-log | `GET /server/log` | `/system-log` |
| backup | `GET, POST /server/backup` · `POST /server/backup/import` | `/backup` · `POST /domains/import` |
| users · user · user-delete | `/server/users` · `…/new` · `…/{uid}` · `…/{uid}/delete` | `/users/…` |
| settings | `GET, POST /server/settings` | the instance half of `/settings`: log retention, rate limits |
| settings | `GET, POST /server/settings` · `POST …/dns-recheck` | the instance half of `/settings`: log retention, rate limits |
| account | `GET /account` · `POST /account/profile`, `/password` | `/settings`: credentials and the DMARC default (today `/account` redirects there) |
| help · components | `GET /help` · `GET /server/components` | `/help` · — |
+11 -4
View File
@@ -38,7 +38,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.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 | agreed (2026-10-13) | — | — |
| local-time | The panel shows time, and counts its days, in a time zone other than UTC | agreed (2026-10-13; design questions open) | — | — |
| notification-mail-recheck | The Notification mail records on Server › Settings can be checked again from where they are shown | agreed (2026-10-15) | — | — |
| notification-mail-recheck | The Notification mail records on Server › Settings can be checked again from where they are shown | done 2026-10-16, unreleased | — | — |
| settings-save-row | Server › Settings: the Save button no longer stands alone under a long right column | done 2026-10-16, unreleased | — | — |
| dns-record-collapse | A DNS record that is right is shown folded: its name and its tag, the values on request | done 2026-10-16, unreleased | — | — |
| go-milter-upstream | Prepare an issue and a PR for go-milter: its header map outlives a message and is unbounded | prepared 2026-10-16, the owner posts | — | [third_party/go-milter/PATCHES.md](../third_party/go-milter/PATCHES.md) |
@@ -756,6 +756,15 @@ the check is and how long a result is kept. Design change, mockup first;
decide together with [dns-record-collapse](#dns-record-collapse) (a record
that turns right on the re-check folds).
**Done (2026-10-16, the owner: "Да, бери notification-mail-recheck"):**
*Re-check DNS* in the page head of Settings - where an outbound domain's
page and Health put the same action; a box head holds a note, a tag, a
link or a filter form - posting to `POST /server/settings/dns-recheck`
(global role), which re-runs Health's lookups (the server's address and
`serverMail(true)`) and returns to Settings with "DNS re-checked."; the
Notification mail box's end slot reads "The server's own name · checked
3 min ago". A record that has turned right comes back folded.
## own-mta-builds
**Question (owner, 2026-10-15):** is it worth patching Postfix and OpenDKIM
@@ -892,9 +901,7 @@ on one whose records are right.
closed the gap only while the records are right. The form is now the left
column - 01 Log retention, 02 Notifications, *Save settings* - and the
right column, outside it, holds 03 Notification mail and 04 Sending rate
limits. It also leaves room for
[notification-mail-recheck](#notification-mail-recheck): a *Re-check DNS*
form in the Notification mail box is no longer a form inside a form.
limits.
## go-milter-upstream
+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