diff --git a/CHANGELOG.md b/CHANGELOG.md index 96fab89..3d72dab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index 0ecd15b..7ce1c60 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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) | diff --git a/docs/guide.md b/docs/guide.md index 02ca053..af110e2 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -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 diff --git a/docs/plans/panel-redesign.md b/docs/plans/panel-redesign.md index 831d676..9b8b32e 100644 --- a/docs/plans/panel-redesign.md +++ b/docs/plans/panel-redesign.md @@ -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` · — | diff --git a/docs/roadmap.md b/docs/roadmap.md index 183cec6..5f203ad 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 diff --git a/internal/web/handlers/authz_test.go b/internal/web/handlers/authz_test.go index 1007904..93ffa0f 100644 --- a/internal/web/handlers/authz_test.go +++ b/internal/web/handlers/authz_test.go @@ -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}, diff --git a/internal/web/handlers/handlers_notifications_test.go b/internal/web/handlers/handlers_notifications_test.go index 138eef8..a06d52f 100644 --- a/internal/web/handlers/handlers_notifications_test.go +++ b/internal/web/handlers/handlers_notifications_test.go @@ -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's own name`, + `id="notification-mail"`, `The server's own name · checked just now`, `

DKIM

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

SPF

published`, @@ -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, `
`); 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) + } +} diff --git a/internal/web/handlers/handlers_settings.go b/internal/web/handlers/handlers_settings.go index 8a39abf..e7365f8 100644 --- a/internal/web/handlers/handlers_settings.go +++ b/internal/web/handlers/handlers_settings.go @@ -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) +} diff --git a/internal/web/view/account.go b/internal/web/view/account.go index b3ed47e..1281ad4 100644 --- a/internal/web/view/account.go +++ b/internal/web/view/account.go @@ -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 } diff --git a/internal/web/view/fixtures_test.go b/internal/web/view/fixtures_test.go index 67f4679..11eeb6c 100644 --- a/internal/web/view/fixtures_test.go +++ b/internal/web/view/fixtures_test.go @@ -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 diff --git a/internal/web/view/help.go b/internal/web/view/help.go index 2c84bd1..1a44fa7 100644 --- a/internal/web/view/help.go +++ b/internal/web/view/help.go @@ -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."), }}, } diff --git a/internal/web/view/pages_test.go b/internal/web/view/pages_test.go index 6f5cfd1..453883c 100644 --- a/internal/web/view/pages_test.go +++ b/internal/web/view/pages_test.go @@ -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, ` Send notification e-mails`, - `

SPF

published`) - pageLacks(t, "Settings", out, `name="notify" checked`, `a receiver may file them as spam`) + `

SPF

published`, + `
DNS records of the server's own name
`) + 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, + `
`, + `
The server's own name · checked 3 min ago
`) + form := out[strings.Index(out, `
`):] + form = form[:strings.Index(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 diff --git a/internal/web/web.go b/internal/web/web.go index ec554d4..323cffb 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -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