diff --git a/CHANGELOG.md b/CHANGELOG.md index 31fb9e4..9929f99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,39 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +## [0.3.0] - 2026-08-03 + +### Fixed + +- panel: the PTR (reverse DNS) check no longer reports a correctly published + record as wrong. The checks went through the container's own resolver, which + forwards to the host's systemd-resolved — and systemd-resolved answers the + reverse lookup of the machine's own IP from the local hostname instead of + asking public DNS. A server with `203.0.113.10 → selfpost.example.com` in DNS + was told its PTR pointed at the provider-assigned hostname. All four + deliverability checks (PTR, SPF, DKIM, DMARC) now query recursive resolvers + directly, so the panel reports what a receiving mail server actually sees. + Set `SELFPOST_DNS_RESOLVERS` if outbound port 53 is closed or you run your + own recursor; it defaults to 1.1.1.1, 8.8.8.8 and 9.9.9.9. + +### Changed + +- panel: the three monitoring pages now live at URLs that match their nav + labels — Deliveries at `/deliveries` (was `/sendlog`), Mail queue at + `/mail-queue` (was `/queue`), System log at `/system-log` (was `/logtail`). + Bookmarks to the old paths stop working. + +- panel: each entry in the navigation bar now carries an icon beside its label, + so the bar is scannable at a glance instead of a row of similar-length words. + The icons are inline SVG drawn in the entry's own colour — no extra request, + no exemption from the panel's Content-Security-Policy — and are hidden from + screen readers, which still announce the label alone. + +- panel: the navigation bar is laid out as two rows on purpose — the signed-in + user, Account and Sign out along the top right, the page entries below. It no + longer fits on one line and used to wrap on its own, which left the session + block sitting left-aligned under the entries as if it were more navigation. + ## [0.2.0] - 2026-08-03 - panel: every authenticated page now ends with the running version diff --git a/cmd/panel/httpserver.go b/cmd/panel/httpserver.go index 72ca803..8a1e6b8 100644 --- a/cmd/panel/httpserver.go +++ b/cmd/panel/httpserver.go @@ -38,6 +38,7 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error { OpenDKIMSocket: cfg.opendkimSocket, JournalSocket: cfg.journalSocket, SessionIdleDays: cfg.sessionIdleDays, + DNSResolvers: cfg.dnsResolvers, }, cfg.setupTokenPath) if err != nil { return err diff --git a/cmd/panel/main.go b/cmd/panel/main.go index 6d6daa2..8c11635 100644 --- a/cmd/panel/main.go +++ b/cmd/panel/main.go @@ -23,6 +23,7 @@ import ( "codeberg.org/mix/selfpost/internal/backup" "codeberg.org/mix/selfpost/internal/buildinfo" + "codeberg.org/mix/selfpost/internal/dnscheck" "codeberg.org/mix/selfpost/internal/logtail" "codeberg.org/mix/selfpost/internal/store" ) @@ -61,6 +62,7 @@ type config struct { submissionEnabled bool trustedProxies []*net.IPNet sessionIdleDays int + dnsResolvers []string // Read-only inputs to the panel's status page: the certificate Postfix // serves and the two milter sockets it connects to. The defaults mirror @@ -107,6 +109,9 @@ func loadConfig() config { // Sliding session idle timeout (spec 7.6.6, plan B.1). Non-positive/invalid // falls back to the 7-day default inside internal/web. sessionIdleDays: envInt("PANEL_SESSION_IDLE_DAYS", 7), + // Recursive resolvers the deliverability checks query directly. Empty + // means dnscheck's public defaults; a closed network names its own here. + dnsResolvers: dnscheck.ParseResolvers(os.Getenv("SELFPOST_DNS_RESOLVERS")), tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"), opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"), diff --git a/deploy/.env.example b/deploy/.env.example index de7dadb..4e045b8 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -22,6 +22,15 @@ SEND_LOG_RETENTION_DAYS=90 # monitoring screens does not count as activity, only navigation/actions do. PANEL_SESSION_IDLE_DAYS=7 +# Comma-separated resolvers the panel's deliverability checks (PTR, SPF, DKIM, +# DMARC) query directly. They deliberately skip this host's own resolver: with +# systemd-resolved the reverse lookup of the server's IP answers with the local +# hostname instead of the PTR record published in public DNS, which made the +# panel report a correct PTR as wrong. Leave unset for the public defaults +# (1.1.1.1, 8.8.8.8, 9.9.9.9); set it if outbound port 53 is closed or you run +# your own recursor, e.g. SELFPOST_DNS_RESOLVERS=10.0.0.2:53 +# SELFPOST_DNS_RESOLVERS= + # Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to # supply X-Forwarded-For for login/setup rate-limiting. Leave unset unless # you know the exact address of your reverse proxy — trusting the wrong diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 03ff977..1f547a0 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -41,6 +41,10 @@ services: SEND_LOG_RETENTION_DAYS: "${SEND_LOG_RETENTION_DAYS:-90}" # Sliding idle timeout for the panel login session, in days (spec 7.6.6). PANEL_SESSION_IDLE_DAYS: "${PANEL_SESSION_IDLE_DAYS:-7}" + # Resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly, so they + # report what the internet sees rather than what this host's own stub + # resolver synthesises. Empty uses public defaults. + SELFPOST_DNS_RESOLVERS: "${SELFPOST_DNS_RESOLVERS:-}" volumes: # All persistent state lives under /data (spec 9): SQLite DB, DKIM keys, # sasldb2, sender map, setup token. Back this up (panel button or the diff --git a/docs/documentation-plan.md b/docs/documentation-plan.md index 8c5a858..257714e 100644 --- a/docs/documentation-plan.md +++ b/docs/documentation-plan.md @@ -77,8 +77,8 @@ 1. **Нет раздела «эксплуатация»** — прямое требование ТЗ 11 п. 7 ([specification.md:427](specification.md)). В README нет ни слова про - `/status` (проверки PTR/hostname), `/sendlog` (журнал отправки, фильтры), - `/queue` (очередь Postfix), `/logtail` (хвост `mail.log`), `/reload`, + `/status` (проверки PTR/hostname), `/deliveries` (журнал отправки, фильтры), + `/mail-queue` (очередь Postfix), `/system-log` (хвост `mail.log`), `/reload`, `/account`, `/backup` — при том что всё это реализовано ([internal/web/web.go:152](../internal/web/web.go:152)–188). Не описана и процедура апгрейда (бамп тега → `docker compose up -d`), хотя раздел diff --git a/internal/dnscheck/dnscheck.go b/internal/dnscheck/dnscheck.go index 4fb4184..87e945c 100644 --- a/internal/dnscheck/dnscheck.go +++ b/internal/dnscheck/dnscheck.go @@ -103,10 +103,11 @@ type cached[T any] struct { expires time.Time } -// New returns a Checker using the process resolver and the package's default -// timeout and cache lifetimes. -func New() *Checker { - return newChecker(net.DefaultResolver, lookupTimeout, serverTTL, domainTTL) +// New returns a Checker querying the given recursive resolvers (empty means +// DefaultResolvers) with the package's default timeout and cache lifetimes. +// The lookups deliberately bypass the system resolver — see externalResolver. +func New(resolvers []string) *Checker { + return newChecker(newExternalResolver(resolvers), lookupTimeout, serverTTL, domainTTL) } func newChecker(r resolver, timeout, srvTTL, domTTL time.Duration) *Checker { diff --git a/internal/dnscheck/resolver.go b/internal/dnscheck/resolver.go new file mode 100644 index 0000000..3c8f358 --- /dev/null +++ b/internal/dnscheck/resolver.go @@ -0,0 +1,112 @@ +package dnscheck + +import ( + "context" + "errors" + "net" + "strings" +) + +// DefaultResolvers are the recursive resolvers the checks query when the +// deployment does not name its own (SELFPOST_DNS_RESOLVERS). Three independent +// operators, so one being unreachable from the host does not blind the checks. +var DefaultResolvers = []string{"1.1.1.1:53", "8.8.8.8:53", "9.9.9.9:53"} + +// externalResolver talks to a fixed list of recursive resolvers directly +// instead of going through the system resolver. +// +// That detour is deliberate: these checks answer "what does a receiving mail +// server see about us?", and the machine's own stub resolver is the one place +// where the answer differs. systemd-resolved — which a Docker container reaches +// through the embedded 127.0.0.11 forwarder — synthesises a PTR record for the +// host's own addresses out of the local hostname, never asking public DNS. A +// server whose reverse DNS was published correctly therefore had its +// provider-assigned hostname reported back to the panel, and the FCrDNS check +// failed a record that was in fact right. +// +// This bypasses the resolvers in /etc/resolv.conf, not /etc/hosts: Go still +// consults the hosts file first, as any local program would. +type externalResolver struct { + servers []*net.Resolver +} + +// newExternalResolver builds a resolver over addrs ("host" or "host:port"). An +// empty list falls back to DefaultResolvers. +func newExternalResolver(addrs []string) *externalResolver { + if len(addrs) == 0 { + addrs = DefaultResolvers + } + e := &externalResolver{} + for _, a := range addrs { + addr := withDefaultPort(a) + e.servers = append(e.servers, &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, addr) + }, + }) + } + return e +} + +func (e *externalResolver) LookupTXT(ctx context.Context, name string) ([]string, error) { + return queryEach(e.servers, func(r *net.Resolver) ([]string, error) { return r.LookupTXT(ctx, name) }) +} + +func (e *externalResolver) LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) { + return queryEach(e.servers, func(r *net.Resolver) ([]net.IPAddr, error) { return r.LookupIPAddr(ctx, host) }) +} + +func (e *externalResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) { + return queryEach(e.servers, func(r *net.Resolver) ([]string, error) { return r.LookupAddr(ctx, addr) }) +} + +func (e *externalResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) { + return queryEach(e.servers, func(r *net.Resolver) ([]*net.MX, error) { return r.LookupMX(ctx, name) }) +} + +// queryEach asks each resolver in turn and stops at the first one that +// answers. "No such name" is an answer — only a resolver that cannot be +// reached, or that times out, moves the query on to the next one. +func queryEach[T any](servers []*net.Resolver, ask func(*net.Resolver) (T, error)) (T, error) { + var zero T + var lastErr error + for _, r := range servers { + v, err := ask(r) + if err == nil { + return v, nil + } + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) && dnsErr.IsNotFound { + return zero, err + } + lastErr = err + } + if lastErr == nil { + lastErr = errors.New("no DNS resolver configured") + } + return zero, lastErr +} + +// withDefaultPort appends the DNS port to a bare address, so the environment +// variable can name a resolver as plainly as "1.1.1.1". +func withDefaultPort(addr string) string { + if _, _, err := net.SplitHostPort(addr); err == nil { + return addr + } + return net.JoinHostPort(addr, "53") +} + +// ParseResolvers reads a comma-separated resolver list, as it arrives from the +// environment. Blank entries are skipped; an empty result means "use +// DefaultResolvers". +func ParseResolvers(s string) []string { + var out []string + for _, part := range strings.Split(s, ",") { + if p := strings.TrimSpace(part); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/internal/dnscheck/resolver_test.go b/internal/dnscheck/resolver_test.go new file mode 100644 index 0000000..83a913f --- /dev/null +++ b/internal/dnscheck/resolver_test.go @@ -0,0 +1,100 @@ +package dnscheck + +import ( + "context" + "errors" + "net" + "testing" +) + +func TestWithDefaultPort(t *testing.T) { + cases := map[string]string{ + "1.1.1.1": "1.1.1.1:53", + "1.1.1.1:5353": "1.1.1.1:5353", + "dns.example.com": "dns.example.com:53", + "2606:4700:4700::": "[2606:4700:4700::]:53", + "[::1]:5353": "[::1]:5353", + } + for in, want := range cases { + if got := withDefaultPort(in); got != want { + t.Errorf("withDefaultPort(%q) = %q, want %q", in, got, want) + } + } +} + +func TestParseResolvers(t *testing.T) { + got := ParseResolvers(" 1.1.1.1 , ,8.8.8.8:53,") + want := []string{"1.1.1.1", "8.8.8.8:53"} + if len(got) != len(want) { + t.Fatalf("got %q, want %q", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %q, want %q", got, want) + } + } + if r := ParseResolvers(""); len(r) != 0 { + t.Errorf("empty string parsed to %q, want nothing", r) + } +} + +func TestNewExternalResolverFallsBackToDefaults(t *testing.T) { + if n := len(newExternalResolver(nil).servers); n != len(DefaultResolvers) { + t.Errorf("resolver count = %d, want %d (the defaults)", n, len(DefaultResolvers)) + } +} + +// The checks must reach the second resolver when the first is unreachable, but +// must not second-guess an authoritative "no such name" — otherwise a domain +// that genuinely lacks a record costs one timeout per configured resolver. +func TestQueryEachTriesTheNextResolverOnlyOnFailure(t *testing.T) { + servers := []*net.Resolver{{}, {}, {}} + + asked := 0 + got, err := queryEach(servers, func(*net.Resolver) (string, error) { + asked++ + if asked < 3 { + return "", &net.DNSError{Err: "timed out", IsTimeout: true} + } + return "answer", nil + }) + if err != nil || got != "answer" { + t.Fatalf("got (%q, %v), want (\"answer\", nil)", got, err) + } + if asked != 3 { + t.Errorf("asked %d resolvers, want 3", asked) + } + + asked = 0 + _, err = queryEach(servers, func(*net.Resolver) (string, error) { + asked++ + return "", notFound("absent.example") + }) + var dnsErr *net.DNSError + if !errors.As(err, &dnsErr) || !dnsErr.IsNotFound { + t.Fatalf("err = %v, want a not-found DNSError", err) + } + if asked != 1 { + t.Errorf("NXDOMAIN asked %d resolvers, want 1 — it is an answer, not a failure", asked) + } +} + +// The reason this package does not use net.DefaultResolver: systemd-resolved +// answers PTR queries for the machine's own addresses out of the local +// hostname, which is not what the rest of the internet sees. The dial hook must +// therefore ignore the address the standard resolver picked from +// /etc/resolv.conf and connect to the configured one. +func TestExternalResolverDialsOnlyTheConfiguredAddress(t *testing.T) { + e := newExternalResolver([]string{"192.0.2.53"}) + if len(e.servers) != 1 { + t.Fatalf("servers = %d, want 1", len(e.servers)) + } + conn, err := e.servers[0].Dial(context.Background(), "udp", "127.0.0.53:53") + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + if got := conn.RemoteAddr().String(); got != "192.0.2.53:53" { + t.Errorf("connected to %q, want 192.0.2.53:53 (the system resolver won)", got) + } +} diff --git a/internal/web/handlers_monitor.go b/internal/web/handlers_monitor.go index 8cf3578..3708a0d 100644 --- a/internal/web/handlers_monitor.go +++ b/internal/web/handlers_monitor.go @@ -19,33 +19,34 @@ const ( logTailLines = 200 ) -// handleSendLog renders the send-log monitoring page: server-side filters by -// domain/application and pagination (spec 7.3.3). The row table itself is the -// "sendlog_rows" fragment, shared verbatim with handleSendLogRows so the -// initial page and its HTMX-polled refreshes never diverge. -func (s *Server) handleSendLog(w http.ResponseWriter, r *http.Request) { +// handleDeliveries renders the Deliveries page over the send log: server-side +// filters by domain/application and pagination (spec 7.3.3). The row table +// itself is the "deliveries_rows" fragment, shared verbatim with +// handleDeliveriesRows so the initial page and its HTMX-polled refreshes never +// diverge. +func (s *Server) handleDeliveries(w http.ResponseWriter, r *http.Request) { data, err := s.sendLogData(r) if err != nil { logf("panel: send log: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } - data["Title"] = "SelfPost — send log" + data["Title"] = "SelfPost — deliveries" data["User"] = currentUser(r) - data["Active"] = "sendlog" - s.render(w, http.StatusOK, "sendlog", data) + data["Active"] = "deliveries" + s.render(w, http.StatusOK, "deliveries", data) } -// handleSendLogRows serves the HTMX polling fragment for the send-log table +// handleDeliveriesRows serves the HTMX polling fragment for the delivery table // (spec 7.1: fragment endpoints return HTML, not JSON). -func (s *Server) handleSendLogRows(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleDeliveriesRows(w http.ResponseWriter, r *http.Request) { data, err := s.sendLogData(r) if err != nil { logf("panel: send log rows: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } - s.renderFragment(w, http.StatusOK, "sendlog_rows", data) + s.renderFragment(w, http.StatusOK, "deliveries_rows", data) } // sendLogData reads the domain/app filters and page number off the query @@ -109,22 +110,22 @@ func parsePage(v string) int { return n } -// handleQueue renders the mail-queue monitoring page (spec 7.2.11). -func (s *Server) handleQueue(w http.ResponseWriter, r *http.Request) { +// handleMailQueue renders the Mail queue page (spec 7.2.11). +func (s *Server) handleMailQueue(w http.ResponseWriter, r *http.Request) { out, errText := readQueue() - s.render(w, http.StatusOK, "queue", map[string]any{ + s.render(w, http.StatusOK, "mail_queue", map[string]any{ "Title": "SelfPost — mail queue", "User": currentUser(r), - "Active": "queue", + "Active": "mail_queue", "Output": out, "Error": errText, }) } -// handleQueueBody serves the HTMX polling fragment for the queue view. -func (s *Server) handleQueueBody(w http.ResponseWriter, r *http.Request) { +// handleMailQueueBody serves the HTMX polling fragment for the queue view. +func (s *Server) handleMailQueueBody(w http.ResponseWriter, r *http.Request) { out, errText := readQueue() - s.renderFragment(w, http.StatusOK, "queue_body", map[string]any{ + s.renderFragment(w, http.StatusOK, "mail_queue_body", map[string]any{ "Output": out, "Error": errText, }) @@ -142,22 +143,22 @@ func readQueue() (string, string) { return out, "" } -// handleLogTail renders the mail.log monitoring page (spec 7.2.13). -func (s *Server) handleLogTail(w http.ResponseWriter, r *http.Request) { +// handleSystemLog renders the System log page over mail.log (spec 7.2.13). +func (s *Server) handleSystemLog(w http.ResponseWriter, r *http.Request) { lines, errText := s.readLogTail() - s.render(w, http.StatusOK, "logtail", map[string]any{ - "Title": "SelfPost — mail log", + s.render(w, http.StatusOK, "system_log", map[string]any{ + "Title": "SelfPost — system log", "User": currentUser(r), - "Active": "logtail", + "Active": "system_log", "Lines": lines, "Error": errText, }) } -// handleLogTailBody serves the HTMX polling fragment for the log-tail view. -func (s *Server) handleLogTailBody(w http.ResponseWriter, r *http.Request) { +// handleSystemLogBody serves the HTMX polling fragment for the log-tail view. +func (s *Server) handleSystemLogBody(w http.ResponseWriter, r *http.Request) { lines, errText := s.readLogTail() - s.renderFragment(w, http.StatusOK, "logtail_body", map[string]any{ + s.renderFragment(w, http.StatusOK, "system_log_body", map[string]any{ "Lines": lines, "Error": errText, }) diff --git a/internal/web/middleware.go b/internal/web/middleware.go index 5af4c90..548d8ef 100644 --- a/internal/web/middleware.go +++ b/internal/web/middleware.go @@ -15,7 +15,8 @@ const usernameKey ctxKey = 0 // // 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, /queue/body, /logtail/body, /sendlog/rows) +// fragments (/status/fragment, /mail-queue/body, /system-log/body, +// /deliveries/rows) // poll every 5s 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. diff --git a/internal/web/static/panel.css b/internal/web/static/panel.css index e1c4b8d..5e4498b 100644 --- a/internal/web/static/panel.css +++ b/internal/web/static/panel.css @@ -88,20 +88,48 @@ details form { margin-top: 0.6rem; } @media (prefers-color-scheme: dark) { .credential { background: #2a2408 !important; border-color: #6b5a10 !important; } } /* Panel navigation: rendered once from the layout, so it is present on every authenticated page without each content template having to include it. */ +/* Two deliberate rows, not one. The six page entries need about 660px and the + session block another 260px, against the 738px the panel is wide — so the bar + was wrapping on its own, and the session landed left-aligned directly under + the entries, reading as a third row of navigation. Shortening labels would buy + the ~200px back but undo the point of naming each entry after its page. Rows + rather than columns: a two-column grid of entries came out a third taller + (107px against 83px) and no easier to read. + The session sits on top, at the right edge, and the page entries below — the + usual arrangement, and the layout template puts the session first so the + reading and tab order follow what the eye sees rather than being flipped by a + CSS `order`. The cost is that Sign out is the bar's first tab stop; nothing + activates on focus, so that is a reordering, not a hazard. */ .nav { - display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; - gap: 0.4rem 1rem; margin-bottom: 1.2rem; padding-bottom: 0.6rem; + display: flex; flex-direction: column; align-items: stretch; + gap: 0.5rem; margin-bottom: 1.2rem; padding-bottom: 0.6rem; border-bottom: 1px solid #e2e5e9; } @media (prefers-color-scheme: dark) { .nav { border-color: #2b3138 !important; } } -.nav .links, .nav .session { display: flex; flex-wrap: wrap; gap: 0.2rem 0.9rem; align-items: baseline; } -.nav .links a, .nav .links [aria-current] { padding: 0.2rem 0.5rem; border-radius: 6px; } -.nav .links [aria-current] { +.nav .links, .nav .session { display: flex; flex-wrap: wrap; gap: 0.2rem 0.9rem; align-items: center; } +/* Pushed to the far edge so the two rows read as two blocks rather than as one + ragged list that happened to wrap. */ +.nav .session { justify-content: flex-end; } +/* Each entry pairs an icon with its label, so the entry itself is a flex row + rather than a run of text — that is also why the bar centres its items + instead of aligning them on the text baseline. Account is included: it is a + page like the others and would otherwise be the one bare word in the bar. */ +.nav a, .nav [aria-current] { + display: inline-flex; align-items: center; gap: 0.4rem; + padding: 0.2rem 0.5rem; border-radius: 6px; +} +.nav [aria-current] { font-weight: 600; color: #1b1f24; background: #e6ebf5; box-shadow: inset 0 -2px 0 #2563eb; } @media (prefers-color-scheme: dark) { - .nav .links [aria-current] { color: #e6e8eb !important; background: #22303f !important; } + .nav [aria-current] { color: #e6e8eb !important; background: #22303f !important; } } +/* Sign out carries an icon too, so it needs the same row layout; its padding + and colours come from the compact button rule further down. */ +.nav button { display: inline-flex; align-items: center; gap: 0.4rem; } +/* The icons draw in the entry's own colour, so the active entry's darker text, + a link's blue and Sign out's red all carry through without a rule apiece. */ +.nav .icon { width: 1rem; height: 1rem; flex: none; } /* Status badges: one vocabulary (ok/warn/error/unknown) shared by the server status page and the per-domain DNS checks, so a colour means the same thing everywhere. The class suffix is the check's own status value. */ diff --git a/internal/web/templates.go b/internal/web/templates.go index 90a9b8a..b3f43d5 100644 --- a/internal/web/templates.go +++ b/internal/web/templates.go @@ -30,19 +30,19 @@ var pageFiles = map[string][]string{ "backup": {"templates/backup.html"}, "domain_detail": {"templates/domain_detail.html"}, "domain_delete": {"templates/domain_delete.html"}, - "sendlog": {"templates/sendlog.html", "templates/sendlog_rows.html"}, - "queue": {"templates/queue.html", "templates/queue_body.html"}, - "logtail": {"templates/logtail.html", "templates/logtail_body.html"}, + "deliveries": {"templates/deliveries.html", "templates/deliveries_rows.html"}, + "mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"}, + "system_log": {"templates/system_log.html", "templates/system_log_body.html"}, "status": {"templates/status.html", "templates/status_body.html"}, } // fragmentFiles maps a fragment name (also its {{define}} block name) to its // template file, for standalone rendering by the HTMX polling endpoints. var fragmentFiles = map[string]string{ - "sendlog_rows": "templates/sendlog_rows.html", - "queue_body": "templates/queue_body.html", - "logtail_body": "templates/logtail_body.html", - "status_body": "templates/status_body.html", + "deliveries_rows": "templates/deliveries_rows.html", + "mail_queue_body": "templates/mail_queue_body.html", + "system_log_body": "templates/system_log_body.html", + "status_body": "templates/status_body.html", } func loadTemplates() (*templates, error) { diff --git a/internal/web/templates/sendlog.html b/internal/web/templates/deliveries.html similarity index 89% rename from internal/web/templates/sendlog.html rename to internal/web/templates/deliveries.html index 30655f0..a4b341b 100644 --- a/internal/web/templates/sendlog.html +++ b/internal/web/templates/deliveries.html @@ -3,7 +3,7 @@

Filter

-
+