release: 0.6.0: Delivery detail page, DNS badge, machine metrics, wide layout

A page per delivery (`/deliveries/{id}`), reached from the *Details* link on
Added:
  - A page per delivery (`/deliveries/{id}`), reached from the *Details* link on
  - A **DNS** badge in the domain list, one per row, carrying the same
  - Machine metrics on the status page: a **Machine** card reporting the
  - An index of the current page's own sections in the navigation column, for the
Changed:
  - Every panel page is laid out in one column of the same width, so moving
  - The mark's small-size variant — the tab icon and the `SP` initials it
  - The stamp's `SELF-HOSTED SMTP RELAY` line is set at 11.5/0.15 instead of
  - The delivery log lists what identifies a message and nothing else: time,
  - Subjects are decoded for display as well as on the way in, so the rows the
  … (4 more in CHANGELOG)

Full detail: CHANGELOG.md (0.6.0).
This commit is contained in:
mix
2026-09-22 15:24:51 +03:00
parent 5e5d157496
commit 83c488cc48
40 changed files with 2207 additions and 242 deletions
+41 -1
View File
@@ -5,10 +5,20 @@ import (
"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).
@@ -30,13 +40,43 @@ func (s *Server) renderDashboard(w http.ResponseWriter, r *http.Request, status
"Title": "SelfPost",
"User": currentUser(r),
"Active": "domains",
"Domains": 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 {