panel: show the SPF and DMARC records the domain page expects

The domain page generated and displayed the DKIM record but said only
"also configure SPF and DMARC for the domain (see the documentation)".
The concrete example existed — buried in the check's remediation text,
and only visible once the check had already failed. Show both records up
front, host and value with a Copy button, the way the DKIM record is
shown, plus the two things that actually bite: a domain may carry only
one SPF record (add the mechanism to the existing one rather than
publishing a second), and p=none is safe to publish immediately.

The SPF value names the addresses SELFPOST_HOSTNAME resolves to, taken
from the hostname check the page already runs, and falls back to an "a:"
mechanism when it does not resolve. New dnscheck.SPFExample/DMARCExample
are the single source for both the page and the checks' advice, so the
two cannot drift into recommending different records; dnscheck.Query
gains Hostname for the fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 16:08:48 +03:00
parent 6cf8b7b631
commit e6910c2c2d
8 changed files with 199 additions and 15 deletions
+19
View File
@@ -5,6 +5,25 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased]
- panel: the domain page now shows the **SPF and DMARC records it expects**,
with host, value and a Copy button, next to the DKIM record it already
showed — previously it only said "also configure SPF and DMARC (see the
documentation)" and the concrete example appeared only once a check had
already failed. The SPF value names the addresses this server's hostname
resolves to (falling back to an `a:` mechanism if it does not resolve), and
the DNS checks below build their remediation advice from the same source, so
the page and its checks cannot recommend different records.
- panel: one appearance for actions. Several controls — a POST wrapped in an
inline form (Re-check, Export domain, Sign out, New password…), the
`<details>` toggles in the applications table, the delete links — used to
render as bold blue text while everything else was a button, so the same
kind of control looked like two different things, sometimes within one card.
They are all buttons now: filled for a card's own action, compact and
outlined where actions cluster in a table row or the nav bar. An `<a>` is
once again only used for navigation.
- panel: on the domain page, **Applications** and **Add an application** sit
side by side on a wide viewport instead of the form being stranded below the
domain rate limit; they stack again on a narrow one.
- ci: hermetic container e2e suite (`test/e2e`, a separate Go module) gates
image publishing — `make e2e` locally, and `go test ./...` in `test/e2e` as
a required step in `release.yml` before a version tag's image is pushed.
+3 -2
View File
@@ -66,12 +66,13 @@ type Domain struct {
// Query describes the domain to check. ExpectedDKIM is the TXT value the panel
// tells the operator to publish (domain.DKIMRecord.Value), so the check
// compares DNS against the key this server actually signs with. ServerIPs comes
// from a preceding Server check.
// compares DNS against the key this server actually signs with. Hostname and
// ServerIPs identify this server and come from a preceding Server check.
type Query struct {
Name string
Selector string
ExpectedDKIM string
Hostname string
ServerIPs []string
}
+49
View File
@@ -0,0 +1,49 @@
package dnscheck
import (
"net"
"strings"
)
// SPF and DMARC are the two records SelfPost cannot generate for the operator:
// unlike the DKIM record they are policy, not a key, and a domain may already
// publish one for other senders. The panel still has to say what "correct"
// looks like, and the checks below have to suggest the same thing when a record
// is missing — so both take their example from here rather than each spelling
// out its own.
// DMARCRecordName is the name a DMARC record is published at. (SPF has no such
// helper: it is published at the domain itself.)
func DMARCRecordName(domainName string) string { return "_dmarc." + domainName }
// SPFExample is the SPF record this server expects for a sending domain: the
// addresses its mail actually leaves from, and "-all" to say that nothing else
// is authorised. When the server's own addresses are not known (its hostname
// does not resolve) it falls back to an "a:" mechanism naming the host, so the
// panel always has something concrete to show.
func SPFExample(hostname string, serverIPs []string) string {
var mechanisms []string
for _, s := range serverIPs {
ip := net.ParseIP(strings.TrimSpace(s))
switch {
case ip == nil:
continue
case ip.To4() != nil:
mechanisms = append(mechanisms, "ip4:"+ip.String())
default:
mechanisms = append(mechanisms, "ip6:"+ip.String())
}
}
if len(mechanisms) == 0 {
mechanisms = []string{"a:" + hostname}
}
return "v=spf1 " + strings.Join(mechanisms, " ") + " -all"
}
// DMARCExample is the least a domain should publish: monitoring only, with an
// address the aggregate reports go to. p=none is deliberate — it changes
// nothing about delivery, so it is safe to publish before the reports have
// shown that DKIM and SPF pass everywhere.
func DMARCExample(domainName string) string {
return "v=DMARC1; p=none; rua=mailto:dmarc@" + domainName
}
+60
View File
@@ -0,0 +1,60 @@
package dnscheck
import (
"context"
"strings"
"testing"
"codeberg.org/mix/selfpost/internal/health"
)
func TestSPFExample(t *testing.T) {
cases := []struct {
name string
hostname string
ips []string
want string
}{
{"ipv4", "mail.example.com", []string{"203.0.113.10"}, "v=spf1 ip4:203.0.113.10 -all"},
{"both families", "mail.example.com", []string{"203.0.113.10", "2001:db8::1"},
"v=spf1 ip4:203.0.113.10 ip6:2001:db8::1 -all"},
// The hostname does not resolve, so there is no address to name; an "a:"
// mechanism still gives the operator a publishable record.
{"no addresses", "mail.example.com", nil, "v=spf1 a:mail.example.com -all"},
{"unparsable addresses", "mail.example.com", []string{"not-an-ip"}, "v=spf1 a:mail.example.com -all"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := SPFExample(c.hostname, c.ips); got != c.want {
t.Errorf("SPFExample = %q, want %q", got, c.want)
}
})
}
}
// The record the panel shows and the one a failed check suggests must be the
// same string, or the operator is told two different things on one page.
func TestMissingRecordChecksSuggestTheShownExample(t *testing.T) {
f := &fakeResolver{}
c := newTestChecker(f)
spf := c.checkSPF(context.Background(), Query{
Name: "example.com",
Hostname: "mail.example.com",
ServerIPs: []string{"203.0.113.10"},
})
if spf.Status != health.StatusError {
t.Fatalf("SPF status = %q, want error (%s)", spf.Status, spf.Detail)
}
if want := SPFExample("mail.example.com", []string{"203.0.113.10"}); !strings.Contains(spf.Detail, want) {
t.Errorf("SPF advice %q does not suggest %q", spf.Detail, want)
}
dmarc := c.checkDMARC(context.Background(), "example.com")
if dmarc.Status != health.StatusWarn {
t.Fatalf("DMARC status = %q, want warn (%s)", dmarc.Status, dmarc.Detail)
}
if want := DMARCExample("example.com"); !strings.Contains(dmarc.Detail, want) {
t.Errorf("DMARC advice %q does not suggest %q", dmarc.Detail, want)
}
}
+2 -2
View File
@@ -61,7 +61,7 @@ func (c *Checker) checkDKIM(ctx context.Context, q Query) Result {
// checkDMARC reports whether the domain publishes a DMARC policy. DMARC is not
// required for delivery, so its absence is advice (warn), not a fault.
func (c *Checker) checkDMARC(ctx context.Context, domainName string) Result {
name := "_dmarc." + domainName
name := DMARCRecordName(domainName)
txt, found, err := c.lookupTXT(ctx, name)
if err != nil {
return lookupFailed("the DMARC record", err)
@@ -76,7 +76,7 @@ func (c *Checker) checkDMARC(ctx context.Context, domainName string) Result {
if !found || len(records) == 0 {
return Result{
Status: health.StatusWarn,
Detail: fmt.Sprintf("No DMARC record at %s. Delivery works without one, but publishing at least \"v=DMARC1; p=none; rua=mailto:you@%s\" tells receivers what to do with mail that fails DKIM and gets you reports.", name, domainName),
Detail: fmt.Sprintf("No DMARC record at %s. Delivery works without one, but publishing at least %q tells receivers what to do with mail that fails DKIM and gets you reports.", name, DMARCExample(domainName)),
}
}
if len(records) > 1 {
+1 -1
View File
@@ -47,7 +47,7 @@ func (c *Checker) checkSPF(ctx context.Context, q Query) Result {
case !found || len(records) == 0:
return Result{
Status: health.StatusError,
Detail: fmt.Sprintf("No SPF record is published for %s. Publish a TXT record such as \"v=spf1 ip4:%s -all\" — without it receivers have nothing authorising this server to send as the domain.", q.Name, ips[0]),
Detail: fmt.Sprintf("No SPF record is published for %s. Publish a TXT record such as %q — without it receivers have nothing authorising this server to send as the domain.", q.Name, SPFExample(q.Hostname, q.ServerIPs)),
}
case len(records) > 1:
return Result{
+20 -8
View File
@@ -96,16 +96,26 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
return
}
// What DNS actually publishes for the domain today, checked against the key
// this server signs with (phase 13.B). Cached by the checker, so
// re-rendering the page after a form post costs nothing.
dns, srv := s.domainDNS(d, record, false)
s.render(w, status, "domain_detail", map[string]any{
"Title": "SelfPost — " + d.Name,
"User": currentUser(r),
"Active": "domains",
"Domain": d,
"Record": record,
// What DNS actually publishes for the domain today, checked against the
// key this server signs with (phase 13.B). Cached by the checker, so
// re-rendering the page after a form post costs nothing.
"DNS": s.domainDNS(d, record, false),
"DNS": dns,
// SPF and DMARC are the operator's to write — SelfPost cannot generate
// them the way it generates the DKIM record — so the page shows what
// this server expects rather than leaving it to the documentation. The
// same builders phrase the suggestions in the check messages, so the
// page and the checks below it never recommend different records.
"SPFExample": dnscheck.SPFExample(s.cfg.Hostname, srv.IPs),
"DMARCName": dnscheck.DMARCRecordName(d.Name),
"DMARCExample": dnscheck.DMARCExample(d.Name),
// Client connection settings (the same for every domain on this
// instance): the hostname clients connect to, and whether the optional
// submission listener is enabled in this deployment.
@@ -131,16 +141,18 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
// domainDNS resolves what the world sees for a domain: its DKIM, SPF and DMARC
// records (phase 13.B). The server's own address comes from the (separately
// cached) hostname check, so the SPF heuristic knows which IP it is looking for
// and no extra environment variable is needed. force bypasses the cache, for the
// Re-check button.
func (s *Server) domainDNS(d store.Domain, record domain.DKIMRecord, force bool) dnscheck.Domain {
// and no extra environment variable is needed. That server result is returned
// alongside, because the page's suggested SPF record is built from the same
// addresses. force bypasses the cache, for the Re-check button.
func (s *Server) domainDNS(d store.Domain, record domain.DKIMRecord, force bool) (dnscheck.Domain, dnscheck.Server) {
srv := s.dns.Server(s.cfg.Hostname, false)
return s.dns.Domain(dnscheck.Query{
Name: d.Name,
Selector: d.DKIMSelector,
ExpectedDKIM: record.Value,
Hostname: srv.Hostname,
ServerIPs: srv.IPs,
}, force)
}, force), srv
}
// handleDomainDNSRecheck re-runs the domain's DNS checks ignoring the cache and
+45 -2
View File
@@ -44,8 +44,51 @@
<button type="button" class="copy">Copy</button>
</div>
<p class="muted">Also configure SPF and DMARC for the domain (see the
documentation). Mail is signed with selector <strong>{{.Domain.DKIMSelector}}</strong>.</p>
<p class="muted">Mail is signed with selector <strong>{{.Domain.DKIMSelector}}</strong>.</p>
</div>
<div class="card">
<h2>SPF and DMARC records</h2>
<p class="muted">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.</p>
<label>SPF — host / name</label>
<div class="code-row">
<span class="code">{{.Domain.Name}}</span>
<button type="button" class="copy">Copy</button>
</div>
<label>SPF — value</label>
<div class="code-row">
<span class="code">{{.SPFExample}}</span>
<button type="button" class="copy">Copy</button>
</div>
<p class="muted">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 <code>all</code> term. <code>-all</code> at the end means
nothing else may send as the domain; use <code>~all</code> while other senders
are still being sorted out.</p>
<label>DMARC — host / name</label>
<div class="code-row">
<span class="code">{{.DMARCName}}</span>
<button type="button" class="copy">Copy</button>
</div>
<label>DMARC — value</label>
<div class="code-row">
<span class="code">{{.DMARCExample}}</span>
<button type="button" class="copy">Copy</button>
</div>
<p class="muted">Point <code>rua=</code> at a mailbox that is actually read —
the aggregate reports are how you find out who else sends as the domain.
<code>p=none</code> changes nothing about delivery, so it is safe to publish
straight away; tighten it to <code>p=quarantine</code> and then
<code>p=reject</code> once the reports come back clean.</p>
</div>
<div class="card">