Files
selfpost/internal/web/handlers/handlers_settings.go
T
mixandClaude Opus 5.5 6023feb405
test / test (push) Canceled after 0s
audit 6: documents, comments, dead code and staticcheck; the test gaps closed
The last stage of the 2026-10-12 audit; no cut.

Doc drift: architecture.md (the sender check in § OpenDKIM, the limit:
row's 48 hours, the inbound log and filter-list routes and who may open
them, migrations to 0011 and every table, KitPage as production data,
Reload configuration as a restore writes, the panel's roles counted),
schema-migrations.md (tables and columns since v2), guide.md (a delivery's
lines go at the next prune; INBOUND_JOURNAL_MILTER_SOCKET moved to
architecture.md's internal list), development.md (the env guard, the
deferral the e2e proves, its fixed sleeps), roadmap.md.

Comments say what the code does now; TestBaselineIsTheOnlyMigration is
TestChainStartsAtTheBaseline; the milter package doc names its three
milters.

Dead code gone: view.Facts, dmarc HostedSuggestion and
ValidateHostedAddress, app UpdateMode (each with the test that alone kept
it), the method checks the method-bound routes made dead;
Settings.WithResult calls resultFlash. staticcheck 2026.1 in test.yml on
both modules, clean: the e2e package builds without its tests now (a test
and two constants moved out of non-test files), three findings fixed.
The test moved, TestImageBuildPreservesLogrotateMode, had sat in a
non-test file and never run; run, it failed on the entrypoint (docker run
without a hostname) and now asks with --entrypoint stat.

The test gaps no step named, each closed: follow() with mail.log gone past
blindMin and made again; CheckRestore with a manifest that is not JSON;
the origin check with a port; every /server/ pattern in web.go through
server() and in the routes table (it found GET /server/health/fragment
missing from the table - guarded, not tested); L9 as the owner decided it
on 2026-10-12, the comment on senderSession.Headers and the test passing
an h that holds the earlier messages' From:.

Plan stage 6 ticked; roadmap audit row done, 81/81.

Tested on the test server: go vet + go test of 27 packages, make e2e (all
pass, 513 s) on this tree; staticcheck locally on both modules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:41:26 +03:00

188 lines
7.4 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"
"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))
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))
}
flash := ""
if r.URL.Query().Has("saved") {
flash = "Settings saved."
}
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
// 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),
}
}