diff --git a/CHANGELOG.md b/CHANGELOG.md index b13c739..e40fd54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,18 @@ rate limit is correct for a non-default level-1 window. Schema migration ### Fixed +- DMARC panel layout classes now exist in + `internal/web/view/static/panel.css` (`.pair`, `.attn`, `.desk-only`, + `.phone-only`, `.phone-list`, `.check-row`), so desktop/mobile DMARC views and + attention highlighting render as intended. +- `dmarc-ingest` now bounds stdin with `io.LimitReader` (10 MiB) before parsing. +- User-role update no longer contains a duplicate "last global admin" demotion + guard; one check now returns a context-specific message for self-demotion. +- Settings flash text now reflects what actually changed and only mentions + session sign-out when the password changed. +- Login/setup rate-limiter bucket eviction now prefers the nearest-expiry bucket + and does not evict currently blocked buckets. + - **Port 25 no longer runs the submission milter chain.** `smtpd_milters` is now always overridden on `smtp/inet` — to the optional antispam milter, or to nothing. Previously, with `INBOUND_ANTISPAM_MILTER` unset, inbound mail @@ -73,6 +85,11 @@ rate limit is correct for a non-default level-1 window. Schema migration ### Changed +- Extracted shared helpers: `internal/atomicfile` (atomic temp-write+rename) and + `internal/supervisor` (supervisorctl start/signal wrappers), and reused them in + domain/postfix code paths. +- `docs/roadmap.md`: shipped `panel-docs` now references CHANGELOG history + instead of a removed plan file. - [schema-migrations.md](docs/schema-migrations.md) notes that migration `0009` changed what the IP list *means* — permissive (those IPs got the application ceiling) before 1.9.0, restrictive (every other IP refused) after. Its diff --git a/Makefile b/Makefile index f8ab028..abb39de 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,13 @@ # SelfPost build. # # The version is stamped into both binaries and MUST match the Docker image tag -# (spec 7.5.A: backup/restore compatibility check). Override on the command line: +# (architecture.md § Persistence: backup/restore compatibility check). Override +# on the command line: # # make build VERSION=1.3.0 # # CGO is disabled to keep the binaries fully static (modernc.org/sqlite is pure -# Go, so no cgo is required) — see spec 7.1. +# Go, so no cgo is required). VERSION ?= dev MODULE := github.com/mixeme/selfpost diff --git a/cmd/panel/dmarc_ingest.go b/cmd/panel/dmarc_ingest.go index 5977bc2..689969f 100644 --- a/cmd/panel/dmarc_ingest.go +++ b/cmd/panel/dmarc_ingest.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "io" "log" "os" "path/filepath" @@ -12,6 +13,8 @@ import ( "github.com/mixeme/selfpost/internal/store" ) +const maxIngestStdin = 10 << 20 // 10 MiB + func runDMARCIngestMode() error { cfg := loadConfig() if !cfg.dmarcEnabled { @@ -27,7 +30,7 @@ func runDMARCIngestMode() error { if recipient == "" { recipient = strings.ToLower(strings.TrimSpace(os.Getenv("ORIGINAL_RECIPIENT"))) } - if err := dmarc.IngestMessage(st, os.Stdin, recipient, time.Now().UTC()); err != nil { + if err := dmarc.IngestMessage(st, io.LimitReader(os.Stdin, maxIngestStdin), recipient, time.Now().UTC()); err != nil { if incrErr := st.IncrDMARCParseFailures(); incrErr != nil { log.Printf("dmarc ingest: record failure: %v", incrErr) } diff --git a/docs/roadmap.md b/docs/roadmap.md index 954fb90..fe7cfc5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -69,8 +69,9 @@ priority. ## panel-docs -**Status:** done — shipped in `[1.8.0]` (2026-08-18). Plan: -[plans/panel-docs.md](plans/panel-docs.md) (checklist complete). +**Status:** done — shipped in `[1.8.0]` (2026-08-18). The implementation plan +file was removed as shipped cleanup; release details remain in +[CHANGELOG.md](../CHANGELOG.md). **Goal:** built-in operator documentation in the panel — short pages (or a help drawer) that explain what each Status check and other controls mean, diff --git a/docs/security.md b/docs/security.md index 539c895..5b2955c 100644 --- a/docs/security.md +++ b/docs/security.md @@ -204,6 +204,15 @@ deferred item from the roadmap. the journal from recording sends at all. The trigger to revisit is a requirement for the allow-list to hold as a security boundary — at which point it must move to the SASL/smtpd layer and fail closed. +- **Level-2 in-flight reservations can outlive a dropped SMTP session until TTL.** + The journal milter cannot observe connection-close directly through + go-milter, so if a client disconnects without an explicit `Abort`, its + in-memory reservation is released by TTL expiry, not immediately. The map is + bounded by key and TTL (currently 10 minutes), so the effect is temporary + over-counting on that app/domain key under churn. Accepted deliberately: + immediate cleanup would require protocol hooks the current milter interface + does not expose; the TTL bound keeps it finite. Trigger to revisit: sustained + high-volume false throttling attributable to orphaned reservations. - **Access to `mail.log` from the unprivileged panel.** The `/data/log` directory is `2750 postfix:selfpost` and the file is `0640`: `postlogd` (user `postfix`) writes, the panel reads through the shared `selfpost` group, and diff --git a/internal/atomicfile/atomicfile.go b/internal/atomicfile/atomicfile.go new file mode 100644 index 0000000..5e8520f --- /dev/null +++ b/internal/atomicfile/atomicfile.go @@ -0,0 +1,45 @@ +package atomicfile + +import ( + "fmt" + "os" + "path/filepath" +) + +// Write writes data to path via a temp file in the same directory followed by +// a rename, so readers only ever see the complete old or new file. +func Write(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".tmp-*") + if err != nil { + return fmt.Errorf("create temp in %s: %w", dir, err) + } + tmpName := tmp.Name() + cleanup := true + defer func() { + if cleanup { + _ = os.Remove(tmpName) + } + }() + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return fmt.Errorf("write temp %s: %w", tmpName, err) + } + if err := tmp.Chmod(perm); err != nil { + tmp.Close() + return fmt.Errorf("chmod temp %s: %w", tmpName, err) + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return fmt.Errorf("sync temp %s: %w", tmpName, err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp %s: %w", tmpName, err) + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("rename %s -> %s: %w", tmpName, path, err) + } + cleanup = false + return nil +} diff --git a/internal/domain/dkim.go b/internal/domain/dkim.go index aa086aa..1aa1c95 100644 --- a/internal/domain/dkim.go +++ b/internal/domain/dkim.go @@ -13,7 +13,8 @@ import ( "encoding/pem" "fmt" "os" - "path/filepath" + + "github.com/mixeme/selfpost/internal/atomicfile" ) // dkimKeyBits is the RSA key size for DKIM signing keys. 2048 is the DKIM @@ -91,37 +92,5 @@ func dkimRecord(selector, domainName string, pub *rsa.PublicKey) (DKIMRecord, er // It is the single safe-write primitive for DKIM keys and OpenDKIM tables // (security.md). func writeFileAtomic(path string, data []byte, perm os.FileMode) error { - dir := filepath.Dir(path) - tmp, err := os.CreateTemp(dir, ".tmp-*") - if err != nil { - return fmt.Errorf("create temp in %s: %w", dir, err) - } - tmpName := tmp.Name() - cleanup := true - defer func() { - if cleanup { - _ = os.Remove(tmpName) - } - }() - - if _, err := tmp.Write(data); err != nil { - tmp.Close() - return fmt.Errorf("write temp %s: %w", tmpName, err) - } - if err := tmp.Chmod(perm); err != nil { - tmp.Close() - return fmt.Errorf("chmod temp %s: %w", tmpName, err) - } - if err := tmp.Sync(); err != nil { - tmp.Close() - return fmt.Errorf("sync temp %s: %w", tmpName, err) - } - if err := tmp.Close(); err != nil { - return fmt.Errorf("close temp %s: %w", tmpName, err) - } - if err := os.Rename(tmpName, path); err != nil { - return fmt.Errorf("rename %s -> %s: %w", tmpName, path, err) - } - cleanup = false - return nil + return atomicfile.Write(path, data, perm) } diff --git a/internal/domain/opendkim.go b/internal/domain/opendkim.go index 295bdb9..5983026 100644 --- a/internal/domain/opendkim.go +++ b/internal/domain/opendkim.go @@ -5,10 +5,12 @@ import ( "encoding/pem" "fmt" "os" - "os/exec" + "path" "path/filepath" "sort" "strings" + + "github.com/mixeme/selfpost/internal/supervisor" ) // OpenDKIM manages the on-disk OpenDKIM state the panel is responsible for @@ -191,7 +193,7 @@ func renderTables(keysDir string, domains []SigningDomain) (keyTable, signingTab } keyName := d.Name // one key per domain; the domain name is a fine handle // Absolute key path so OpenDKIM resolves it independently of its CWD. - keyFile := filepath.Join(keysDir, d.Name, d.Selector+".private") + keyFile := path.Join(keysDir, d.Name, d.Selector+".private") // KeyTable: :: fmt.Fprintf(&kt, "%s %s:%s:%s\n", keyName, d.Name, d.Selector, keyFile) // SigningTable (refile): @@ -226,12 +228,5 @@ func assertConfigSafe(domainName, selector string) error { // Arguments are fixed literals — no user input is interpolated into the command, // and it never goes through a shell (security.md). func reloadViaSupervisor() error { - cmd := exec.Command("supervisorctl", - "-c", "/etc/supervisor/supervisord.conf", - "signal", "USR1", "opendkim") - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("reload opendkim via supervisor: %w: %s", err, strings.TrimSpace(string(out))) - } - return nil + return supervisor.Signal("USR1", "opendkim") } diff --git a/internal/postfix/postfix.go b/internal/postfix/postfix.go index 1643ea3..7c20d7b 100644 --- a/internal/postfix/postfix.go +++ b/internal/postfix/postfix.go @@ -7,10 +7,11 @@ package postfix import ( "fmt" - "os/exec" "path/filepath" "sort" "strings" + + "github.com/mixeme/selfpost/internal/supervisor" ) // Postfix manages the on-disk Postfix state the panel is responsible for. After @@ -154,17 +155,5 @@ func assertMapSafe(address, login string) error { // Arguments are fixed literals — no user input is interpolated into the command, // and it never goes through a shell (security.md). func reloadViaSupervisor() error { - cmd := exec.Command("supervisorctl", - "-c", "/etc/supervisor/supervisord.conf", - "start", "postfix-reload") - out, err := cmd.CombinedOutput() - if err != nil { - // A reload already in flight is not a failure: that pending run reloads - // Postfix after our file is in place (the file is written before this). - if strings.Contains(string(out), "already started") { - return nil - } - return fmt.Errorf("reload postfix via supervisor: %w: %s", err, strings.TrimSpace(string(out))) - } - return nil + return supervisor.Start("postfix-reload") } diff --git a/internal/postfix/write.go b/internal/postfix/write.go index b79da1f..939a67f 100644 --- a/internal/postfix/write.go +++ b/internal/postfix/write.go @@ -1,47 +1,14 @@ package postfix import ( - "fmt" "os" - "path/filepath" + + "github.com/mixeme/selfpost/internal/atomicfile" ) // writeFileAtomic writes data to path via a temp file in the same directory // followed by a rename, so a concurrent Postfix reload only ever sees the -// complete old or new map, never a partial write (security.md). It mirrors the -// same primitive used for the OpenDKIM tables. +// complete old or new map, never a partial write (security.md). func writeFileAtomic(path string, data []byte, perm os.FileMode) error { - dir := filepath.Dir(path) - tmp, err := os.CreateTemp(dir, ".tmp-*") - if err != nil { - return fmt.Errorf("create temp in %s: %w", dir, err) - } - tmpName := tmp.Name() - cleanup := true - defer func() { - if cleanup { - _ = os.Remove(tmpName) - } - }() - - if _, err := tmp.Write(data); err != nil { - tmp.Close() - return fmt.Errorf("write temp %s: %w", tmpName, err) - } - if err := tmp.Chmod(perm); err != nil { - tmp.Close() - return fmt.Errorf("chmod temp %s: %w", tmpName, err) - } - if err := tmp.Sync(); err != nil { - tmp.Close() - return fmt.Errorf("sync temp %s: %w", tmpName, err) - } - if err := tmp.Close(); err != nil { - return fmt.Errorf("close temp %s: %w", tmpName, err) - } - if err := os.Rename(tmpName, path); err != nil { - return fmt.Errorf("rename %s -> %s: %w", tmpName, path, err) - } - cleanup = false - return nil + return atomicfile.Write(path, data, perm) } diff --git a/internal/supervisor/supervisor.go b/internal/supervisor/supervisor.go new file mode 100644 index 0000000..6f8d14f --- /dev/null +++ b/internal/supervisor/supervisor.go @@ -0,0 +1,36 @@ +// Package supervisor provides helpers for controlling processes through +// supervisord's control socket (architecture.md, security.md). +package supervisor + +import ( + "fmt" + "os/exec" + "strings" +) + +const confPath = "/etc/supervisor/supervisord.conf" + +// Start issues a `supervisorctl start` for the named program. A program that +// is already running is not an error (the pending run covers the caller's +// intent). +func Start(program string) error { + cmd := exec.Command("supervisorctl", "-c", confPath, "start", program) + out, err := cmd.CombinedOutput() + if err != nil { + if strings.Contains(string(out), "already started") { + return nil + } + return fmt.Errorf("supervisor start %s: %w: %s", program, err, strings.TrimSpace(string(out))) + } + return nil +} + +// Signal sends a signal to a supervised program via `supervisorctl signal`. +func Signal(sig, program string) error { + cmd := exec.Command("supervisorctl", "-c", confPath, "signal", sig, program) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("supervisor signal %s %s: %w: %s", sig, program, err, strings.TrimSpace(string(out))) + } + return nil +} diff --git a/internal/web/auth/ratelimit.go b/internal/web/auth/ratelimit.go index f8b09f4..6277935 100644 --- a/internal/web/auth/ratelimit.go +++ b/internal/web/auth/ratelimit.go @@ -69,24 +69,34 @@ func (r *rateLimiter) makeRoom(now time.Time) { } r.sweep(now) for len(r.buckets) >= r.maxBuckets { - r.evictOldest() + if !r.evictNearest() { + break + } } } -func (r *rateLimiter) evictOldest() { - var oldestKey string - var oldestEnds time.Time +// evictNearest removes the bucket whose window ends soonest, skipping buckets +// that are currently blocked (count >= max) so an attacker cannot flush active +// rate-limit state by filling the map with new keys. +func (r *rateLimiter) evictNearest() bool { + var bestKey string + var bestEnds time.Time first := true for k, b := range r.buckets { - if first || b.windowEnds.Before(oldestEnds) { - oldestKey = k - oldestEnds = b.windowEnds + if b.count >= r.max { + continue + } + if first || b.windowEnds.Before(bestEnds) { + bestKey = k + bestEnds = b.windowEnds first = false } } - if oldestKey != "" { - delete(r.buckets, oldestKey) + if bestKey == "" { + return false } + delete(r.buckets, bestKey) + return true } func (r *rateLimiter) sweep(now time.Time) { diff --git a/internal/web/handlers/handlers_ratelimit.go b/internal/web/handlers/handlers_ratelimit.go index 2b2fb71..7cd9e97 100644 --- a/internal/web/handlers/handlers_ratelimit.go +++ b/internal/web/handlers/handlers_ratelimit.go @@ -61,7 +61,7 @@ func parseRateLimitMode(raw string) (string, error) { return raw, nil } -func parseDomainRateLimitForm(r *http.Request, l1Max int) (rateLimitInput, error) { +func parseRateLimitForm(r *http.Request, l1Max int) (rateLimitInput, error) { if err := r.ParseForm(); err != nil { return rateLimitInput{}, fmt.Errorf("invalid form submission") } @@ -98,42 +98,12 @@ func parseDomainRateLimitForm(r *http.Request, l1Max int) (rateLimitInput, error return rateLimitInput{mode: store.RateLimitModeManual, maxMessages: maxMessages, windowSeconds: windowSeconds}, nil } +func parseDomainRateLimitForm(r *http.Request, l1Max int) (rateLimitInput, error) { + return parseRateLimitForm(r, l1Max) +} + func parseAppRateLimitForm(r *http.Request, l1Max int) (rateLimitInput, error) { - if err := r.ParseForm(); err != nil { - return rateLimitInput{}, fmt.Errorf("invalid form submission") - } - if r.PostFormValue("clear") != "" { - return rateLimitInput{clear: true}, nil - } - mode, err := parseRateLimitMode(r.PostFormValue("mode")) - if err != nil { - return rateLimitInput{}, err - } - - if mode == store.RateLimitModeAuto { - mult, err := parseAutoMultiplier(r.PostFormValue("auto_multiplier")) - if err != nil { - return rateLimitInput{}, err - } - return rateLimitInput{mode: mode, autoMultiplier: mult}, nil - } - - rawMax := strings.TrimSpace(r.PostFormValue("max_messages")) - if rawMax == "" { - return rateLimitInput{clear: true}, nil - } - maxMessages, err := parsePositiveInt(rawMax, 0) - if err != nil || maxMessages <= 0 { - return rateLimitInput{}, fmt.Errorf("enter a message limit greater than zero") - } - if maxMessages > l1Max { - return rateLimitInput{}, fmt.Errorf("message limit cannot exceed the level-1 backstop (%d)", l1Max) - } - windowSeconds, err := parsePositiveInt(r.PostFormValue("window_seconds"), defaultRateLimitWindowSeconds) - if err != nil || windowSeconds <= 0 { - return rateLimitInput{}, fmt.Errorf("enter a time window greater than zero seconds") - } - return rateLimitInput{mode: store.RateLimitModeManual, maxMessages: maxMessages, windowSeconds: windowSeconds}, nil + return parseRateLimitForm(r, l1Max) } func parseAppAuthIPsForm(r *http.Request) (bool, []string, error) { @@ -187,7 +157,7 @@ func (h *Handlers) HandleDomainRateLimit(w http.ResponseWriter, r *http.Request) if !ok { return } - in, err := parseDomainRateLimitForm(r, h.l1Messages()) + in, err := parseRateLimitForm(r, h.l1Messages()) if err != nil { h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{ FormMode: store.AddressModeWildcard, @@ -195,7 +165,7 @@ func (h *Handlers) HandleDomainRateLimit(w http.ResponseWriter, r *http.Request) }) return } - if err := h.applyDomainRateLimit(in, d.ID); err != nil { + if err := h.applyRateLimit(in, store.RateLimitScopeDomain, d.ID, h.domains); err != nil { logf("panel: domain %d: save rate limit: %v", d.ID, err) http.Error(w, "internal error", http.StatusInternalServerError) return @@ -213,7 +183,7 @@ func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) { http.Error(w, "internal error", http.StatusInternalServerError) return } - in, err := parseAppRateLimitForm(r, h.l1Messages()) + in, err := parseRateLimitForm(r, h.l1Messages()) if err != nil { h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{ FormMode: store.AddressModeWildcard, @@ -221,7 +191,7 @@ func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) { }) return } - if err := h.applyAppRateLimit(in, a.ID); err != nil { + if err := h.applyRateLimit(in, store.RateLimitScopeApp, a.ID, h.apps); err != nil { logf("panel: application %d: save rate limit: %v", a.ID, err) http.Error(w, "internal error", http.StatusInternalServerError) return @@ -291,35 +261,18 @@ func (h *Handlers) recalcRateLimit(scope string, refID int64) error { return h.store.RecalcAutoRateLimit(scope, refID, h.sendLogRetentionDays(), h.l1Messages(), h.l1Window()) } -func (h *Handlers) applyDomainRateLimit(in rateLimitInput, domainID int64) error { - if in.clear { - return h.domains.ClearRateLimit(domainID) - } - rl := store.RateLimit{ - Scope: store.RateLimitScopeDomain, - RefID: domainID, - Mode: in.mode, - MaxMessages: in.maxMessages, - WindowSeconds: in.windowSeconds, - AutoMultiplier: in.autoMultiplier, - } - if in.mode == store.RateLimitModeAuto { - rl.WindowSeconds = h.l1Window() - if err := h.domains.SaveRateLimit(domainID, rl); err != nil { - return err - } - return h.recalcRateLimit(store.RateLimitScopeDomain, domainID) - } - return h.domains.SaveRateLimit(domainID, rl) +type rateLimitSaver interface { + ClearRateLimit(id int64) error + SaveRateLimit(id int64, rl store.RateLimit) error } -func (h *Handlers) applyAppRateLimit(in rateLimitInput, appID int64) error { +func (h *Handlers) applyRateLimit(in rateLimitInput, scope string, refID int64, saver rateLimitSaver) error { if in.clear { - return h.apps.ClearRateLimit(appID) + return saver.ClearRateLimit(refID) } rl := store.RateLimit{ - Scope: store.RateLimitScopeApp, - RefID: appID, + Scope: scope, + RefID: refID, Mode: in.mode, MaxMessages: in.maxMessages, WindowSeconds: in.windowSeconds, @@ -327,10 +280,10 @@ func (h *Handlers) applyAppRateLimit(in rateLimitInput, appID int64) error { } if in.mode == store.RateLimitModeAuto { rl.WindowSeconds = h.l1Window() - if err := h.apps.SaveRateLimit(appID, rl); err != nil { + if err := saver.SaveRateLimit(refID, rl); err != nil { return err } - return h.recalcRateLimit(store.RateLimitScopeApp, appID) + return h.recalcRateLimit(scope, refID) } - return h.apps.SaveRateLimit(appID, rl) + return saver.SaveRateLimit(refID, rl) } diff --git a/internal/web/handlers/handlers_settings.go b/internal/web/handlers/handlers_settings.go index 2619b47..74d6c3d 100644 --- a/internal/web/handlers/handlers_settings.go +++ b/internal/web/handlers/handlers_settings.go @@ -71,26 +71,29 @@ func (h *Handlers) renderSettings(w http.ResponseWriter, r *http.Request, status } func settingsFlash(r *http.Request) string { - switch r.URL.Query().Get("updated") { - case "username": - return "Username changed." - case "password": - return "Password changed. Any other signed-in sessions were signed out." - case "both": - return "Username and password changed. Any other signed-in sessions were signed out." - case "email": - return "DMARC report address updated." - case "retention": - return "Send log retention updated." - case "username-email": - return "Username and DMARC report address updated." - case "password-email": - return "Password and DMARC report address updated. Any other signed-in sessions were signed out." - case "all": - return "Settings updated. Any other signed-in sessions were signed out." - default: + q := r.URL.Query() + var parts []string + if q.Has("u") { + parts = append(parts, "username") + } + if q.Has("p") { + parts = append(parts, "password") + } + if q.Has("e") { + parts = append(parts, "DMARC report address") + } + if q.Has("r") { + parts = append(parts, "send log retention") + } + if len(parts) == 0 { return "" } + msg := strings.Join(parts, ", ") + " updated." + msg = strings.ToUpper(msg[:1]) + msg[1:] + if q.Has("p") { + msg += " Any other signed-in sessions were signed out." + } + return msg } func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) { @@ -240,36 +243,24 @@ func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) { } logf("panel: user %d settings updated (username: %t, password: %t, dmarc email: %t, retention: %t)", user.ID, renaming, repassword, emailChanging, retentionChanging) - http.Redirect(w, r, "/settings?updated="+updatedFlag(renaming, repassword, emailChanging, retentionChanging), http.StatusSeeOther) + http.Redirect(w, r, "/settings?"+updatedQuery(renaming, repassword, emailChanging, retentionChanging), http.StatusSeeOther) } -func updatedFlag(renamed, repassword, emailChanged, retentionChanged bool) string { - changed := 0 +func updatedQuery(renamed, repassword, emailChanged, retentionChanged bool) string { + var params []string if renamed { - changed++ + params = append(params, "u=1") } if repassword { - changed++ + params = append(params, "p=1") } if emailChanged { - changed++ + params = append(params, "e=1") } if retentionChanged { - changed++ - } - if changed > 1 { - return "all" - } - switch { - case renamed: - return "username" - case repassword: - return "password" - case emailChanged: - return "email" - default: - return "retention" + params = append(params, "r=1") } + return strings.Join(params, "&") } // sendLogRetentionDays returns the effective delivery-journal retention window. diff --git a/internal/web/handlers/handlers_users.go b/internal/web/handlers/handlers_users.go index 05330b6..9ec6a70 100644 --- a/internal/web/handlers/handlers_users.go +++ b/internal/web/handlers/handlers_users.go @@ -225,15 +225,11 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st if u.Role == store.RoleGlobal && role == store.RoleDomainAdmin { n, err := h.store.CountGlobalUsers() if err != nil || n <= 1 { - h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Cannot demote the last global administrator.", FormUsername: username, FormRole: string(u.Role), FormDomains: selected}) - return - } - } - - if u.ID == p.ID && u.Role == store.RoleGlobal && role == store.RoleDomainAdmin { - n, err := h.store.CountGlobalUsers() - if err != nil || n <= 1 { - h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "You cannot demote yourself without another global administrator.", FormUsername: username, FormRole: string(u.Role), FormDomains: selected}) + msg := "Cannot demote the last global administrator." + if u.ID == p.ID { + msg = "You cannot demote yourself without another global administrator." + } + h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: msg, FormUsername: username, FormRole: string(u.Role), FormDomains: selected}) return } } diff --git a/internal/web/view/static/panel.css b/internal/web/view/static/panel.css index 19145b6..87f7c97 100644 --- a/internal/web/view/static/panel.css +++ b/internal/web/view/static/panel.css @@ -824,3 +824,39 @@ html:has(#help-export:checked) .help-pane-export { display: block; } @media (max-width: 66rem) { .help-drawer { width: 100vw; } } + +/* DMARC pages: paired cards side by side, same layout as .split but for two + summary cards at the top of a page. */ +.pair { + display: grid; grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); + gap: 1.2rem; margin-top: 1.2rem; +} +.pair > .card { margin-left: 0; margin-right: 0; min-width: 0; } +.pair > .card + .card { margin-top: 0; } + +/* Attention card: elevated border when a DMARC report has failures. */ +.card.attn { border-color: var(--st-warn-fg); } + +/* Responsive visibility: hide elements on narrow/wide screens. */ +.desk-only { display: revert; } +.phone-only, .phone-list { display: none; } +@media (max-width: 48rem) { + .desk-only { display: none !important; } + .phone-only, .phone-list { display: revert !important; } +} +.phone-list { list-style: none; margin: 0; padding: 0; } +.phone-list li { border-bottom: 1px solid var(--border); } +.phone-list li:last-child { border-bottom: none; } +.phone-list a { + display: grid; grid-template-columns: auto 1fr; gap: 0.15rem 0.6rem; + padding: 0.7rem 0; text-decoration: none; color: inherit; +} +.phone-list .when { grid-column: 1; font-family: var(--font-mono); font-size: 0.75rem; color: var(--muted); } +.phone-list .meta { grid-column: 2; font-size: 0.85rem; } +.phone-list .subj { grid-column: 1 / -1; font-size: 0.85rem; color: var(--muted); } + +/* Checkbox-label row inside a form (IP restriction toggle). */ +.check-row { + display: flex; align-items: center; gap: 0.5rem; + margin: 0.5rem 0; cursor: pointer; +}