Agreed by the owner on 2026-10-13 (the local day, no "UTC" beside a
time); the zone is the container's TZ in .env and Settings only shows it,
his "Да" of 2026-10-17. Mockups c205106.
Every time the panel shows is formatted in time.Local and says no zone:
the logs' Time and the DMARC Received columns, a message's timeline,
Overview's "Updated", Health's certificate and renewal, the filter lists,
DMARC reports and windows, the auto limit's last recalculation. Stored
instants stay UTC. The days counted are the zone's: delivery_log_days and
sender_check_days key their rows by the local date, the sender check cuts
downtime at local midnights (byDay), the log filters' days run from local
midnight to local midnight. Notification mail and the test message write
the zone's abbreviation after a time - they are read away from the panel.
DMARC's one-day window label stays the reporter's (UTC) day.
Settings box 05, Time zone, read-only: the name (UTC without TZ), its
offset, and a TZ the image cannot load said to leave the times in UTC
(time/zoneinfo_unix.go, initLocal) rather than named. TZ is passed by the
compose file ("${TZ:-}"), in .env.example and the guide's table; tzdata is
named in the image (supervisor's python3.13 brings it today). Postfix
takes TZ through import_environment, so mail.log's stamps are the same
zone, which closes the trap the roadmap wrote down: the tailer has always
read them in time.Local.
Tests: a TestMain runs view, handlers, store and cmd/panel in UTC whatever
the machine's zone (as logtail's); TestSettingsShowsTheTimeZone,
TestTimesAreTheContainersZone, TestByDayInTheContainersZone, a Moscow day
in TestQueryInboundSpamFilters; envdoc's pattern takes two-letter names.
e2e time_zone_is_the_containers recreates the stand with
TZ=Europe/Moscow: date says MSK, Settings names the zone and UTC+03:00, a
test message's page gives its time and its mail.log lines in Moscow's.
go vet + go test (26 packages) and make e2e on the test server for this
tree.
Docs: guide (§ Settings, the TZ row, the days of Health and the logs),
architecture, schema-migrations, help (word for word with the mockup),
CHANGELOG [Unreleased] § Added, roadmap row done with the decision.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
210 lines
8.3 KiB
Go
210 lines
8.3 KiB
Go
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()))
|
||
page.WithTimeZone(h.cfg.TimeZone, time.Local, 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)
|
||
}
|