",
// The stamp is split off into its own cell, without the microseconds
@@ -174,10 +174,10 @@ func TestDeliveryPageShowsThisMessagesLogLines(t *testing.T) {
// A line whose head is not a timestamp still has to show in full; the format is
// the log's, not ours, and a line we cannot split is a line we must not drop.
func TestDeliveryPageKeepsAnUnstampedLogLineWhole(t *testing.T) {
- s, row := serverWithDelivery(t)
- s.cfg.MailLogPath = writeMailLog(t, "host postfix/smtp[26]: 4A1B2C3D: to=, status=sent (250 OK)")
+ h, row := serverWithDelivery(t)
+ h.cfg.MailLogPath = writeMailLog(t, "host postfix/smtp[26]: 4A1B2C3D: to=, status=sent (250 OK)")
- out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID))
+ out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
if !strings.Contains(out, "host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, status=sent (250 OK)") {
t.Errorf("an unstamped log line did not survive the split into columns:\n%s", out)
}
@@ -186,10 +186,10 @@ func TestDeliveryPageKeepsAnUnstampedLogLineWhole(t *testing.T) {
// Rows outlive mail.log, and a message the milter refused never reached the
// queue at all. Neither is a fault, so neither may render as an error.
func TestDeliveryPageExplainsAnEmptyDeliveryLog(t *testing.T) {
- s, row := serverWithDelivery(t)
- s.cfg.MailLogPath = filepath.Join(t.TempDir(), "mail.log") // never created
+ h, row := serverWithDelivery(t)
+ h.cfg.MailLogPath = filepath.Join(t.TempDir(), "mail.log") // never created
- out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID))
+ out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
if !strings.Contains(out, "rotated away") {
t.Errorf("delivery page does not explain the empty delivery log:\n%s", out)
}
@@ -201,13 +201,13 @@ func TestDeliveryPageExplainsAnEmptyDeliveryLog(t *testing.T) {
// Send-log rows are pruned on the retention window, so a bookmarked delivery
// that no longer exists is a 404, not a 500.
func TestDeliveryPageNotFound(t *testing.T) {
- s, _ := serverWithDelivery(t)
+ h, _ := serverWithDelivery(t)
for _, path := range []string{"/deliveries/999999", "/deliveries/abc", "/deliveries/0"} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.SetPathValue("id", strings.TrimPrefix(path, "/deliveries/"))
- s.handleDelivery(rec, req)
+ h.HandleDelivery(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("GET %s = %d, want 404", path, rec.Code)
}
@@ -216,7 +216,7 @@ func TestDeliveryPageNotFound(t *testing.T) {
// serverWithDelivery builds a panel over a store holding one delivery, written
// the way the journal-milter wrote them before it decoded subjects itself.
-func serverWithDelivery(t *testing.T) (*Server, store.SendLogRow) {
+func serverWithDelivery(t *testing.T) (*Handlers, store.SendLogRow) {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
@@ -242,11 +242,7 @@ func serverWithDelivery(t *testing.T) (*Server, store.SendLogRow) {
t.Fatalf("query: %v (%d rows)", err, len(rows))
}
- tmpl, err := loadTemplates()
- if err != nil {
- t.Fatalf("loadTemplates: %v", err)
- }
- return &Server{store: st, tmpl: tmpl, cfg: Config{Version: "test"}}, rows[0]
+ return &Handlers{store: st, view: mustView(t), cfg: Config{Version: "test"}}, rows[0]
}
// getBody runs one handler over a GET and returns the page it wrote, failing
diff --git a/internal/web/handlers_ratelimit.go b/internal/web/handlers/handlers_ratelimit.go
similarity index 57%
rename from internal/web/handlers_ratelimit.go
rename to internal/web/handlers/handlers_ratelimit.go
index 410b7ec..e7e9550 100644
--- a/internal/web/handlers_ratelimit.go
+++ b/internal/web/handlers/handlers_ratelimit.go
@@ -1,4 +1,4 @@
-package web
+package handlers
import (
"fmt"
@@ -10,15 +10,8 @@ import (
"github.com/mixeme/selfpost/internal/store"
)
-// defaultRateLimitWindowSeconds is the sliding-window length used when an
-// admin sets a message ceiling but leaves the window blank (guide § Rate
-// limiting, matching the level-1 default hour; guide § Environment variables:
-// RATE_LIMIT_WINDOW_SECONDS).
const defaultRateLimitWindowSeconds = 3600
-// rateLimitInput is the validated result of a rate-limit form submission.
-// clear means "remove the differentiated limit" (guide § Rate limiting: an
-// empty IP binding leaves only level 1).
type rateLimitInput struct {
clear bool
ips []string
@@ -26,10 +19,6 @@ type rateLimitInput struct {
windowSeconds int
}
-// parseRateLimitForm validates a rate-limit submission on the server
-// (security.md). It returns clear=true when the admin removes the limit or
-// leaves the IP binding empty; otherwise it requires a positive ceiling and
-// window. The returned error's message is safe to show to the admin.
func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
if err := r.ParseForm(); err != nil {
return rateLimitInput{}, fmt.Errorf("invalid form submission")
@@ -42,8 +31,6 @@ func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
return rateLimitInput{}, err
}
if len(ips) == 0 {
- // No IP binding: the differentiated limit does not apply (guide § Rate
- // limiting).
return rateLimitInput{clear: true}, nil
}
maxMessages, err := parsePositiveInt(r.PostFormValue("max_messages"), 0)
@@ -57,10 +44,6 @@ func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
return rateLimitInput{ips: ips, maxMessages: maxMessages, windowSeconds: windowSeconds}, nil
}
-// parseIPList parses the allowed-IP field (IPs separated by newlines, commas or
-// whitespace) into a deduplicated list of canonical addresses, rejecting any
-// token that is not a valid IP (security.md). The values are only ever stored as
-// SQLite parameters and compared in the milter, never written to a config file.
func parseIPList(raw string) ([]string, error) {
fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == '\n' || r == '\r' || r == ',' || r == ' ' || r == '\t' || r == ';'
@@ -81,8 +64,6 @@ func parseIPList(raw string) ([]string, error) {
return out, nil
}
-// parsePositiveInt parses a trimmed integer field, returning def when it is
-// blank. A non-numeric value returns an error.
func parsePositiveInt(raw string, def int) (int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
@@ -91,23 +72,20 @@ func parsePositiveInt(raw string, def int) (int, error) {
return strconv.Atoi(raw)
}
-// handleDomainRateLimit saves or clears a domain-level differentiated rate
-// limit (guide § Rate limiting). No reload is needed — the milter reads the
-// row live.
-func (s *Server) handleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
- d, ok := s.lookupDomain(w, r)
+func (h *Handlers) HandleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
+ d, ok := h.lookupDomain(w, r)
if !ok {
return
}
in, err := parseRateLimitForm(r)
if err != nil {
- s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
+ h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard,
RateLimitErr: err.Error(),
})
return
}
- if err := s.applyRateLimit(in, s.domains.SaveRateLimit, s.domains.ClearRateLimit, d.ID); err != nil {
+ if err := h.applyRateLimit(in, h.domains.SaveRateLimit, h.domains.ClearRateLimit, d.ID); err != nil {
logf("panel: domain %d: save rate limit: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
@@ -115,27 +93,25 @@ func (s *Server) handleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", d.ID), http.StatusSeeOther)
}
-// handleAppRateLimit saves or clears an application-level differentiated rate
-// limit (guide § Rate limiting).
-func (s *Server) handleAppRateLimit(w http.ResponseWriter, r *http.Request) {
- a, ok := s.lookupApplication(w, r)
+func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) {
+ a, ok := h.lookupApplication(w, r)
if !ok {
return
}
- d, err := s.domains.Get(a.DomainID)
+ d, err := h.domains.Get(a.DomainID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
in, err := parseRateLimitForm(r)
if err != nil {
- s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
+ h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard,
RateLimitErr: fmt.Sprintf("%s: %s", a.Login, err.Error()),
})
return
}
- if err := s.applyRateLimit(in, s.apps.SaveRateLimit, s.apps.ClearRateLimit, a.ID); err != nil {
+ if err := h.applyRateLimit(in, h.apps.SaveRateLimit, h.apps.ClearRateLimit, a.ID); err != nil {
logf("panel: application %d: save rate limit: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
@@ -143,9 +119,7 @@ func (s *Server) handleAppRateLimit(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", a.DomainID), http.StatusSeeOther)
}
-// applyRateLimit dispatches a validated input to the save or clear method of the
-// relevant service, keyed by the domain or application id.
-func (s *Server) applyRateLimit(
+func (h *Handlers) applyRateLimit(
in rateLimitInput,
save func(id int64, ips []string, maxMessages, windowSeconds int) error,
clear func(id int64) error,
diff --git a/internal/web/handlers/handlers_status.go b/internal/web/handlers/handlers_status.go
new file mode 100644
index 0000000..c731860
--- /dev/null
+++ b/internal/web/handlers/handlers_status.go
@@ -0,0 +1,111 @@
+package handlers
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/mixeme/selfpost/internal/health"
+ "github.com/mixeme/selfpost/internal/web/auth"
+)
+
+func (h *Handlers) HandleStatus(w http.ResponseWriter, r *http.Request) {
+ data := h.statusBody()
+ srv := h.dns.Server(h.cfg.Hostname, false)
+
+ data["Title"] = "SelfPost — status"
+ data["User"] = auth.CurrentUser(r)
+ data["Active"] = "status"
+ data["Flash"] = statusFlash(r)
+ data["Hostname"] = h.cfg.Hostname
+ data["PTR"] = srv.PTR
+ h.view.Render(w, http.StatusOK, "status", data)
+}
+
+func (h *Handlers) HandleStatusFragment(w http.ResponseWriter, _ *http.Request) {
+ h.view.RenderFragment(w, http.StatusOK, "status_body", h.statusBody())
+}
+
+func (h *Handlers) HandleStatusRecheck(w http.ResponseWriter, r *http.Request) {
+ h.dns.Server(h.cfg.Hostname, true)
+ http.Redirect(w, r, "/status?rechecked=1", http.StatusSeeOther)
+}
+
+func (h *Handlers) statusBody() map[string]any {
+ procs, procErr := health.Processes()
+ procStatus := health.StatusUnknown
+ if procErr != nil {
+ logf("panel: status: supervisorctl: %v", procErr)
+ } else {
+ for _, p := range procs {
+ procStatus = health.Worst(procStatus, p.Status)
+ }
+ }
+
+ queueText, queueErr := readQueue()
+ queueStatus := health.StatusOK
+ if queueErr != "" {
+ queueStatus = health.StatusWarn
+ }
+
+ cert := health.CheckCertificate(h.cfg.TLSCertFile)
+ sockets := []health.Socket{
+ health.CheckSocket("OpenDKIM", h.cfg.OpenDKIMSocket, true),
+ health.CheckSocket("send-log", h.cfg.JournalSocket, false),
+ }
+ socketStatus := health.StatusUnknown
+ for _, sock := range sockets {
+ socketStatus = health.Worst(socketStatus, sock.Status)
+ }
+
+ machine := h.machine.Sample()
+
+ overall := health.Worst(procStatus, queueStatus, cert.Status, socketStatus, machine.Status)
+ return map[string]any{
+ "Processes": procs,
+ "ProcessError": procErr != nil,
+ "ProcessStatus": procStatus,
+ "QueueSummary": queueSummary(queueText),
+ "QueueError": queueErr,
+ "QueueStatus": queueStatus,
+ "Machine": machine,
+ "Cert": cert,
+ "Sockets": sockets,
+ "SocketStatus": socketStatus,
+ "OverallStatus": overall,
+ "OverallHeading": overallHeading(overall),
+ }
+}
+
+func queueSummary(out string) string {
+ lines := strings.Split(strings.TrimSpace(out), "\n")
+ for i := len(lines) - 1; i >= 0; i-- {
+ if line := strings.TrimSpace(lines[i]); line != "" {
+ return strings.TrimSpace(strings.TrimPrefix(line, "--"))
+ }
+ }
+ return ""
+}
+
+func overallHeading(worst health.Status) string {
+ switch worst {
+ case health.StatusError:
+ return "A component needs attention — see the details below."
+ case health.StatusWarn:
+ return "Running, with warnings below."
+ case health.StatusOK:
+ return "All components are running normally."
+ default:
+ return "Some checks could not be performed."
+ }
+}
+
+func statusFlash(r *http.Request) string {
+ switch {
+ case r.URL.Query().Get("reloaded") != "":
+ return "Configuration regenerated from the database; OpenDKIM and Postfix have re-read it."
+ case r.URL.Query().Get("rechecked") != "":
+ return "DNS re-checked."
+ default:
+ return ""
+ }
+}
diff --git a/internal/web/handlers/testutil_test.go b/internal/web/handlers/testutil_test.go
new file mode 100644
index 0000000..a3c0bf6
--- /dev/null
+++ b/internal/web/handlers/testutil_test.go
@@ -0,0 +1,16 @@
+package handlers
+
+import (
+ "testing"
+
+ "github.com/mixeme/selfpost/internal/web/view"
+)
+
+func mustView(t *testing.T) *view.Engine {
+ t.Helper()
+ v, err := view.New("test")
+ if err != nil {
+ t.Fatalf("view: %v", err)
+ }
+ return v
+}
diff --git a/internal/web/handlers_account.go b/internal/web/handlers_account.go
deleted file mode 100644
index 9cbdf0a..0000000
--- a/internal/web/handlers_account.go
+++ /dev/null
@@ -1,168 +0,0 @@
-package web
-
-import (
- "errors"
- "net/http"
- "strings"
-
- "github.com/mixeme/selfpost/internal/store"
- "golang.org/x/crypto/bcrypt"
-)
-
-// handleAccount serves the administrator's own account settings: the username
-// and password chosen during setup are the only panel credentials
-// (security.md), and until now they could be changed only by recreating the
-// state. Changing them here never touches application SASL logins, which are a
-// separate identity system (architecture.md § Mail path).
-func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) {
- switch r.Method {
- case http.MethodGet:
- s.renderAccount(w, r, http.StatusOK, "", currentUser(r))
- case http.MethodPost:
- s.submitAccount(w, r)
- default:
- w.Header().Set("Allow", "GET, POST")
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- }
-}
-
-// renderAccount draws the settings form. formUsername repopulates the username
-// field after a rejected submission; the password fields are never repopulated.
-func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername string) {
- s.render(w, status, "account", map[string]any{
- "Title": "SelfPost — settings",
- "User": currentUser(r),
- "Active": "account",
- "FormUsername": formUsername,
- "Error": formErr,
- "Flash": accountFlash(r),
- })
-}
-
-// accountFlash maps a fixed redirect flag to a fixed message, so status text
-// after a redirect is never attacker-influenced.
-func accountFlash(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."
- default:
- return ""
- }
-}
-
-// submitAccount applies a username and/or password change. The current password
-// is always required, so a stolen session alone cannot lock the administrator
-// out of their own panel, and the attempt is throttled on the same limiter as
-// the login form so this route cannot be used to brute-force the password past
-// that limit (security.md).
-func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) {
- if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) {
- s.renderAccount(w, r, http.StatusTooManyRequests,
- "Too many attempts. Please wait and try again.", currentUser(r))
- return
- }
- if err := r.ParseForm(); err != nil {
- s.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", currentUser(r))
- return
- }
-
- username := strings.TrimSpace(r.PostFormValue("username"))
- current := r.PostFormValue("current_password")
- password := r.PostFormValue("new_password")
- confirm := r.PostFormValue("new_password_confirm")
-
- admin, err := s.store.GetAdmin()
- if err != nil {
- logf("panel: account: get admin failed: %v", err)
- http.Error(w, "internal error", http.StatusInternalServerError)
- return
- }
- if username == "" {
- username = admin.Username
- }
-
- if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(current)); err != nil {
- s.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username)
- return
- }
-
- renaming := username != admin.Username
- if renaming {
- if err := validateUsername(username); err != nil {
- s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username)
- return
- }
- }
-
- // An empty pair of new-password fields means "leave the password alone", so
- // the username can be changed on its own.
- repassword := password != "" || confirm != ""
- if repassword {
- if password != confirm {
- s.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username)
- return
- }
- if err := validateAdminPassword(password); err != nil {
- s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username)
- return
- }
- }
- if !renaming && !repassword {
- s.renderAccount(w, r, http.StatusBadRequest,
- "Nothing to change: enter a new username, a new password, or both.", username)
- return
- }
-
- hash := admin.PasswordHash
- if repassword {
- newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
- if err != nil {
- logf("panel: account: hashing password failed: %v", err)
- s.renderAccount(w, r, http.StatusInternalServerError,
- "Internal error. Please try again.", username)
- return
- }
- hash = string(newHash)
- }
-
- if err := s.store.UpdateAdmin(username, hash); err != nil {
- logf("panel: account: update admin failed: %v", err)
- msg := "Could not save the changes. Please check the logs and try again."
- if errors.Is(err, store.ErrNoAdmin) {
- msg = "There is no administrator account to update."
- }
- s.renderAccount(w, r, http.StatusInternalServerError, msg, username)
- return
- }
-
- // Keep this session usable under the new name, and — when the password
- // changed — drop every other session so a cookie captured under the old
- // password stops working.
- if token, ok := s.sessionToken(r); ok {
- if renaming {
- s.sessions.Rename(token, username)
- }
- if repassword {
- s.sessions.DestroyOthers(token)
- }
- }
-
- logf("panel: administrator account updated (username changed: %t, password changed: %t)", renaming, repassword)
- http.Redirect(w, r, "/account?updated="+updatedFlag(renaming, repassword), http.StatusSeeOther)
-}
-
-// updatedFlag names what changed, for the fixed post-redirect flash message.
-func updatedFlag(renamed, repassword bool) string {
- switch {
- case renamed && repassword:
- return "both"
- case renamed:
- return "username"
- default:
- return "password"
- }
-}
diff --git a/internal/web/handlers_auth.go b/internal/web/handlers_auth.go
deleted file mode 100644
index 72c546e..0000000
--- a/internal/web/handlers_auth.go
+++ /dev/null
@@ -1,198 +0,0 @@
-package web
-
-import (
- "errors"
- "net/http"
- "strings"
-
- "github.com/mixeme/selfpost/internal/store"
- "golang.org/x/crypto/bcrypt"
-)
-
-// The panel session cookie's two possible names. In the production shape —
-// TLS in front, so CookieSecure — it carries the __Host- prefix, which turns
-// what the cookie's attributes merely promise into something the browser
-// enforces: Secure, Path=/ and, the point of the exercise, no Domain
-// attribute, so no other host may set a cookie by this name.
-// The prefix is only valid on a Secure cookie, so a development instance on
-// plain HTTP has to keep the bare name: with the prefix the browser would
-// discard the Set-Cookie outright and logging in would silently never stick.
-const (
- sessionCookieBase = "selfpost_session"
- sessionCookiePrefixed = "__Host-" + sessionCookieBase
-)
-
-// sessionCookie is the session cookie's name for this deployment.
-func (s *Server) sessionCookie() string {
- if s.cfg.CookieSecure {
- return sessionCookiePrefixed
- }
- return sessionCookieBase
-}
-
-// sessionToken returns the session token the request carries, if exactly one
-// cookie of that name is present.
-//
-// It walks r.Cookies() rather than calling r.Cookie, which silently returns
-// the first match. Two cookies with the same name mean somebody other than
-// this panel set one of them — a host on the same registrable domain can,
-// with Domain=example.com, and the browser will then send both — and RFC 6265
-// makes the older one come first, so "the first match" is precisely the
-// attacker's. The value cannot be forged into a valid session, so the effect
-// is denial of service, not compromise; refusing the request and saying so in
-// the log is what makes it diagnosable instead of an endless login loop. The
-// __Host- prefix prevents this outright, but only where it applies — this
-// check also covers the plain-HTTP development shape.
-func (s *Server) sessionToken(r *http.Request) (string, bool) {
- name := s.sessionCookie()
- var token string
- var n int
- for _, c := range r.Cookies() {
- if c.Name == name {
- n++
- token = c.Value
- }
- }
- switch n {
- case 0:
- return "", false
- case 1:
- return token, true
- default:
- logf("panel: %s %s carries %d cookies named %q — treating the request as signed out; "+
- "another host on this domain is overwriting the session cookie, clear the cookies for the parent domain",
- r.Method, r.URL.Path, n, name)
- return "", false
- }
-}
-
-// clearSessionCookies expires the session cookie under both names, so an
-// upgrade that switches to the __Host- prefix does not leave the old cookie
-// sitting in the browser until it is closed.
-func (s *Server) clearSessionCookies(w http.ResponseWriter) {
- for _, name := range []string{sessionCookieBase, sessionCookiePrefixed} {
- http.SetCookie(w, &http.Cookie{
- Name: name,
- Value: "",
- Path: "/",
- MaxAge: -1,
- HttpOnly: true,
- // The prefixed name is only accepted at all when Secure is set,
- // including on the expiring copy.
- Secure: s.cfg.CookieSecure || name == sessionCookiePrefixed,
- SameSite: http.SameSiteLaxMode,
- })
- }
-}
-
-// handleLogin serves the login form (GET) and authenticates (POST). Until an
-// administrator exists there is nobody to log in, so it points at setup.
-func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
- exists, err := s.store.AdminExists()
- if err != nil {
- http.Error(w, "internal error", http.StatusInternalServerError)
- return
- }
- if !exists {
- // No admin yet: login is meaningless. Send a clear message rather than
- // a failing form.
- s.render(w, http.StatusOK, "login", map[string]any{
- "Title": "SelfPost — Sign in",
- "Active": "login",
- "SetupHint": true,
- })
- return
- }
-
- switch r.Method {
- case http.MethodGet:
- s.renderLogin(w, http.StatusOK, "")
- case http.MethodPost:
- s.submitLogin(w, r)
- default:
- w.Header().Set("Allow", "GET, POST")
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- }
-}
-
-func (s *Server) renderLogin(w http.ResponseWriter, status int, formErr string) {
- // Active names the page for the layout even though no navigation is drawn
- // here: it is what puts page-login on , which the stylesheet uses to
- // give the signed-out pages a column the width of their own card.
- s.render(w, status, "login", map[string]any{
- "Title": "SelfPost — Sign in",
- "Active": "login",
- "Error": formErr,
- })
-}
-
-func (s *Server) submitLogin(w http.ResponseWriter, r *http.Request) {
- // Brute-force throttle by client IP (security.md).
- if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) {
- s.renderLogin(w, http.StatusTooManyRequests, "Too many attempts. Please wait and try again.")
- return
- }
- if err := r.ParseForm(); err != nil {
- s.renderLogin(w, http.StatusBadRequest, "Invalid form submission.")
- return
- }
- username := strings.TrimSpace(r.PostFormValue("username"))
- password := r.PostFormValue("password")
-
- admin, err := s.store.GetAdmin()
- if err != nil {
- if !errors.Is(err, store.ErrNoAdmin) {
- logf("panel: login: get admin failed: %v", err)
- }
- s.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
- return
- }
-
- // Always run bcrypt so timing does not distinguish "wrong user" from
- // "wrong password", and compare the username too.
- pwErr := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password))
- if username != admin.Username || pwErr != nil {
- s.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
- return
- }
-
- token := s.sessions.Create(admin.Username)
- s.setSessionCookie(w, token)
- http.Redirect(w, r, "/", http.StatusSeeOther)
-}
-
-// setSessionCookie (re)issues the session cookie with a fresh Max-Age equal
-// to the sliding idle window (plan B.1), so the browser-side expiry tracks
-// whatever the database row was just set to — at login, and again whenever
-// requireAuth extends an active session.
-func (s *Server) setSessionCookie(w http.ResponseWriter, token string) {
- http.SetCookie(w, &http.Cookie{
- Name: s.sessionCookie(),
- Value: token,
- Path: "/",
- MaxAge: s.sessions.MaxAge(),
- HttpOnly: true,
- Secure: s.cfg.CookieSecure,
- SameSite: http.SameSiteLaxMode,
- })
-}
-
-// handleLogout destroys the session and clears the cookie.
-func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- w.Header().Set("Allow", "POST")
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
- // Destroy every token presented under the session cookie's name: if a
- // shadowing duplicate is present (see sessionToken) one of them is the
- // real session, and a value that names no session is simply not found.
- name := s.sessionCookie()
- for _, c := range r.Cookies() {
- if c.Name == name {
- s.sessions.Destroy(c.Value)
- }
- }
- s.clearSessionCookies(w)
- http.Redirect(w, r, "/login", http.StatusSeeOther)
-}
diff --git a/internal/web/handlers_domains.go b/internal/web/handlers_domains.go
deleted file mode 100644
index 767e4fa..0000000
--- a/internal/web/handlers_domains.go
+++ /dev/null
@@ -1,206 +0,0 @@
-package web
-
-import (
- "errors"
- "fmt"
- "net/http"
- "strconv"
- "sync"
-
- "github.com/mixeme/selfpost/internal/health"
- "github.com/mixeme/selfpost/internal/store"
-)
-
-// domainRow is one line of the domain list: the stored domain plus the rolled-up
-// verdict of its published DNS records, so the operator sees which domains still
-// need a record published without opening each one.
-type domainRow struct {
- store.Domain
- DNS health.Status
-}
-
-// handleDashboard is the authenticated landing page: the list of sending
-// domains with their DKIM/selector and application counts, plus the add-domain
-// form (product.md).
-func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
- s.renderDashboard(w, r, http.StatusOK, "", "")
-}
-
-// renderDashboard renders the domain list. formErr and formName repopulate the
-// add-domain form after a rejected submission; flash surfaces a one-shot status
-// message keyed by a redirect query flag (never reflected user input).
-func (s *Server) renderDashboard(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) {
- domains, err := s.domains.List()
- if err != nil {
- logf("panel: dashboard: list domains: %v", err)
- http.Error(w, "internal error", http.StatusInternalServerError)
- return
- }
- s.render(w, status, "dashboard", map[string]any{
- "Title": "SelfPost",
- "User": currentUser(r),
- "Active": "domains",
- "Domains": s.domainRows(domains),
- "Error": formErr,
- "FormName": formName,
- "Flash": dashboardFlash(r),
- })
-}
-
-// domainRows attaches each domain's DNS verdict to its row. The checks run
-// concurrently rather than one after another: each carries its own timeout, so
-// in series a dead resolver would multiply that wait by the number of domains
-// and the list would look hung. The checker caches results for a few minutes,
-// so a repeat view of the list costs no lookups at all, and it is the same
-// cache the domain page fills — opening a domain after the list is free.
-func (s *Server) domainRows(domains []store.Domain) []domainRow {
- rows := make([]domainRow, len(domains))
- var wg sync.WaitGroup
- for i, d := range domains {
- rows[i] = domainRow{Domain: d, DNS: health.StatusUnknown}
- wg.Add(1)
- go func() {
- defer wg.Done()
- record, err := s.domains.DKIMRecord(d)
- if err != nil {
- // Without the expected key there is nothing to compare DNS
- // against; leave the row unknown rather than accusing the
- // domain of a misconfiguration this server caused.
- logf("panel: dashboard: domain %d: dkim record: %v", d.ID, err)
- return
- }
- dns, _ := s.domainDNS(d, record, false)
- rows[i].DNS = dns.Overall
- }()
- }
- wg.Wait()
- return rows
-}
-
-// dashboardFlash maps a fixed redirect flag to a fixed message, so status text
-// after a redirect is never attacker-influenced.
-func dashboardFlash(r *http.Request) string {
- if r.URL.Query().Get("deleted") != "" {
- return "Domain deleted."
- }
- return ""
-}
-
-// handleAddDomain validates the submitted name, creates the domain (DKIM key +
-// OpenDKIM reload), and redirects to the domain's page so the DNS record to
-// publish is shown (product.md).
-func (s *Server) handleAddDomain(w http.ResponseWriter, r *http.Request) {
- if err := r.ParseForm(); err != nil {
- s.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "")
- return
- }
- raw := r.PostFormValue("name")
- name := normalizeDomain(raw)
- if err := validateDomain(name); err != nil {
- s.renderDashboard(w, r, http.StatusBadRequest, err.Error(), raw)
- return
- }
-
- d, err := s.domains.Add(name)
- if err != nil {
- if errors.Is(err, store.ErrDomainExists) {
- s.renderDashboard(w, r, http.StatusConflict, "That domain is already configured.", raw)
- return
- }
- logf("panel: add domain %q: %v", name, err)
- s.renderDashboard(w, r, http.StatusInternalServerError,
- "Could not add the domain. Please check the logs and try again.", raw)
- return
- }
- http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther)
-}
-
-// handleDeleteConfirm shows the cascade warning before a domain is removed: the
-// panel must explicitly state that all bound applications go with it (product.md).
-func (s *Server) handleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
- d, ok := s.lookupDomain(w, r)
- if !ok {
- return
- }
- s.render(w, http.StatusOK, "domain_delete", map[string]any{
- "Title": "SelfPost — delete " + d.Name,
- "User": currentUser(r),
- "Active": "domains",
- "Domain": d,
- })
-}
-
-// handleDeleteDomain performs the deletion (cascade + DKIM key + OpenDKIM reload)
-// and returns to the domain list.
-func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
- id, ok := parseDomainID(w, r)
- if !ok {
- return
- }
- // Drop any cached DNS verdict for the name while it is still resolvable, so
- // re-adding the domain later starts from a fresh check instead of a stale
- // one from before it was removed.
- if d, err := s.domains.Get(id); err == nil {
- defer s.dns.Forget(d.Name)
- }
- if err := s.domains.Delete(id); err != nil {
- if errors.Is(err, store.ErrDomainNotFound) {
- http.NotFound(w, r)
- return
- }
- logf("panel: delete domain %d: %v", id, err)
- http.Error(w, "internal error", http.StatusInternalServerError)
- return
- }
- http.Redirect(w, r, "/domains?deleted=1", http.StatusSeeOther)
-}
-
-// handleReload re-applies both the OpenDKIM configuration and the Postfix
-// sender map on demand (architecture.md § Panel HTTP surface). Each Resync
-// regenerates its files from the database and reloads its daemon, so the
-// button doubles as a drift-recovery. The button lives on the status page: it
-// is a "put the daemons back in the state the database describes" action,
-// which belongs with the rest of the server-health screen rather than in the
-// domain list's top bar.
-func (s *Server) handleReload(w http.ResponseWriter, r *http.Request) {
- if err := s.domains.Resync(); err != nil {
- logf("panel: manual reload (opendkim): %v", err)
- http.Error(w, "reload failed", http.StatusInternalServerError)
- return
- }
- if err := s.apps.Resync(); err != nil {
- logf("panel: manual reload (postfix): %v", err)
- http.Error(w, "reload failed", http.StatusInternalServerError)
- return
- }
- http.Redirect(w, r, "/status?reloaded=1", http.StatusSeeOther)
-}
-
-// lookupDomain resolves the {id} path value to a domain, writing a 404 for a
-// bad id or a missing domain and reporting ok=false in that case.
-func (s *Server) lookupDomain(w http.ResponseWriter, r *http.Request) (store.Domain, bool) {
- id, ok := parseDomainID(w, r)
- if !ok {
- return store.Domain{}, false
- }
- d, err := s.domains.Get(id)
- if err != nil {
- if errors.Is(err, store.ErrDomainNotFound) {
- http.NotFound(w, r)
- return store.Domain{}, false
- }
- logf("panel: get domain %d: %v", id, err)
- http.Error(w, "internal error", http.StatusInternalServerError)
- return store.Domain{}, false
- }
- return d, true
-}
-
-func parseDomainID(w http.ResponseWriter, r *http.Request) (int64, bool) {
- id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
- if err != nil || id <= 0 {
- http.NotFound(w, r)
- return 0, false
- }
- return id, true
-}
diff --git a/internal/web/handlers_license_test.go b/internal/web/handlers_license_test.go
new file mode 100644
index 0000000..63b14b7
--- /dev/null
+++ b/internal/web/handlers_license_test.go
@@ -0,0 +1,40 @@
+package web
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/mixeme/selfpost/internal/legal"
+)
+
+func TestLicenseHandlerServesAGPL(t *testing.T) {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/license", nil)
+ handleLicense(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ ct := rec.Header().Get("Content-Type")
+ if !strings.HasPrefix(ct, "text/plain") {
+ t.Errorf("Content-Type = %q, want text/plain", ct)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "GNU AFFERO GENERAL PUBLIC LICENSE") {
+ t.Error("response is missing the AGPL title")
+ }
+ if body != string(legal.License) {
+ t.Error("response body does not match legal.License")
+ }
+}
+
+func TestLicenseHandlerRejectsNonGET(t *testing.T) {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/license", nil)
+ handleLicense(rec, req)
+ if rec.Code != http.StatusMethodNotAllowed {
+ t.Fatalf("status = %d, want 405", rec.Code)
+ }
+}
diff --git a/internal/web/handlers_setup.go b/internal/web/handlers_setup.go
deleted file mode 100644
index 06fcf2a..0000000
--- a/internal/web/handlers_setup.go
+++ /dev/null
@@ -1,99 +0,0 @@
-package web
-
-import (
- "net/http"
- "strings"
-
- "golang.org/x/crypto/bcrypt"
-)
-
-// handleSetup serves the one-time administrator creation flow at
-// /setup/ (security.md). Once an administrator exists the whole route
-// returns 404; an invalid or expired token is indistinguishable from a missing
-// page, also 404.
-func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
- // Route-specific rate limit, separate from login (security.md).
- if !s.setupLimiter.Allow(clientIP(r, s.trustedProxies)) {
- http.Error(w, "too many requests", http.StatusTooManyRequests)
- return
- }
-
- token := strings.TrimPrefix(r.URL.Path, "/setup/")
- // Reject nested/garbage paths outright.
- if token == "" || strings.Contains(token, "/") {
- http.NotFound(w, r)
- return
- }
- if !s.setup.validate(token) {
- http.NotFound(w, r)
- return
- }
-
- switch r.Method {
- case http.MethodGet:
- s.renderSetupForm(w, http.StatusOK, token, "")
- case http.MethodPost:
- s.submitSetup(w, r, token)
- default:
- w.Header().Set("Allow", "GET, POST")
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- }
-}
-
-func (s *Server) renderSetupForm(w http.ResponseWriter, status int, token, formErr string) {
- s.render(w, status, "setup", map[string]any{
- "Title": "SelfPost — Create administrator",
- "Active": "setup",
- "Token": token,
- "Error": formErr,
- })
-}
-
-func (s *Server) submitSetup(w http.ResponseWriter, r *http.Request, token string) {
- if err := r.ParseForm(); err != nil {
- s.renderSetupForm(w, http.StatusBadRequest, token, "Invalid form submission.")
- return
- }
- username := strings.TrimSpace(r.PostFormValue("username"))
- password := r.PostFormValue("password")
- confirm := r.PostFormValue("password_confirm")
-
- if err := validateUsername(username); err != nil {
- s.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
- return
- }
- if password != confirm {
- s.renderSetupForm(w, http.StatusBadRequest, token, "Passwords do not match.")
- return
- }
- if err := validateAdminPassword(password); err != nil {
- s.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
- return
- }
-
- hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
- if err != nil {
- logf("panel: setup: hashing password failed: %v", err)
- s.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
- return
- }
-
- if err := s.store.CreateAdmin(username, string(hash)); err != nil {
- // A concurrent submission may have already created the admin; the
- // id=1 / non-empty-table guard makes this the second writer. Treat it
- // as "setup already done" rather than an error.
- if exists, _ := s.store.AdminExists(); exists {
- s.setup.complete()
- http.Redirect(w, r, "/login", http.StatusSeeOther)
- return
- }
- logf("panel: setup: create admin failed: %v", err)
- s.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
- return
- }
-
- // Setup is now permanently complete: burn the token (security.md).
- s.setup.complete()
- logf("panel: administrator %q created; setup link is now disabled", username)
- http.Redirect(w, r, "/login", http.StatusSeeOther)
-}
diff --git a/internal/web/handlers_status.go b/internal/web/handlers_status.go
deleted file mode 100644
index 2216652..0000000
--- a/internal/web/handlers_status.go
+++ /dev/null
@@ -1,140 +0,0 @@
-package web
-
-import (
- "net/http"
- "strings"
-
- "github.com/mixeme/selfpost/internal/health"
-)
-
-// handleStatus renders the server status page: the panel's landing page and the
-// one screen that answers "is the service healthy and will mail be accepted".
-// The cheap local checks live in the polled "status_body"
-// fragment; the hostname/PTR lookup and the configuration reload sit outside it,
-// because neither belongs on a five-second timer.
-func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
- data := s.statusBody()
- srv := s.dns.Server(s.cfg.Hostname, false)
-
- data["Title"] = "SelfPost — status"
- data["User"] = currentUser(r)
- data["Active"] = "status"
- data["Flash"] = statusFlash(r)
- data["Hostname"] = s.cfg.Hostname
- data["PTR"] = srv.PTR
- s.render(w, http.StatusOK, "status", data)
-}
-
-// handleStatusFragment serves the HTMX polling fragment for the local checks
-// (architecture.md § Panel HTTP surface: fragment endpoints return HTML, not
-// JSON).
-func (s *Server) handleStatusFragment(w http.ResponseWriter, _ *http.Request) {
- s.renderFragment(w, http.StatusOK, "status_body", s.statusBody())
-}
-
-// handleStatusRecheck forces a fresh hostname/PTR lookup, bypassing the cache,
-// and returns to the page. DNS is the one part of this screen that talks to the
-// network, so it refreshes on demand rather than with the poll.
-func (s *Server) handleStatusRecheck(w http.ResponseWriter, r *http.Request) {
- s.dns.Server(s.cfg.Hostname, true)
- http.Redirect(w, r, "/status?rechecked=1", http.StatusSeeOther)
-}
-
-// statusBody collects the local checks the fragment renders. Each one
-// reports its own problem rather than failing the page, so a broken component
-// costs one line and not the whole screen.
-func (s *Server) statusBody() map[string]any {
- procs, procErr := health.Processes()
- procStatus := health.StatusUnknown
- if procErr != nil {
- // Outside the container (or if the control socket is gone) there is
- // nothing to report — "unknown", not "everything is broken".
- logf("panel: status: supervisorctl: %v", procErr)
- } else {
- for _, p := range procs {
- procStatus = health.Worst(procStatus, p.Status)
- }
- }
-
- queueText, queueErr := readQueue()
- queueStatus := health.StatusOK
- if queueErr != "" {
- queueStatus = health.StatusWarn
- }
-
- cert := health.CheckCertificate(s.cfg.TLSCertFile)
- sockets := []health.Socket{
- // OpenDKIM signs every outgoing message and Postfix is configured to
- // tempfail without it: a missing socket stops mail.
- health.CheckSocket("OpenDKIM", s.cfg.OpenDKIMSocket, true),
- // The journal-milter only records the send log and fails open.
- health.CheckSocket("send-log", s.cfg.JournalSocket, false),
- }
- socketStatus := health.StatusUnknown
- for _, sock := range sockets {
- socketStatus = health.Worst(socketStatus, sock.Status)
- }
-
- // Resource usage of the machine underneath. It is graded like the rest —
- // a processor that is fully busy or a machine out of memory delays or
- // kills the mail path — so it counts towards the headline verdict, and
- // its rates are measured against the previous poll (internal/health).
- machine := s.machine.Sample()
-
- overall := health.Worst(procStatus, queueStatus, cert.Status, socketStatus, machine.Status)
- return map[string]any{
- "Processes": procs,
- "ProcessError": procErr != nil,
- "ProcessStatus": procStatus,
- "QueueSummary": queueSummary(queueText),
- "QueueError": queueErr,
- "QueueStatus": queueStatus,
- "Machine": machine,
- "Cert": cert,
- "Sockets": sockets,
- "SocketStatus": socketStatus,
- "OverallStatus": overall,
- "OverallHeading": overallHeading(overall),
- }
-}
-
-// queueSummary reduces postqueue's listing to the one line worth showing on
-// the status page; the full listing has its own screen (architecture.md §
-// Panel HTTP surface). postqueue prints either "Mail queue is empty" or a
-// trailing "-- N Kbytes in M Requests."
-func queueSummary(out string) string {
- lines := strings.Split(strings.TrimSpace(out), "\n")
- for i := len(lines) - 1; i >= 0; i-- {
- if line := strings.TrimSpace(lines[i]); line != "" {
- return strings.TrimSpace(strings.TrimPrefix(line, "--"))
- }
- }
- return ""
-}
-
-// overallHeading turns the worst check into the page's one-line verdict.
-func overallHeading(worst health.Status) string {
- switch worst {
- case health.StatusError:
- return "A component needs attention — see the details below."
- case health.StatusWarn:
- return "Running, with warnings below."
- case health.StatusOK:
- return "All components are running normally."
- default:
- return "Some checks could not be performed."
- }
-}
-
-// statusFlash maps a fixed redirect flag to a fixed message, so status text
-// after a redirect is never attacker-influenced.
-func statusFlash(r *http.Request) string {
- switch {
- case r.URL.Query().Get("reloaded") != "":
- return "Configuration regenerated from the database; OpenDKIM and Postfix have re-read it."
- case r.URL.Query().Get("rechecked") != "":
- return "DNS re-checked."
- default:
- return ""
- }
-}
diff --git a/internal/web/middleware.go b/internal/web/middleware.go
deleted file mode 100644
index 0397b02..0000000
--- a/internal/web/middleware.go
+++ /dev/null
@@ -1,55 +0,0 @@
-package web
-
-import (
- "context"
- "net/http"
-)
-
-type ctxKey int
-
-const usernameKey ctxKey = 0
-
-// requireAuth wraps a handler so only requests with a valid session cookie
-// reach it; everyone else is redirected to the login page. The authenticated
-// username is stashed in the request context for downstream handlers.
-//
-// It also extends the sliding session (plan B.1) on activity, defined as
-// everything except a GET request carrying HX-Request: the four monitoring
-// fragments (/status/fragment, /mail-queue/body, /system-log/body,
-// /deliveries/rows)
-// poll on a timer regardless of whether anyone is looking at the tab, so
-// counting those as activity would make "N days idle" mean "N days since a
-// browser tab was last open" instead.
-func (s *Server) requireAuth(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- token, ok := s.sessionToken(r)
- if !ok {
- http.Redirect(w, r, "/login", http.StatusSeeOther)
- return
- }
- username, ok := s.sessions.Lookup(token)
- if !ok {
- http.Redirect(w, r, "/login", http.StatusSeeOther)
- return
- }
- if isSessionActivity(r) && s.sessions.Touch(token) {
- s.setSessionCookie(w, token)
- }
- ctx := context.WithValue(r.Context(), usernameKey, username)
- next.ServeHTTP(w, r.WithContext(ctx))
- })
-}
-
-// isSessionActivity reports whether a request counts as administrator
-// activity for the sliding session timeout, per requireAuth's doc comment.
-func isSessionActivity(r *http.Request) bool {
- return !(r.Method == http.MethodGet && r.Header.Get("HX-Request") != "")
-}
-
-// currentUser returns the authenticated username from the request context.
-func currentUser(r *http.Request) string {
- if v, ok := r.Context().Value(usernameKey).(string); ok {
- return v
- }
- return ""
-}
diff --git a/internal/web/session_test.go b/internal/web/session_test.go
deleted file mode 100644
index 3644d26..0000000
--- a/internal/web/session_test.go
+++ /dev/null
@@ -1,84 +0,0 @@
-package web
-
-import (
- "path/filepath"
- "testing"
- "time"
-
- "github.com/mixeme/selfpost/internal/store"
-)
-
-func newTestSessionStore(t *testing.T) *sessionStore {
- t.Helper()
- st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
- if err != nil {
- t.Fatalf("open store: %v", err)
- }
- t.Cleanup(func() { st.Close() })
- return newSessionStore(st, 7*24*time.Hour)
-}
-
-func TestSessionRename(t *testing.T) {
- s := newTestSessionStore(t)
- token := s.Create("admin")
-
- s.Rename(token, "operator")
-
- name, ok := s.Lookup(token)
- if !ok {
- t.Fatal("session lost after rename")
- }
- if name != "operator" {
- t.Fatalf("session username = %q, want %q", name, "operator")
- }
-}
-
-// A password change must invalidate every other session (so a cookie captured
-// under the old password stops working) while keeping the one performing the
-// change signed in.
-func TestSessionDestroyOthers(t *testing.T) {
- s := newTestSessionStore(t)
- keep := s.Create("admin")
- other := s.Create("admin")
-
- s.DestroyOthers(keep)
-
- if _, ok := s.Lookup(keep); !ok {
- t.Fatal("current session was destroyed")
- }
- if _, ok := s.Lookup(other); ok {
- t.Fatal("other session survived")
- }
-}
-
-// A session past its sliding idle expiry must not be honoured.
-func TestSessionLookupRejectsExpired(t *testing.T) {
- s := newTestSessionStore(t)
- s.idle = -time.Minute // already expired the instant it's created
- token := s.Create("admin")
-
- if _, ok := s.Lookup(token); ok {
- t.Fatal("expired session was accepted")
- }
-}
-
-// Touch must not rewrite the expiry (or report a renewal) inside the
-// once-an-hour throttle window, so an active tab's polling doesn't turn into
-// a database write per request.
-func TestSessionTouchThrottled(t *testing.T) {
- s := newTestSessionStore(t)
- token := s.Create("admin")
-
- if s.Touch(token) {
- t.Fatal("touch renewed a session created moments ago")
- }
-
- // Back-date the session's last renewal by rewriting its expiry, as if it
- // had been created (or last renewed) 2 hours ago rather than moments ago.
- if err := s.store.RenewSession(hashToken(token), time.Now().Add(-2*time.Hour).Add(s.idle)); err != nil {
- t.Fatalf("renew session: %v", err)
- }
- if !s.Touch(token) {
- t.Fatal("touch did not renew a session past the throttle window")
- }
-}
diff --git a/internal/web/templates/account.html b/internal/web/templates/account.html
deleted file mode 100644
index 89a5162..0000000
--- a/internal/web/templates/account.html
+++ /dev/null
@@ -1,32 +0,0 @@
-{{define "content"}}
-
Settings
-
-{{if .Flash}}
{{.Flash}}
{{end}}
-
-
-
Panel credentials
-
These are the credentials for this control panel only.
- Applications keep their own logins and passwords, which are not affected.
- {{if .Error}}
{{.Error}}
{{end}}
-
-
Leave both new-password fields empty to change the username
- only. Changing the password signs out every other session; this one stays
- signed in.
-
-{{end}}
diff --git a/internal/web/validate.go b/internal/web/validate.go
deleted file mode 100644
index a53483b..0000000
--- a/internal/web/validate.go
+++ /dev/null
@@ -1,107 +0,0 @@
-package web
-
-import (
- "fmt"
- "strings"
- "unicode"
-)
-
-// minAdminPasswordLen is the floor for the administrator password. The panel is
-// public (security.md), so this is deliberately not tiny.
-const minAdminPasswordLen = 12
-
-const (
- minUsernameLen = 3
- maxUsernameLen = 64
-)
-
-// minSecretFilePasswordLen is the floor for the password protecting an
-// encrypted backup or domain export. Such a file is offline and can be attacked
-// at leisure, so the floor matches the administrator password's rather than the
-// weaker "any password is better than none".
-const minSecretFilePasswordLen = minAdminPasswordLen
-
-// validateUsername enforces a strict server-side whitelist (security.md):
-// letters, digits, dot, dash, underscore. Client validation is never trusted.
-func validateUsername(u string) error {
- if len(u) < minUsernameLen || len(u) > maxUsernameLen {
- return fmt.Errorf("username must be %d-%d characters", minUsernameLen, maxUsernameLen)
- }
- for _, r := range u {
- if r > unicode.MaxASCII || (!isASCIILetterOrDigit(r) && r != '.' && r != '-' && r != '_') {
- return fmt.Errorf("username may contain only letters, digits, '.', '-' and '_'")
- }
- }
- return nil
-}
-
-// validateAdminPassword enforces a minimum length. Composition rules beyond
-// length tend to reduce entropy in practice, so length is the sole gate.
-func validateAdminPassword(p string) error {
- if len(p) < minAdminPasswordLen {
- return fmt.Errorf("password must be at least %d characters", minAdminPasswordLen)
- }
- return nil
-}
-
-func isASCIILetterOrDigit(r rune) bool {
- return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
-}
-
-const maxDomainLen = 253 // RFC 1035 limit on a fully-qualified name
-
-// normalizeDomain lower-cases and trims a domain name. Domain names are
-// case-insensitive, and the generated OpenDKIM tables/keys use the canonical
-// lower-case form, so we normalise before both validation and storage.
-func normalizeDomain(name string) string {
- return strings.ToLower(strings.TrimSpace(name))
-}
-
-// validateDomain enforces a strict server-side whitelist for sending-domain
-// names (security.md). The result is safe to write verbatim into the OpenDKIM
-// KeyTable/SigningTable and to use as a filesystem path segment: only
-// lower-case letters, digits, '.' and '-' are allowed, in valid DNS label
-// shape. Input must already be normalised with normalizeDomain.
-//
-// This is deliberately stricter than "any string DNS might accept" — no
-// leading/trailing dots or hyphens, no empty or over-long labels, and at least
-// two labels so single-word hostnames cannot be registered as sending domains.
-func validateDomain(name string) error {
- if name == "" {
- return fmt.Errorf("domain is required")
- }
- if len(name) > maxDomainLen {
- return fmt.Errorf("domain must be at most %d characters", maxDomainLen)
- }
- labels := strings.Split(name, ".")
- if len(labels) < 2 {
- return fmt.Errorf("domain must include at least one dot (e.g. example.com)")
- }
- for _, label := range labels {
- if err := validateDomainLabel(label); err != nil {
- return err
- }
- }
- return nil
-}
-
-func validateDomainLabel(label string) error {
- if len(label) == 0 {
- return fmt.Errorf("domain must not contain an empty label")
- }
- if len(label) > 63 {
- return fmt.Errorf("each domain label must be at most 63 characters")
- }
- if label[0] == '-' || label[len(label)-1] == '-' {
- return fmt.Errorf("domain labels must not start or end with '-'")
- }
- for i := 0; i < len(label); i++ {
- c := label[i]
- lower := c >= 'a' && c <= 'z'
- digit := c >= '0' && c <= '9'
- if !lower && !digit && c != '-' {
- return fmt.Errorf("domain may contain only lower-case letters, digits, '.' and '-'")
- }
- }
- return nil
-}
diff --git a/internal/web/validate/validate.go b/internal/web/validate/validate.go
new file mode 100644
index 0000000..6ba5cde
--- /dev/null
+++ b/internal/web/validate/validate.go
@@ -0,0 +1,148 @@
+// Package validate holds shared server-side form validation for the panel.
+package validate
+
+import (
+ "fmt"
+ "strings"
+ "unicode"
+)
+
+// MinAdminPasswordLen is the floor for the administrator password. The panel is
+// public (security.md), so this is deliberately not tiny.
+const MinAdminPasswordLen = 12
+
+const (
+ minUsernameLen = 3
+ maxUsernameLen = 64
+)
+
+// MinSecretFilePasswordLen is the floor for the password protecting an
+// encrypted backup or domain export. Such a file is offline and can be attacked
+// at leisure, so the floor matches the administrator password's rather than the
+// weaker "any password is better than none".
+const MinSecretFilePasswordLen = MinAdminPasswordLen
+
+// Username enforces a strict server-side whitelist (security.md): letters,
+// digits, dot, dash, underscore. Client validation is never trusted.
+func Username(u string) error {
+ if len(u) < minUsernameLen || len(u) > maxUsernameLen {
+ return fmt.Errorf("username must be %d-%d characters", minUsernameLen, maxUsernameLen)
+ }
+ for _, r := range u {
+ if r > unicode.MaxASCII || (!isASCIILetterOrDigit(r) && r != '.' && r != '-' && r != '_') {
+ return fmt.Errorf("username may contain only letters, digits, '.', '-' and '_'")
+ }
+ }
+ return nil
+}
+
+// AdminPassword enforces a minimum length. Composition rules beyond length tend
+// to reduce entropy in practice, so length is the sole gate.
+func AdminPassword(p string) error {
+ if len(p) < MinAdminPasswordLen {
+ return fmt.Errorf("password must be at least %d characters", MinAdminPasswordLen)
+ }
+ return nil
+}
+
+func isASCIILetterOrDigit(r rune) bool {
+ return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
+}
+
+const maxDomainLen = 253 // RFC 1035 limit on a fully-qualified name
+
+// NormalizeDomain lower-cases and trims a domain name. Domain names are
+// case-insensitive, and the generated OpenDKIM tables/keys use the canonical
+// lower-case form, so we normalise before both validation and storage.
+func NormalizeDomain(name string) string {
+ return strings.ToLower(strings.TrimSpace(name))
+}
+
+// Domain enforces a strict server-side whitelist for sending-domain names
+// (security.md). The result is safe to write verbatim into the OpenDKIM
+// KeyTable/SigningTable and to use as a filesystem path segment: only
+// lower-case letters, digits, '.' and '-' are allowed, in valid DNS label
+// shape. Input must already be normalised with NormalizeDomain.
+func Domain(name string) error {
+ if name == "" {
+ return fmt.Errorf("domain is required")
+ }
+ if len(name) > maxDomainLen {
+ return fmt.Errorf("domain must be at most %d characters", maxDomainLen)
+ }
+ labels := strings.Split(name, ".")
+ if len(labels) < 2 {
+ return fmt.Errorf("domain must include at least one dot (e.g. example.com)")
+ }
+ for _, label := range labels {
+ if err := domainLabel(label); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func domainLabel(label string) error {
+ if len(label) == 0 {
+ return fmt.Errorf("domain must not contain an empty label")
+ }
+ if len(label) > 63 {
+ return fmt.Errorf("each domain label must be at most 63 characters")
+ }
+ if label[0] == '-' || label[len(label)-1] == '-' {
+ return fmt.Errorf("domain labels must not start or end with '-'")
+ }
+ for i := 0; i < len(label); i++ {
+ c := label[i]
+ lower := c >= 'a' && c <= 'z'
+ digit := c >= '0' && c <= '9'
+ if !lower && !digit && c != '-' {
+ return fmt.Errorf("domain may contain only lower-case letters, digits, '.' and '-'")
+ }
+ }
+ return nil
+}
+
+// freemailDomains lists public mail hosts that cannot publish _report._dmarc
+// authorisation for third-party sending domains.
+var freemailDomains = map[string]struct{}{
+ "gmail.com": {},
+ "googlemail.com": {},
+ "outlook.com": {},
+ "hotmail.com": {},
+ "live.com": {},
+ "yahoo.com": {},
+ "icloud.com": {},
+ "me.com": {},
+ "proton.me": {},
+ "protonmail.com": {},
+}
+
+// Email checks a DMARC rua= mailbox. Empty is allowed (policy-only).
+func Email(addr string) error {
+ addr = strings.TrimSpace(addr)
+ if addr == "" {
+ return nil
+ }
+ at := strings.LastIndex(addr, "@")
+ if at <= 0 || at >= len(addr)-1 {
+ return fmt.Errorf("enter a valid email address")
+ }
+ local := addr[:at]
+ domain := NormalizeDomain(addr[at+1:])
+ if err := Domain(domain); err != nil {
+ return fmt.Errorf("email domain is invalid: %w", err)
+ }
+ for _, r := range local {
+ if r > unicode.MaxASCII {
+ return fmt.Errorf("email address must be ASCII")
+ }
+ if !isASCIILetterOrDigit(r) && r != '.' && r != '-' && r != '_' && r != '+' {
+ return fmt.Errorf("email address contains invalid characters")
+ }
+ }
+ if _, blocked := freemailDomains[domain]; blocked {
+ return fmt.Errorf("use an address on a domain you control; public mail hosts cannot receive authorised DMARC reports")
+ }
+ return nil
+}
diff --git a/internal/web/validate/validate_email_test.go b/internal/web/validate/validate_email_test.go
new file mode 100644
index 0000000..9e09be0
--- /dev/null
+++ b/internal/web/validate/validate_email_test.go
@@ -0,0 +1,18 @@
+package validate
+
+import "testing"
+
+func TestValidateEmail(t *testing.T) {
+ if err := Email(""); err != nil {
+ t.Errorf("empty: %v", err)
+ }
+ if err := Email("reports@mail.example.com"); err != nil {
+ t.Errorf("valid: %v", err)
+ }
+ if err := Email("bad"); err == nil {
+ t.Error("bad address accepted")
+ }
+ if err := Email("x@gmail.com"); err == nil {
+ t.Error("gmail accepted")
+ }
+}
diff --git a/internal/web/validate/validate_test.go b/internal/web/validate/validate_test.go
new file mode 100644
index 0000000..6a20bf4
--- /dev/null
+++ b/internal/web/validate/validate_test.go
@@ -0,0 +1,67 @@
+package validate
+
+import "testing"
+
+func TestNormalizeDomain(t *testing.T) {
+ cases := map[string]string{
+ " Example.COM ": "example.com",
+ "MAIL.Example.Org": "mail.example.org",
+ "example.com": "example.com",
+ }
+ for in, want := range cases {
+ if got := NormalizeDomain(in); got != want {
+ t.Errorf("NormalizeDomain(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+func TestValidateDomainValid(t *testing.T) {
+ valid := []string{
+ "example.com",
+ "mail.example.com",
+ "a.co",
+ "sub-domain.example.co.uk",
+ "x1.y2.z3",
+ "1example.com",
+ }
+ for _, d := range valid {
+ if err := Domain(d); err != nil {
+ t.Errorf("Domain(%q) unexpected error: %v", d, err)
+ }
+ }
+}
+
+func TestValidateDomainInvalid(t *testing.T) {
+ invalid := []string{
+ "",
+ "localhost",
+ "example",
+ ".example.com",
+ "example.com.",
+ "exa mple.com",
+ "example..com",
+ "-example.com",
+ "example-.com",
+ "example.com\n",
+ "exa*mple.com",
+ "exa_mple.com",
+ "Example.com",
+ "пример.рф",
+ "example.c/m",
+ }
+ for _, d := range invalid {
+ if err := Domain(d); err == nil {
+ t.Errorf("Domain(%q) = nil, want error", d)
+ }
+ }
+}
+
+func TestValidateDomainLongLabelRejected(t *testing.T) {
+ label := make([]byte, 64)
+ for i := range label {
+ label[i] = 'a'
+ }
+ if err := Domain(string(label) + ".com"); err == nil {
+ t.Error("expected error for over-long label")
+ }
+}
diff --git a/internal/web/validate_test.go b/internal/web/validate_test.go
deleted file mode 100644
index b7556e4..0000000
--- a/internal/web/validate_test.go
+++ /dev/null
@@ -1,67 +0,0 @@
-package web
-
-import "testing"
-
-func TestNormalizeDomain(t *testing.T) {
- cases := map[string]string{
- " Example.COM ": "example.com",
- "MAIL.Example.Org": "mail.example.org",
- "example.com": "example.com",
- }
- for in, want := range cases {
- if got := normalizeDomain(in); got != want {
- t.Errorf("normalizeDomain(%q) = %q, want %q", in, got, want)
- }
- }
-}
-
-func TestValidateDomainValid(t *testing.T) {
- valid := []string{
- "example.com",
- "mail.example.com",
- "a.co",
- "sub-domain.example.co.uk",
- "x1.y2.z3",
- "1example.com",
- }
- for _, d := range valid {
- if err := validateDomain(d); err != nil {
- t.Errorf("validateDomain(%q) unexpected error: %v", d, err)
- }
- }
-}
-
-func TestValidateDomainInvalid(t *testing.T) {
- invalid := []string{
- "", // empty
- "localhost", // single label
- "example", // single label
- ".example.com", // leading dot -> empty label
- "example.com.", // trailing dot -> empty label
- "exa mple.com", // space
- "example..com", // empty label
- "-example.com", // label starts with '-'
- "example-.com", // label ends with '-'
- "example.com\n", // newline (config injection attempt)
- "exa*mple.com", // disallowed char
- "exa_mple.com", // underscore not allowed in domains
- "Example.com", // upper-case (must be normalised first)
- "пример.рф", // non-ASCII
- "example.c/m", // slash (path-traversal attempt)
- }
- for _, d := range invalid {
- if err := validateDomain(d); err == nil {
- t.Errorf("validateDomain(%q) = nil, want error", d)
- }
- }
-}
-
-func TestValidateDomainLongLabelRejected(t *testing.T) {
- label := make([]byte, 64)
- for i := range label {
- label[i] = 'a'
- }
- if err := validateDomain(string(label) + ".com"); err == nil {
- t.Error("expected error for over-long label")
- }
-}
diff --git a/internal/web/static.go b/internal/web/view/static.go
similarity index 94%
rename from internal/web/static.go
rename to internal/web/view/static.go
index d4b70d5..ba798b8 100644
--- a/internal/web/static.go
+++ b/internal/web/view/static.go
@@ -1,4 +1,4 @@
-package web
+package view
import (
"crypto/sha256"
@@ -39,7 +39,7 @@ func buildStaticETags() map[string]string {
return etags
}
-// staticHandler serves the embedded assets under /static/ with a content ETag.
+// StaticHandler serves the embedded assets under /static/ with a content ETag.
//
// Cache-Control is no-cache rather than a max-age: it lets the browser keep the
// copy but requires it to revalidate, so an asset that changed is picked up on
@@ -47,7 +47,7 @@ func buildStaticETags() map[string]string {
// handful of small files on a single-operator panel that trade is the right way
// round — correctness after a deploy matters more than saving the round trip.
// http.ServeContent answers the conditional request from the ETag we set here.
-func staticHandler() http.Handler {
+func StaticHandler() http.Handler {
files := http.FileServer(http.FS(assetsFS))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if etag, ok := staticETags[path.Clean(r.URL.Path)]; ok {
diff --git a/internal/web/static/favicon.png b/internal/web/view/static/favicon.png
similarity index 100%
rename from internal/web/static/favicon.png
rename to internal/web/view/static/favicon.png
diff --git a/internal/web/static/favicon.svg b/internal/web/view/static/favicon.svg
similarity index 100%
rename from internal/web/static/favicon.svg
rename to internal/web/view/static/favicon.svg
diff --git a/internal/web/static/htmx.min.js b/internal/web/view/static/htmx.min.js
similarity index 100%
rename from internal/web/static/htmx.min.js
rename to internal/web/view/static/htmx.min.js
diff --git a/internal/web/static/logo-compact.svg b/internal/web/view/static/logo-compact.svg
similarity index 100%
rename from internal/web/static/logo-compact.svg
rename to internal/web/view/static/logo-compact.svg
diff --git a/internal/web/static/logo.svg b/internal/web/view/static/logo.svg
similarity index 100%
rename from internal/web/static/logo.svg
rename to internal/web/view/static/logo.svg
diff --git a/internal/web/static/panel.css b/internal/web/view/static/panel.css
similarity index 98%
rename from internal/web/static/panel.css
rename to internal/web/view/static/panel.css
index bddd573..e2e155c 100644
--- a/internal/web/static/panel.css
+++ b/internal/web/view/static/panel.css
@@ -176,7 +176,12 @@ h2 { font-size: 1.05rem; margin: 0 0 0.4rem; }
.back { display: block; margin-bottom: 1rem; }
/* Build version, closing every authenticated page. Quiet on purpose: it is
reference material, not something to read on the way past. */
-.version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: #6b7280; }
+.version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: #6b7280; line-height: 1.45; }
+.version a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
+.version a:hover { color: var(--fg); }
+/* The signed-out column is 24rem; a right-aligned multi-link notice wraps
+ into a ragged edge, so centre it there. */
+main.page-login .version, main.page-setup .version { text-align: center; }
select, textarea {
width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem;
border: 1px solid var(--control-border); border-radius: 6px; background: var(--input-bg); color: inherit;
diff --git a/internal/web/static/panel.js b/internal/web/view/static/panel.js
similarity index 100%
rename from internal/web/static/panel.js
rename to internal/web/view/static/panel.js
diff --git a/internal/web/static_test.go b/internal/web/view/static_test.go
similarity index 98%
rename from internal/web/static_test.go
rename to internal/web/view/static_test.go
index 17b71f2..3954af0 100644
--- a/internal/web/static_test.go
+++ b/internal/web/view/static_test.go
@@ -1,4 +1,4 @@
-package web
+package view
import (
"net/http"
@@ -13,7 +13,7 @@ func serveStatic(path string, headers map[string]string) *httptest.ResponseRecor
r.Header.Set(k, v)
}
rec := httptest.NewRecorder()
- staticHandler().ServeHTTP(rec, r)
+ StaticHandler().ServeHTTP(rec, r)
return rec
}
diff --git a/internal/web/view/templates/account.html b/internal/web/view/templates/account.html
new file mode 100644
index 0000000..e515568
--- /dev/null
+++ b/internal/web/view/templates/account.html
@@ -0,0 +1,68 @@
+{{define "content"}}
+
Settings
+
+{{if .Flash}}
{{.Flash}}
{{end}}
+
+
+
Panel credentials
+
These are the credentials for this control panel only.
+ Applications keep their own logins and passwords, which are not affected.
+ {{if .Error}}
{{.Error}}
{{end}}
+
+
Leave both new-password fields empty to change the username
+ or DMARC address only. Changing the password signs out every other session;
+ this one stays signed in.
+
+{{end}}
diff --git a/internal/web/templates/backup.html b/internal/web/view/templates/backup.html
similarity index 100%
rename from internal/web/templates/backup.html
rename to internal/web/view/templates/backup.html
diff --git a/internal/web/templates/dashboard.html b/internal/web/view/templates/dashboard.html
similarity index 100%
rename from internal/web/templates/dashboard.html
rename to internal/web/view/templates/dashboard.html
diff --git a/internal/web/templates/deliveries.html b/internal/web/view/templates/deliveries.html
similarity index 100%
rename from internal/web/templates/deliveries.html
rename to internal/web/view/templates/deliveries.html
diff --git a/internal/web/templates/deliveries_rows.html b/internal/web/view/templates/deliveries_rows.html
similarity index 100%
rename from internal/web/templates/deliveries_rows.html
rename to internal/web/view/templates/deliveries_rows.html
diff --git a/internal/web/templates/delivery.html b/internal/web/view/templates/delivery.html
similarity index 100%
rename from internal/web/templates/delivery.html
rename to internal/web/view/templates/delivery.html
diff --git a/internal/web/templates/domain_delete.html b/internal/web/view/templates/domain_delete.html
similarity index 100%
rename from internal/web/templates/domain_delete.html
rename to internal/web/view/templates/domain_delete.html
diff --git a/internal/web/templates/domain_detail.html b/internal/web/view/templates/domain_detail.html
similarity index 84%
rename from internal/web/templates/domain_detail.html
rename to internal/web/view/templates/domain_detail.html
index 93a7c0a..f9ec373 100644
--- a/internal/web/templates/domain_detail.html
+++ b/internal/web/view/templates/domain_detail.html
@@ -51,8 +51,10 @@
SPF and DMARC records
These two are not generated the way the DKIM record above is —
they are policy, and the domain may already publish an SPF record for other
- senders. What follows is what this server expects to see and what the checks
- below look for. Publish both as TXT records.
+ senders. SelfPost is a send-only relay — most operators have no inbox on the
+ sending domain, so the suggested DMARC record omits rua= unless a
+ report address is configured below or in Settings.
+ Publish both as TXT records.
@@ -68,9 +70,23 @@
A domain may have only one SPF record. If it already has one,
do not add a second — add this server's mechanism to the existing record
- instead, before its all term. -all at the end means
- nothing else may send as the domain; use ~all while other senders
- are still being sorted out.
+ instead, before its all term.
+
+
@@ -78,17 +94,36 @@
-
+
{{.DMARCExample}}
-
Point rua= at a mailbox that is actually read —
- the aggregate reports are how you find out who else sends as the domain.
- p=none changes nothing about delivery, so it is safe to publish
- straight away; tighten it to p=quarantine and then
- p=reject once the reports come back clean.
+ {{if .SameDomainRUA}}
+
The report address is on this sending domain. SelfPost does
+ not receive inbound mail — use a mailbox elsewhere or wait for in-panel report
+ reception in a future release.
+ {{end}}
+
+ {{if .NeedsReportAuth}}
+
+
+ {{.ReportAuthName}}
+
+
+
+
+
+ {{.ReportAuthValue}}
+
+
+ {{end}}
+
+
p=none changes nothing about delivery. Omit
+ rua= on a send-only domain, or point it at a mailbox that
+ receives inbound mail. Tighten to p=quarantine and then
+ p=reject once aggregate reports look clean.
+ {{if .DNS.DMARCReportAuth.Records}}{{range .DNS.DMARCReportAuth.Records}}{{.}}
+{{end}}{{end}}
+ {{end}}
+
diff --git a/internal/web/templates/encrypt_fields.html b/internal/web/view/templates/encrypt_fields.html
similarity index 100%
rename from internal/web/templates/encrypt_fields.html
rename to internal/web/view/templates/encrypt_fields.html
diff --git a/internal/web/templates/layout.html b/internal/web/view/templates/layout.html
similarity index 93%
rename from internal/web/templates/layout.html
rename to internal/web/view/templates/layout.html
index 8cdaed5..d1d868e 100644
--- a/internal/web/templates/layout.html
+++ b/internal/web/view/templates/layout.html
@@ -37,11 +37,17 @@
tell the send log from a single delivery's page. */}}
{{template "content" .}}
-{{/* The running version, on every authenticated page: it is what a backup
- manifest is checked against on restore and the first thing to establish
- when something behaves unexpectedly. Only for signed-in administrators —
- the login and setup pages must not advertise it to the internet. */}}
-{{if .User}}{{end}}
+{{/* Appropriate Legal Notices (AGPL-3.0): copyright, how to read the licence,
+ where the Corresponding Source is, and that there is no warranty. Shown
+ on every page, including login/setup — those are interactive UIs too.
+ The running version stays signed-in only: it is what a backup manifest is
+ checked against on restore, and must not be advertised to the internet. */}}
+