test / test (push) Canceled after 0s
The pages of the mockups accepted in 87fa0a4, built from the kit's partials
(written by a Sonnet agent into the working tree, reviewed here):
- Account: "Send me notifications" in the Profile form, saved with the
profile; an unticked box is off.
- Server > Settings: the instance switch (box 03), and box 04 Notification
mail with the DKIM and SPF records of the server's own name, drawn with the
record partial of a domain's DNS page and checked in DNS. Sending rate
limits are box 02 in the left column. A record that is not in place is
tagged as a warning, not a failure: notifications are sent without it.
- Server > Health: card 06 Notification mail with the state of each record
and a link to Settings, beside Configuration (07); part of the polled
fragment; Re-check DNS forces it too. Overview does not run this check.
- help.go: the Notifications topic and the clauses in Settings and Your
account, word for word with help.html.
If the server's key cannot be read the pages still render: the DKIM record
is left out and SPF is shown unchecked.
e2e: the administrator gets a mailbox behind the sink and the server's DKIM
record from Settings is published; the level-2 refusal of the next step must
then arrive as a notification from postmaster@mail.e2e.test, signed as that
name with the key Settings shows, and must not be a row of the Outbound log.
The rotation step now waits for Postfix to be running: it follows a step that
restarts the container, and logrotate's `postfix reload` failed in between.
On the test server, for this tree: go vet clean, go test ./... 23 packages
ok, make e2e ok (147 s); the notification reached the sink 28 s after the
refusal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
186 lines
7.2 KiB
Go
186 lines
7.2 KiB
Go
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
|
||
}
|
||
switch r.Method {
|
||
case http.MethodGet:
|
||
h.renderServerSettings(w, r, http.StatusOK, "", h.currentRetentions())
|
||
case http.MethodPost:
|
||
h.submitServerSettings(w, r)
|
||
default:
|
||
w.Header().Set("Allow", "GET, POST")
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
}
|
||
}
|
||
|
||
// 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 {
|
||
logf("panel: settings: read the notifications switch: %v", err)
|
||
}
|
||
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.
|
||
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),
|
||
}
|
||
}
|