Files
selfpost/internal/web/handlers/handlers_settings.go
T
mixandClaude Opus 5.5 3f5cd9adf0
test / test (push) Waiting to run
notification-mail-recheck: Re-check DNS on Server › Settings
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>
2026-10-16 13:29:54 +03:00

209 lines
8.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handlers
import (
"net/http"
"strconv"
"strings"
"time"
"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), 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.
// HandleServerSettings shows and saves the instance settings (global role).
func (h *Handlers) HandleServerSettings(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
// The routes are method-bound (GET and POST), so it is one or the other.
if r.Method == http.MethodPost {
h.submitServerSettings(w, r)
return
}
h.renderServerSettings(w, r, http.StatusOK, "", h.currentRetentions())
}
// retentions are the two journal retentions the Settings form shows: the
// outbound log's, and the inbound log's while the inbound relay is on.
type retentions struct {
outbound, inbound int
}
func (h *Handlers) currentRetentions() retentions {
cur := retentions{outbound: h.sendLogRetentionDays()}
if h.cfg.InboundEnabled {
cur.inbound = h.inboundSpamLogRetentionDays()
}
return cur
}
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 {
// Shown as on: the form saves what it shows, and a failed read must not
// become "off" at the next Save.
logf("panel: settings: read the notifications switch: %v", err)
on = true
}
page.WithNotifications(on, notify.From(h.cfg.Hostname))
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 := ""
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)
}
func (h *Handlers) submitServerSettings(w http.ResponseWriter, r *http.Request) {
current := h.currentRetentions()
if err := r.ParseForm(); err != nil {
h.renderServerSettings(w, r, http.StatusBadRequest, "Invalid form submission.", current)
return
}
days, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("send_log_retention_days")))
if err != nil {
h.renderServerSettings(w, r, http.StatusBadRequest, "Send log retention must be a whole number of days.", current)
return
}
form := retentions{outbound: days, inbound: current.inbound}
if err := store.ValidateSendLogRetentionDays(days); err != nil {
h.renderServerSettings(w, r, http.StatusBadRequest, err.Error(), form)
return
}
if h.cfg.InboundEnabled {
inboundDays, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("inbound_spam_log_retention_days")))
if err != nil {
h.renderServerSettings(w, r, http.StatusBadRequest, "Inbound log retention must be a whole number of days.", form)
return
}
form.inbound = inboundDays
if err := store.ValidateInboundSpamLogRetentionDays(inboundDays); err != nil {
h.renderServerSettings(w, r, http.StatusBadRequest, err.Error(), form)
return
}
}
// An unticked checkbox is not sent at all: absent is off.
// HTML Living Standard, form-control-infrastructure.html#constructing-entry-list:
// a checkbox whose checkedness is false is skipped.
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)
h.renderServerSettings(w, r, http.StatusInternalServerError,
"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, notifications: %t)", days, form.inbound, notifications)
} else {
logf("panel: instance settings updated (send-log retention: %d days, notifications: %t)", days, notifications)
}
http.Redirect(w, r, "/server/settings?saved=1", http.StatusSeeOther)
}
// sendLogRetentionDays returns the effective delivery-journal retention window.
func (h *Handlers) sendLogRetentionDays() int {
days, err := h.store.GetSendLogRetentionDays(h.cfg.SendLogRetentionEnvDefault)
if err != nil {
logf("panel: send-log retention: %v", err)
if h.cfg.SendLogRetentionEnvDefault > 0 {
return h.cfg.SendLogRetentionEnvDefault
}
return store.SendLogRetentionDaysDefault
}
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
// 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.
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),
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)
}