release: 0.3.0: PTR fix via public resolvers; monitoring URLs and nav icons
panel: the PTR (reverse DNS) check no longer reports a correctly published Fixed: - panel: the PTR (reverse DNS) check no longer reports a correctly published Changed: - panel: the three monitoring pages now live at URLs that match their nav - panel: each entry in the navigation bar now carries an icon beside its label, - panel: the navigation bar is laid out as two rows on purpose — the signed-in Full detail: CHANGELOG.md (0.3.0).
This commit is contained in:
@@ -5,6 +5,39 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.2.0] - 2026-08-03
|
||||||
|
|
||||||
- panel: every authenticated page now ends with the running version
|
- panel: every authenticated page now ends with the running version
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
|
|||||||
OpenDKIMSocket: cfg.opendkimSocket,
|
OpenDKIMSocket: cfg.opendkimSocket,
|
||||||
JournalSocket: cfg.journalSocket,
|
JournalSocket: cfg.journalSocket,
|
||||||
SessionIdleDays: cfg.sessionIdleDays,
|
SessionIdleDays: cfg.sessionIdleDays,
|
||||||
|
DNSResolvers: cfg.dnsResolvers,
|
||||||
}, cfg.setupTokenPath)
|
}, cfg.setupTokenPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
|
|
||||||
"codeberg.org/mix/selfpost/internal/backup"
|
"codeberg.org/mix/selfpost/internal/backup"
|
||||||
"codeberg.org/mix/selfpost/internal/buildinfo"
|
"codeberg.org/mix/selfpost/internal/buildinfo"
|
||||||
|
"codeberg.org/mix/selfpost/internal/dnscheck"
|
||||||
"codeberg.org/mix/selfpost/internal/logtail"
|
"codeberg.org/mix/selfpost/internal/logtail"
|
||||||
"codeberg.org/mix/selfpost/internal/store"
|
"codeberg.org/mix/selfpost/internal/store"
|
||||||
)
|
)
|
||||||
@@ -61,6 +62,7 @@ type config struct {
|
|||||||
submissionEnabled bool
|
submissionEnabled bool
|
||||||
trustedProxies []*net.IPNet
|
trustedProxies []*net.IPNet
|
||||||
sessionIdleDays int
|
sessionIdleDays int
|
||||||
|
dnsResolvers []string
|
||||||
|
|
||||||
// Read-only inputs to the panel's status page: the certificate Postfix
|
// Read-only inputs to the panel's status page: the certificate Postfix
|
||||||
// serves and the two milter sockets it connects to. The defaults mirror
|
// 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
|
// Sliding session idle timeout (spec 7.6.6, plan B.1). Non-positive/invalid
|
||||||
// falls back to the 7-day default inside internal/web.
|
// falls back to the 7-day default inside internal/web.
|
||||||
sessionIdleDays: envInt("PANEL_SESSION_IDLE_DAYS", 7),
|
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"),
|
tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
|
||||||
opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
|
opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ SEND_LOG_RETENTION_DAYS=90
|
|||||||
# monitoring screens does not count as activity, only navigation/actions do.
|
# monitoring screens does not count as activity, only navigation/actions do.
|
||||||
PANEL_SESSION_IDLE_DAYS=7
|
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
|
# Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to
|
||||||
# supply X-Forwarded-For for login/setup rate-limiting. Leave unset unless
|
# supply X-Forwarded-For for login/setup rate-limiting. Leave unset unless
|
||||||
# you know the exact address of your reverse proxy — trusting the wrong
|
# you know the exact address of your reverse proxy — trusting the wrong
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ services:
|
|||||||
SEND_LOG_RETENTION_DAYS: "${SEND_LOG_RETENTION_DAYS:-90}"
|
SEND_LOG_RETENTION_DAYS: "${SEND_LOG_RETENTION_DAYS:-90}"
|
||||||
# Sliding idle timeout for the panel login session, in days (spec 7.6.6).
|
# Sliding idle timeout for the panel login session, in days (spec 7.6.6).
|
||||||
PANEL_SESSION_IDLE_DAYS: "${PANEL_SESSION_IDLE_DAYS:-7}"
|
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:
|
volumes:
|
||||||
# All persistent state lives under /data (spec 9): SQLite DB, DKIM keys,
|
# All persistent state lives under /data (spec 9): SQLite DB, DKIM keys,
|
||||||
# sasldb2, sender map, setup token. Back this up (panel button or the
|
# sasldb2, sender map, setup token. Back this up (panel button or the
|
||||||
|
|||||||
@@ -77,8 +77,8 @@
|
|||||||
|
|
||||||
1. **Нет раздела «эксплуатация»** — прямое требование ТЗ 11 п. 7
|
1. **Нет раздела «эксплуатация»** — прямое требование ТЗ 11 п. 7
|
||||||
([specification.md:427](specification.md)). В README нет ни слова про
|
([specification.md:427](specification.md)). В README нет ни слова про
|
||||||
`/status` (проверки PTR/hostname), `/sendlog` (журнал отправки, фильтры),
|
`/status` (проверки PTR/hostname), `/deliveries` (журнал отправки, фильтры),
|
||||||
`/queue` (очередь Postfix), `/logtail` (хвост `mail.log`), `/reload`,
|
`/mail-queue` (очередь Postfix), `/system-log` (хвост `mail.log`), `/reload`,
|
||||||
`/account`, `/backup` — при том что всё это реализовано
|
`/account`, `/backup` — при том что всё это реализовано
|
||||||
([internal/web/web.go:152](../internal/web/web.go:152)–188). Не описана и
|
([internal/web/web.go:152](../internal/web/web.go:152)–188). Не описана и
|
||||||
процедура апгрейда (бамп тега → `docker compose up -d`), хотя раздел
|
процедура апгрейда (бамп тега → `docker compose up -d`), хотя раздел
|
||||||
|
|||||||
@@ -103,10 +103,11 @@ type cached[T any] struct {
|
|||||||
expires time.Time
|
expires time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a Checker using the process resolver and the package's default
|
// New returns a Checker querying the given recursive resolvers (empty means
|
||||||
// timeout and cache lifetimes.
|
// DefaultResolvers) with the package's default timeout and cache lifetimes.
|
||||||
func New() *Checker {
|
// The lookups deliberately bypass the system resolver — see externalResolver.
|
||||||
return newChecker(net.DefaultResolver, lookupTimeout, serverTTL, domainTTL)
|
func New(resolvers []string) *Checker {
|
||||||
|
return newChecker(newExternalResolver(resolvers), lookupTimeout, serverTTL, domainTTL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newChecker(r resolver, timeout, srvTTL, domTTL time.Duration) *Checker {
|
func newChecker(r resolver, timeout, srvTTL, domTTL time.Duration) *Checker {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,33 +19,34 @@ const (
|
|||||||
logTailLines = 200
|
logTailLines = 200
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleSendLog renders the send-log monitoring page: server-side filters by
|
// handleDeliveries renders the Deliveries page over the send log: server-side
|
||||||
// domain/application and pagination (spec 7.3.3). The row table itself is the
|
// filters by domain/application and pagination (spec 7.3.3). The row table
|
||||||
// "sendlog_rows" fragment, shared verbatim with handleSendLogRows so the
|
// itself is the "deliveries_rows" fragment, shared verbatim with
|
||||||
// initial page and its HTMX-polled refreshes never diverge.
|
// handleDeliveriesRows so the initial page and its HTMX-polled refreshes never
|
||||||
func (s *Server) handleSendLog(w http.ResponseWriter, r *http.Request) {
|
// diverge.
|
||||||
|
func (s *Server) handleDeliveries(w http.ResponseWriter, r *http.Request) {
|
||||||
data, err := s.sendLogData(r)
|
data, err := s.sendLogData(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("panel: send log: %v", err)
|
logf("panel: send log: %v", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data["Title"] = "SelfPost — send log"
|
data["Title"] = "SelfPost — deliveries"
|
||||||
data["User"] = currentUser(r)
|
data["User"] = currentUser(r)
|
||||||
data["Active"] = "sendlog"
|
data["Active"] = "deliveries"
|
||||||
s.render(w, http.StatusOK, "sendlog", data)
|
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).
|
// (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)
|
data, err := s.sendLogData(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("panel: send log rows: %v", err)
|
logf("panel: send log rows: %v", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
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
|
// sendLogData reads the domain/app filters and page number off the query
|
||||||
@@ -109,22 +110,22 @@ func parsePage(v string) int {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleQueue renders the mail-queue monitoring page (spec 7.2.11).
|
// handleMailQueue renders the Mail queue page (spec 7.2.11).
|
||||||
func (s *Server) handleQueue(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleMailQueue(w http.ResponseWriter, r *http.Request) {
|
||||||
out, errText := readQueue()
|
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",
|
"Title": "SelfPost — mail queue",
|
||||||
"User": currentUser(r),
|
"User": currentUser(r),
|
||||||
"Active": "queue",
|
"Active": "mail_queue",
|
||||||
"Output": out,
|
"Output": out,
|
||||||
"Error": errText,
|
"Error": errText,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleQueueBody serves the HTMX polling fragment for the queue view.
|
// handleMailQueueBody serves the HTMX polling fragment for the queue view.
|
||||||
func (s *Server) handleQueueBody(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleMailQueueBody(w http.ResponseWriter, r *http.Request) {
|
||||||
out, errText := readQueue()
|
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,
|
"Output": out,
|
||||||
"Error": errText,
|
"Error": errText,
|
||||||
})
|
})
|
||||||
@@ -142,22 +143,22 @@ func readQueue() (string, string) {
|
|||||||
return out, ""
|
return out, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleLogTail renders the mail.log monitoring page (spec 7.2.13).
|
// handleSystemLog renders the System log page over mail.log (spec 7.2.13).
|
||||||
func (s *Server) handleLogTail(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSystemLog(w http.ResponseWriter, r *http.Request) {
|
||||||
lines, errText := s.readLogTail()
|
lines, errText := s.readLogTail()
|
||||||
s.render(w, http.StatusOK, "logtail", map[string]any{
|
s.render(w, http.StatusOK, "system_log", map[string]any{
|
||||||
"Title": "SelfPost — mail log",
|
"Title": "SelfPost — system log",
|
||||||
"User": currentUser(r),
|
"User": currentUser(r),
|
||||||
"Active": "logtail",
|
"Active": "system_log",
|
||||||
"Lines": lines,
|
"Lines": lines,
|
||||||
"Error": errText,
|
"Error": errText,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleLogTailBody serves the HTMX polling fragment for the log-tail view.
|
// handleSystemLogBody serves the HTMX polling fragment for the log-tail view.
|
||||||
func (s *Server) handleLogTailBody(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSystemLogBody(w http.ResponseWriter, r *http.Request) {
|
||||||
lines, errText := s.readLogTail()
|
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,
|
"Lines": lines,
|
||||||
"Error": errText,
|
"Error": errText,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ const usernameKey ctxKey = 0
|
|||||||
//
|
//
|
||||||
// It also extends the sliding session (plan B.1) on activity, defined as
|
// It also extends the sliding session (plan B.1) on activity, defined as
|
||||||
// everything except a GET request carrying HX-Request: the four monitoring
|
// 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
|
// 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
|
// counting those as activity would make "N days idle" mean "N days since a
|
||||||
// browser tab was last open" instead.
|
// browser tab was last open" instead.
|
||||||
|
|||||||
@@ -88,20 +88,48 @@ details form { margin-top: 0.6rem; }
|
|||||||
@media (prefers-color-scheme: dark) { .credential { background: #2a2408 !important; border-color: #6b5a10 !important; } }
|
@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
|
/* Panel navigation: rendered once from the layout, so it is present on every
|
||||||
authenticated page without each content template having to include it. */
|
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 {
|
.nav {
|
||||||
display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline;
|
display: flex; flex-direction: column; align-items: stretch;
|
||||||
gap: 0.4rem 1rem; margin-bottom: 1.2rem; padding-bottom: 0.6rem;
|
gap: 0.5rem; margin-bottom: 1.2rem; padding-bottom: 0.6rem;
|
||||||
border-bottom: 1px solid #e2e5e9;
|
border-bottom: 1px solid #e2e5e9;
|
||||||
}
|
}
|
||||||
@media (prefers-color-scheme: dark) { .nav { border-color: #2b3138 !important; } }
|
@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, .nav .session { display: flex; flex-wrap: wrap; gap: 0.2rem 0.9rem; align-items: center; }
|
||||||
.nav .links a, .nav .links [aria-current] { padding: 0.2rem 0.5rem; border-radius: 6px; }
|
/* Pushed to the far edge so the two rows read as two blocks rather than as one
|
||||||
.nav .links [aria-current] {
|
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;
|
font-weight: 600; color: #1b1f24; background: #e6ebf5; box-shadow: inset 0 -2px 0 #2563eb;
|
||||||
}
|
}
|
||||||
@media (prefers-color-scheme: dark) {
|
@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 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
|
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. */
|
everywhere. The class suffix is the check's own status value. */
|
||||||
|
|||||||
@@ -30,19 +30,19 @@ var pageFiles = map[string][]string{
|
|||||||
"backup": {"templates/backup.html"},
|
"backup": {"templates/backup.html"},
|
||||||
"domain_detail": {"templates/domain_detail.html"},
|
"domain_detail": {"templates/domain_detail.html"},
|
||||||
"domain_delete": {"templates/domain_delete.html"},
|
"domain_delete": {"templates/domain_delete.html"},
|
||||||
"sendlog": {"templates/sendlog.html", "templates/sendlog_rows.html"},
|
"deliveries": {"templates/deliveries.html", "templates/deliveries_rows.html"},
|
||||||
"queue": {"templates/queue.html", "templates/queue_body.html"},
|
"mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"},
|
||||||
"logtail": {"templates/logtail.html", "templates/logtail_body.html"},
|
"system_log": {"templates/system_log.html", "templates/system_log_body.html"},
|
||||||
"status": {"templates/status.html", "templates/status_body.html"},
|
"status": {"templates/status.html", "templates/status_body.html"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// fragmentFiles maps a fragment name (also its {{define}} block name) to its
|
// fragmentFiles maps a fragment name (also its {{define}} block name) to its
|
||||||
// template file, for standalone rendering by the HTMX polling endpoints.
|
// template file, for standalone rendering by the HTMX polling endpoints.
|
||||||
var fragmentFiles = map[string]string{
|
var fragmentFiles = map[string]string{
|
||||||
"sendlog_rows": "templates/sendlog_rows.html",
|
"deliveries_rows": "templates/deliveries_rows.html",
|
||||||
"queue_body": "templates/queue_body.html",
|
"mail_queue_body": "templates/mail_queue_body.html",
|
||||||
"logtail_body": "templates/logtail_body.html",
|
"system_log_body": "templates/system_log_body.html",
|
||||||
"status_body": "templates/status_body.html",
|
"status_body": "templates/status_body.html",
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadTemplates() (*templates, error) {
|
func loadTemplates() (*templates, error) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Filter</h2>
|
<h2>Filter</h2>
|
||||||
<form method="get" action="/sendlog">
|
<form method="get" action="/deliveries">
|
||||||
<label for="domain">Domain</label>
|
<label for="domain">Domain</label>
|
||||||
<select id="domain" name="domain">
|
<select id="domain" name="domain">
|
||||||
<option value="">All domains</option>
|
<option value="">All domains</option>
|
||||||
@@ -19,6 +19,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
{{template "sendlog_rows" .}}
|
{{template "deliveries_rows" .}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
{{define "sendlog_rows"}}
|
{{define "deliveries_rows"}}
|
||||||
<div id="sendlog-rows" hx-get="/sendlog/rows?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.Page}}"
|
<div id="deliveries-rows" hx-get="/deliveries/rows?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.Page}}"
|
||||||
hx-trigger="every 5s" hx-swap="outerHTML">
|
hx-trigger="every 5s" hx-swap="outerHTML">
|
||||||
{{if .Rows}}
|
{{if .Rows}}
|
||||||
<table>
|
<table>
|
||||||
@@ -22,8 +22,8 @@
|
|||||||
</table>
|
</table>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
Page {{.Page}}{{if .LastPage}} of {{.LastPage}}{{end}}
|
Page {{.Page}}{{if .LastPage}} of {{.LastPage}}{{end}}
|
||||||
{{if .HasPrev}} · <a href="/sendlog?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.PrevPage}}">← Newer</a>{{end}}
|
{{if .HasPrev}} · <a href="/deliveries?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.PrevPage}}">← Newer</a>{{end}}
|
||||||
{{if .HasNext}} · <a href="/sendlog?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.NextPage}}">Older →</a>{{end}}
|
{{if .HasNext}} · <a href="/deliveries?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.NextPage}}">Older →</a>{{end}}
|
||||||
</p>
|
</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="muted">No messages logged yet.</p>
|
<p class="muted">No messages logged yet.</p>
|
||||||
@@ -35,20 +35,42 @@
|
|||||||
.User and get no nav. */}}
|
.User and get no nav. */}}
|
||||||
{{define "nav"}}
|
{{define "nav"}}
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<div class="links">
|
{{/* The session block is first here because it is drawn first: it is the top
|
||||||
{{if eq .Active "status"}}<span aria-current="page">Status</span>{{else}}<a href="/status">Status</a>{{end}}
|
row of the bar (see .nav in panel.css), and putting it first in the
|
||||||
{{if eq .Active "domains"}}<span aria-current="page">Domains</span>{{else}}<a href="/domains">Domains</a>{{end}}
|
document keeps the tab order matching the visual one. */}}
|
||||||
{{if eq .Active "sendlog"}}<span aria-current="page">Deliveries</span>{{else}}<a href="/sendlog">Deliveries</a>{{end}}
|
|
||||||
{{if eq .Active "queue"}}<span aria-current="page">Mail queue</span>{{else}}<a href="/queue">Mail queue</a>{{end}}
|
|
||||||
{{if eq .Active "logtail"}}<span aria-current="page">System log</span>{{else}}<a href="/logtail">System log</a>{{end}}
|
|
||||||
{{if eq .Active "backup"}}<span aria-current="page">Backup</span>{{else}}<a href="/backup">Backup</a>{{end}}
|
|
||||||
</div>
|
|
||||||
<div class="session">
|
<div class="session">
|
||||||
<span class="muted">{{.User}}</span>
|
<span class="muted">{{.User}}</span>
|
||||||
{{if eq .Active "account"}}<span aria-current="page">Account</span>{{else}}<a href="/account">Account</a>{{end}}
|
{{if eq .Active "account"}}<span aria-current="page">{{template "icon-account"}}Account</span>{{else}}<a href="/account">{{template "icon-account"}}Account</a>{{end}}
|
||||||
<form class="inline" method="post" action="/logout">
|
<form class="inline" method="post" action="/logout">
|
||||||
<button type="submit" class="danger">Sign out</button>
|
<button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="links">
|
||||||
|
{{if eq .Active "status"}}<span aria-current="page">{{template "icon-status"}}Status</span>{{else}}<a href="/status">{{template "icon-status"}}Status</a>{{end}}
|
||||||
|
{{if eq .Active "domains"}}<span aria-current="page">{{template "icon-domains"}}Domains</span>{{else}}<a href="/domains">{{template "icon-domains"}}Domains</a>{{end}}
|
||||||
|
{{if eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/deliveries">{{template "icon-deliveries"}}Deliveries</a>{{end}}
|
||||||
|
{{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/mail-queue">{{template "icon-mail-queue"}}Mail queue</a>{{end}}
|
||||||
|
{{if eq .Active "system_log"}}<span aria-current="page">{{template "icon-system-log"}}System log</span>{{else}}<a href="/system-log">{{template "icon-system-log"}}System log</a>{{end}}
|
||||||
|
{{if eq .Active "backup"}}<span aria-current="page">{{template "icon-backup"}}Backup</span>{{else}}<a href="/backup">{{template "icon-backup"}}Backup</a>{{end}}
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{/* Navigation icons. Inline SVG rather than an icon font or sprite file: they
|
||||||
|
inherit the link's colour through currentColor, cost no extra request, and
|
||||||
|
need no exemption from the panel's "default-src 'self'" policy. Each is
|
||||||
|
aria-hidden because the entry's own text is already the accessible name;
|
||||||
|
the icon is a landmark for the eye, not a second label. Kept as separate
|
||||||
|
templates so the nav above stays one readable line per page. */}}
|
||||||
|
{{define "icon-status"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.25 8.5h2.9L6.2 3.4l3.1 9.4 1.9-4.3h3.55"/></svg>{{end}}
|
||||||
|
{{define "icon-domains"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M1.9 8h12.2"/><path d="M8 1.75c1.85 1.8 2.8 4 2.8 6.25S9.85 12.45 8 14.25C6.15 12.45 5.2 10.25 5.2 8S6.15 3.55 8 1.75Z"/></svg>{{end}}
|
||||||
|
{{define "icon-deliveries"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.25 1.75 1.6 6.6l5 2.05 2.05 5z"/><path d="M14.25 1.75 6.6 8.65"/></svg>{{end}}
|
||||||
|
{{define "icon-mail-queue"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.75 9.5h3.3l1 1.75h3.9l1-1.75h3.3v3.05a1.2 1.2 0 0 1-1.2 1.2H2.95a1.2 1.2 0 0 1-1.2-1.2z"/><path d="M1.75 9.5 3.4 3.2a1.25 1.25 0 0 1 1.2-.95h6.8a1.25 1.25 0 0 1 1.2.95l1.65 6.3"/></svg>{{end}}
|
||||||
|
{{define "icon-system-log"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.75 1.75h5.1l3.4 3.4v8.05a1.05 1.05 0 0 1-1.05 1.05H3.75a1.05 1.05 0 0 1-1.05-1.05V2.8a1.05 1.05 0 0 1 1.05-1.05Z"/><path d="M8.85 1.75v3.4h3.4"/><path d="M5.35 8.6h5.3M5.35 11.1h3.5"/></svg>{{end}}
|
||||||
|
{{/* A filing cabinet rather than a storage box: the box read as a box, while
|
||||||
|
two drawers with handles is the picture of an archive. */}}
|
||||||
|
{{define "icon-backup"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.75" y="1.75" width="10.5" height="12.5" rx="1.15"/><path d="M2.75 8h10.5"/><path d="M6.4 4.85h3.2M6.4 11.15h3.2"/></svg>{{end}}
|
||||||
|
{{define "icon-account"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="5.4" r="2.75"/><path d="M2.9 14.25a5.1 5.1 0 0 1 10.2 0"/></svg>{{end}}
|
||||||
|
{{/* Sign out is the one icon here that marks an action rather than a page: an
|
||||||
|
open door with the arrow leading out of it. */}}
|
||||||
|
{{define "icon-sign-out"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.1 14.25H3.65a1.15 1.15 0 0 1-1.15-1.15V2.9a1.15 1.15 0 0 1 1.15-1.15H6.1"/><path d="M10.6 11.15 13.75 8 10.6 4.85"/><path d="M13.75 8H6.35"/></svg>{{end}}
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Pending messages</h2>
|
<h2>Pending messages</h2>
|
||||||
{{template "queue_body" .}}
|
{{template "mail_queue_body" .}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
{{define "queue_body"}}
|
{{define "mail_queue_body"}}
|
||||||
<div id="queue-body" hx-get="/queue/body" hx-trigger="every 5s" hx-swap="outerHTML">
|
<div id="mail-queue-body" hx-get="/mail-queue/body" hx-trigger="every 5s" hx-swap="outerHTML">
|
||||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||||
<span class="code">{{if .Output}}{{.Output}}{{else}}Queue is empty.{{end}}</span>
|
<span class="code">{{if .Output}}{{.Output}}{{else}}Queue is empty.{{end}}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
<p class="error">{{.QueueError}}</p>
|
<p class="error">{{.QueueError}}</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p>{{if .QueueSummary}}{{.QueueSummary}}{{else}}Mail queue is empty.{{end}}</p>
|
<p>{{if .QueueSummary}}{{.QueueSummary}}{{else}}Mail queue is empty.{{end}}</p>
|
||||||
<a class="btn" href="/queue">Full queue</a>
|
<a class="btn" href="/mail-queue">Full queue</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Mail log</h1>
|
<h1>System log</h1>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Recent log entries</h2>
|
<h2>Recent log entries</h2>
|
||||||
{{template "logtail_body" .}}
|
{{template "system_log_body" .}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
{{define "logtail_body"}}
|
{{define "system_log_body"}}
|
||||||
<div id="logtail-body" hx-get="/logtail/body" hx-trigger="every 5s" hx-swap="outerHTML">
|
<div id="system-log-body" hx-get="/system-log/body" hx-trigger="every 5s" hx-swap="outerHTML">
|
||||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||||
<span class="code">{{if .Lines}}{{range .Lines}}{{.}}
|
<span class="code">{{if .Lines}}{{range .Lines}}{{.}}
|
||||||
{{end}}{{else}}No log lines yet.{{end}}</span>
|
{{end}}{{else}}No log lines yet.{{end}}</span>
|
||||||
@@ -99,19 +99,21 @@ func TestNavMarksActivePage(t *testing.T) {
|
|||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = tmpl.pages["dashboard"].ExecuteTemplate(&buf, "nav", map[string]any{
|
err = tmpl.pages["dashboard"].ExecuteTemplate(&buf, "nav", map[string]any{
|
||||||
"User": "admin",
|
"User": "admin",
|
||||||
"Active": "queue",
|
"Active": "mail_queue",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("execute nav: %v", err)
|
t.Fatalf("execute nav: %v", err)
|
||||||
}
|
}
|
||||||
out := buf.String()
|
out := buf.String()
|
||||||
if !strings.Contains(out, `<span aria-current="page">Mail queue</span>`) {
|
// The label is checked apart from the opening tag because each entry now
|
||||||
|
// carries an icon between the two.
|
||||||
|
if !strings.Contains(out, `<span aria-current="page">`) || !strings.Contains(out, `Mail queue</span>`) {
|
||||||
t.Errorf("active page is not marked:\n%s", out)
|
t.Errorf("active page is not marked:\n%s", out)
|
||||||
}
|
}
|
||||||
if strings.Contains(out, `href="/queue"`) {
|
if strings.Contains(out, `href="/mail-queue"`) {
|
||||||
t.Errorf("active page still links to itself:\n%s", out)
|
t.Errorf("active page still links to itself:\n%s", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, `href="/sendlog"`) {
|
if !strings.Contains(out, `href="/deliveries"`) {
|
||||||
t.Errorf("inactive pages are not linked:\n%s", out)
|
t.Errorf("inactive pages are not linked:\n%s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,7 +131,7 @@ func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
|
|||||||
t.Fatalf("execute nav: %v", err)
|
t.Fatalf("execute nav: %v", err)
|
||||||
}
|
}
|
||||||
out := buf.String()
|
out := buf.String()
|
||||||
if !strings.Contains(out, `<span aria-current="page">Status</span>`) {
|
if !strings.Contains(out, `<span aria-current="page">`) || !strings.Contains(out, `Status</span>`) {
|
||||||
t.Errorf("the status page is not marked active:\n%s", out)
|
t.Errorf("the status page is not marked active:\n%s", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, `href="/domains"`) {
|
if !strings.Contains(out, `href="/domains"`) {
|
||||||
|
|||||||
+12
-7
@@ -63,6 +63,11 @@ type Config struct {
|
|||||||
// session expires (env PANEL_SESSION_IDLE_DAYS, plan B.1). Non-positive
|
// session expires (env PANEL_SESSION_IDLE_DAYS, plan B.1). Non-positive
|
||||||
// falls back to the 7-day default.
|
// falls back to the 7-day default.
|
||||||
SessionIdleDays int
|
SessionIdleDays int
|
||||||
|
// DNSResolvers are the recursive resolvers the deliverability checks query
|
||||||
|
// (env SELFPOST_DNS_RESOLVERS). Empty uses dnscheck.DefaultResolvers. The
|
||||||
|
// checks must not go through the system resolver — see dnscheck's
|
||||||
|
// externalResolver — so this is how a closed network points them at its own.
|
||||||
|
DNSResolvers []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is the panel HTTP application.
|
// Server is the panel HTTP application.
|
||||||
@@ -105,7 +110,7 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config
|
|||||||
// Published-DNS checks for the status page and the domain pages. The
|
// Published-DNS checks for the status page and the domain pages. The
|
||||||
// checker caches its own results, so page views do not each pay for a
|
// checker caches its own results, so page views do not each pay for a
|
||||||
// round of lookups (phase 13).
|
// round of lookups (phase 13).
|
||||||
dns: dnscheck.New(),
|
dns: dnscheck.New(cfg.DNSResolvers),
|
||||||
// Setup: a handful of attempts per minute per IP is plenty for a
|
// Setup: a handful of attempts per minute per IP is plenty for a
|
||||||
// legitimate admin and blunts automated probing (spec 7.6.1).
|
// legitimate admin and blunts automated probing (spec 7.6.1).
|
||||||
setupLimiter: newRateLimiter(10, time.Minute),
|
setupLimiter: newRateLimiter(10, time.Minute),
|
||||||
@@ -180,12 +185,12 @@ func (s *Server) Handler() http.Handler {
|
|||||||
|
|
||||||
// Monitoring screens (spec 7.2.11-13): each page and its HTMX polling
|
// Monitoring screens (spec 7.2.11-13): each page and its HTMX polling
|
||||||
// fragment (spec 7.1 — the /rows and /body endpoints return HTML, not JSON).
|
// fragment (spec 7.1 — the /rows and /body endpoints return HTML, not JSON).
|
||||||
authed.HandleFunc("GET /sendlog", s.handleSendLog)
|
authed.HandleFunc("GET /deliveries", s.handleDeliveries)
|
||||||
authed.HandleFunc("GET /sendlog/rows", s.handleSendLogRows)
|
authed.HandleFunc("GET /deliveries/rows", s.handleDeliveriesRows)
|
||||||
authed.HandleFunc("GET /queue", s.handleQueue)
|
authed.HandleFunc("GET /mail-queue", s.handleMailQueue)
|
||||||
authed.HandleFunc("GET /queue/body", s.handleQueueBody)
|
authed.HandleFunc("GET /mail-queue/body", s.handleMailQueueBody)
|
||||||
authed.HandleFunc("GET /logtail", s.handleLogTail)
|
authed.HandleFunc("GET /system-log", s.handleSystemLog)
|
||||||
authed.HandleFunc("GET /logtail/body", s.handleLogTailBody)
|
authed.HandleFunc("GET /system-log/body", s.handleSystemLogBody)
|
||||||
|
|
||||||
mux.Handle("/", s.requireAuth(authed))
|
mux.Handle("/", s.requireAuth(authed))
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ services:
|
|||||||
SELFPOST_HOSTNAME: mail.e2e.test
|
SELFPOST_HOSTNAME: mail.e2e.test
|
||||||
# Plain HTTP panel for the test client — no reverse proxy in this stand.
|
# Plain HTTP panel for the test client — no reverse proxy in this stand.
|
||||||
PANEL_COOKIE_SECURE: "false"
|
PANEL_COOKIE_SECURE: "false"
|
||||||
|
# The panel's deliverability checks query resolvers directly rather than
|
||||||
|
# the container's own (see internal/dnscheck), so the fake zone has to be
|
||||||
|
# named here too — `dns:` below only redirects the system resolver.
|
||||||
|
SELFPOST_DNS_RESOLVERS: 10.77.0.10:53
|
||||||
# High enough that the whole suite's other ~10 sends (positive path,
|
# High enough that the whole suite's other ~10 sends (positive path,
|
||||||
# level-2 test, the various rejected attempts) never trip it by
|
# level-2 test, the various rejected attempts) never trip it by
|
||||||
# accident — only the dedicated level-1 test (negative check 4, which
|
# accident — only the dedicated level-1 test (negative check 4, which
|
||||||
|
|||||||
@@ -187,10 +187,10 @@ func (c *panelClient) setRateLimit(path, allowedIP string, maxMessages, windowSe
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendLogRows returns the raw /sendlog/rows HTML fragment, filtered to one
|
// sendLogRows returns the raw /deliveries/rows HTML fragment, filtered to one
|
||||||
// domain, for polling a row's status without parsing full HTML into structs.
|
// domain, for polling a row's status without parsing full HTML into structs.
|
||||||
func (c *panelClient) sendLogRows(domain string) (string, error) {
|
func (c *panelClient) sendLogRows(domain string) (string, error) {
|
||||||
_, body, err := c.get("/sendlog/rows?domain=" + url.QueryEscape(domain))
|
_, body, err := c.get("/deliveries/rows?domain=" + url.QueryEscape(domain))
|
||||||
return body, err
|
return body, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user