panel: pair domain detail cards like Status (SPF | DMARC)
test / test (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-12 15:33:41 +03:00
parent 1d30605533
commit 44e79c4120
3 changed files with 264 additions and 245 deletions
+4
View File
@@ -7,6 +7,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
### Changed
- panel: the domain detail page is wide with paired cards (DKIM ‖ DNS status;
SPF ‖ DMARC; sending settings ‖ add application; rate limit ‖ export).
Applications and Danger stay full-width; SPF and DMARC are separate cards;
section blurbs are shorter.
- panel: page URLs, browser titles, and headings are aligned — **Settings** is
now `/settings` (legacy `/account` redirects with 308); the domains list title
is `SelfPost — domains`; Status, Users, and user create/edit titles match their
+233 -244
View File
@@ -1,3 +1,7 @@
{{/* Wide so the .split pairs fill the column rather than the 48rem reading
measure (same pattern as Status). */}}
{{define "wide"}}wide{{end}}
{{define "content"}}
<h1>{{.Domain.Name}}</h1>
@@ -9,8 +13,8 @@
{{if .NewCred}}
<div class="card credential" id="new-credential">
<h2>New application password</h2>
<p class="muted">This password is shown <strong>once only</strong> and is not
stored. Copy it now — if it is lost, regenerate a new one.</p>
<p class="muted">Shown <strong>once only</strong> and not stored. Copy it now
— if it is lost, regenerate a new one.</p>
<label>Login</label>
<div class="code-row">
<span class="code">{{.NewCred.Login}}</span>
@@ -24,202 +28,197 @@
</div>
{{end}}
<div class="card" id="dkim">
<h2>DKIM DNS record</h2>
<p class="muted">Publish this TXT record in the DNS for <strong>{{.Domain.Name}}</strong>.
It is not a secret and can be viewed at any time.</p>
<div class="split">
<div class="card" id="dkim">
<h2>DKIM DNS record</h2>
<p class="muted">Publish this TXT for <strong>{{.Domain.Name}}</strong>.
It is not a secret.</p>
<label>Host / name</label>
<div class="code-row">
<span class="code">{{.Record.Name}}</span>
<button type="button" class="copy">Copy</button>
</div>
<label>Type</label>
<span class="code">TXT</span>
<label>Value</label>
<div class="code-row">
<span class="code">{{.Record.Value}}</span>
<button type="button" class="copy">Copy</button>
</div>
<p class="muted">Mail is signed with selector <strong>{{.Domain.DKIMSelector}}</strong>.</p>
</div>
<div class="card" id="spf-dmarc">
<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. SelfPost is a send-only relay — most operators have no inbox on the
sending domain, so the suggested DMARC record omits <code>rua=</code> unless a
report address is configured below or in <a href="/settings">Settings</a>.
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.</p>
<form method="post" action="/domains/{{.Domain.ID}}/dmarc">
<label for="dmarc_rua_mode">DMARC aggregate reports (rua=)</label>
<select id="dmarc_rua_mode" name="dmarc_rua_mode">
<option value="inherit"{{if eq .DMARCRuaMode "inherit"}} selected{{end}}>Same as Settings{{if .ProfileDMARCEmail}} ({{.ProfileDMARCEmail}}){{end}}</option>
<option value="none"{{if eq .DMARCRuaMode "none"}} selected{{end}}>No aggregate reports</option>
<option value="custom"{{if eq .DMARCRuaMode "custom"}} selected{{end}}>Custom address</option>
</select>
<label for="dmarc_rua_email">Custom report address</label>
<input id="dmarc_rua_email" name="dmarc_rua_email" type="email"
autocapitalize="none" spellcheck="false" value="{{.DMARCRuaCustom}}"
placeholder="reports@your-mail-domain.com">
<button type="submit">Save DMARC report settings</button>
</form>
<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{{if eq .DMARCSource "settings"}} <span class="muted">(from Settings)</span>{{else if eq .DMARCSource "custom"}} <span class="muted">(custom)</span>{{else if eq .DMARCSource "none"}} <span class="muted">(no reports)</span>{{end}}</label>
<div class="code-row">
<span class="code">{{.DMARCExample}}</span>
<button type="button" class="copy">Copy</button>
</div>
{{if .SameDomainRUA}}
<p class="error">The report address is on this sending domain. SelfPost does
not receive inbound mail — use a mailbox elsewhere or wait for in-panel report
reception in a future release.</p>
{{end}}
{{if .NeedsReportAuth}}
<label>Report authorization — host / name</label>
<div class="code-row">
<span class="code">{{.ReportAuthName}}</span>
<button type="button" class="copy">Copy</button>
</div>
<label>Report authorization — value</label>
<div class="code-row">
<span class="code">{{.ReportAuthValue}}</span>
<button type="button" class="copy">Copy</button>
</div>
{{end}}
<p class="muted"><code>p=none</code> changes nothing about delivery. Omit
<code>rua=</code> on a send-only domain, or point it at a mailbox that
receives inbound mail. Tighten to <code>p=quarantine</code> and then
<code>p=reject</code> once aggregate reports look clean.</p>
</div>
<div class="card" id="dns-status">
<h2>DNS status <span class="st st-{{.DNS.Overall}}">{{.DNS.Overall}}</span></h2>
<p class="muted">What DNS publishes for <strong>{{.Domain.Name}}</strong> right
now, checked against the key this server signs with. Results are cached for a
few minutes — after publishing a record, use <em>Re-check</em>.</p>
<label>DKIM <span class="st st-{{.DNS.DKIM.Status}}">{{.DNS.DKIM.Status}}</span></label>
<p class="{{if eq .DNS.DKIM.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DKIM.Detail}}</p>
{{if .DNS.DKIM.Records}}<span class="code">{{range .DNS.DKIM.Records}}{{.}}
{{end}}</span>{{end}}
<label>SPF <span class="st st-{{.DNS.SPF.Status}}">{{.DNS.SPF.Status}}</span></label>
<p class="{{if eq .DNS.SPF.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.SPF.Detail}}</p>
{{if .DNS.SPF.Records}}<span class="code">{{range .DNS.SPF.Records}}{{.}}
{{end}}</span>{{end}}
<p class="muted">The SPF check is deliberately shallow: it looks for a
mechanism that literally covers this server's address and does not follow
<code>include:</code> or <code>redirect=</code>, so a record that authorises
the server through an include is reported as “cannot tell”, not as a failure.</p>
<label>DMARC <span class="st st-{{.DNS.DMARC.Status}}">{{.DNS.DMARC.Status}}</span></label>
<p class="{{if eq .DNS.DMARC.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARC.Detail}}</p>
{{if .DNS.DMARC.Records}}<span class="code">{{range .DNS.DMARC.Records}}{{.}}
{{end}}</span>{{end}}
{{if .DNS.DMARCReportAuth.Status}}
<label>Report authorization <span class="st st-{{.DNS.DMARCReportAuth.Status}}">{{.DNS.DMARCReportAuth.Status}}</span></label>
<p class="{{if eq .DNS.DMARCReportAuth.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARCReportAuth.Detail}}</p>
{{if .DNS.DMARCReportAuth.Records}}<span class="code">{{range .DNS.DMARCReportAuth.Records}}{{.}}
{{end}}</span>{{end}}
{{end}}
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/dns-recheck">
<button type="submit">Re-check</button>
</form>
</div>
<div class="card" id="settings">
<h2>Sending server settings</h2>
<p class="muted">Point the mail client or script at these settings and
authenticate with an application login and password from the
<strong>Applications</strong> section below. They are the same for every
domain on this server.</p>
<label>Server</label>
<div class="code-row">
<span class="code">{{.Hostname}}</span>
<button type="button" class="copy">Copy</button>
</div>
<label>Port and encryption</label>
<span class="code">465 — SSL/TLS (implicit){{if .SubmissionEnabled}}
587 — STARTTLS (submission){{end}}</span>
<p class="muted">Authentication is required on every port. The username is the
application's login (see the table below) and the password is the one shown
once when that application was created or its password regenerated — if it was
lost, generate a new one.</p>
</div>
{{/* Create form above the list, the same order the domains page uses for
"Add a sending domain" above "Domains". */}}
<div class="card" id="add-application">
<h2>Add an application</h2>
<form method="post" action="/domains/{{.Domain.ID}}/applications">
<label for="login">Login</label>
<input id="login" name="login" type="text" placeholder="prod-server"
autocomplete="off" autocapitalize="none" spellcheck="false"
value="{{.FormLogin}}" required>
<label for="mode">Address mode</label>
<select id="mode" name="mode" data-list-mode="{{.List}}">
<option value="{{.Wildcard}}" {{if eq .FormMode .Wildcard}}selected{{end}}>Any address of the domain</option>
<option value="{{.List}}" {{if eq .FormMode .List}}selected{{end}}>Specific addresses (list)</option>
</select>
<div data-addresses>
<label for="addresses">Addresses (one per line or comma-separated)</label>
<textarea id="addresses" name="addresses" rows="3"
placeholder="alerts@{{.Domain.Name}}">{{.FormAddrs}}</textarea>
<label>Host / name</label>
<div class="code-row">
<span class="code">{{.Record.Name}}</span>
<button type="button" class="copy">Copy</button>
</div>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<button type="submit">Create application</button>
</form>
<p class="muted">A strong password is generated and shown once. The login must
be unique across all domains and may contain letters, digits, '.', '-' and '_'.</p>
<label>Type</label>
<span class="code">TXT</span>
<label>Value</label>
<div class="code-row">
<span class="code">{{.Record.Value}}</span>
<button type="button" class="copy">Copy</button>
</div>
<p class="muted">Signed with selector <strong>{{.Domain.DKIMSelector}}</strong>.</p>
</div>
<div class="card" id="dns-status">
<h2>DNS status <span class="st st-{{.DNS.Overall}}">{{.DNS.Overall}}</span></h2>
<p class="muted">Cached a few minutes — use <em>Re-check</em> after
publishing.</p>
<label>DKIM <span class="st st-{{.DNS.DKIM.Status}}">{{.DNS.DKIM.Status}}</span></label>
<p class="{{if eq .DNS.DKIM.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DKIM.Detail}}</p>
{{if .DNS.DKIM.Records}}<span class="code">{{range .DNS.DKIM.Records}}{{.}}
{{end}}</span>{{end}}
<label>SPF <span class="st st-{{.DNS.SPF.Status}}">{{.DNS.SPF.Status}}</span></label>
<p class="{{if eq .DNS.SPF.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.SPF.Detail}}</p>
{{if .DNS.SPF.Records}}<span class="code">{{range .DNS.SPF.Records}}{{.}}
{{end}}</span>{{end}}
<p class="muted">Shallow check: literal address only, no
<code>include:</code> / <code>redirect=</code>.</p>
<label>DMARC <span class="st st-{{.DNS.DMARC.Status}}">{{.DNS.DMARC.Status}}</span></label>
<p class="{{if eq .DNS.DMARC.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARC.Detail}}</p>
{{if .DNS.DMARC.Records}}<span class="code">{{range .DNS.DMARC.Records}}{{.}}
{{end}}</span>{{end}}
{{if .DNS.DMARCReportAuth.Status}}
<label>Report authorization <span class="st st-{{.DNS.DMARCReportAuth.Status}}">{{.DNS.DMARCReportAuth.Status}}</span></label>
<p class="{{if eq .DNS.DMARCReportAuth.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARCReportAuth.Detail}}</p>
{{if .DNS.DMARCReportAuth.Records}}<span class="code">{{range .DNS.DMARCReportAuth.Records}}{{.}}
{{end}}</span>{{end}}
{{end}}
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/dns-recheck">
<button type="submit">Re-check</button>
</form>
</div>
</div>
<div class="split">
<div class="card" id="spf">
<h2>SPF record</h2>
<p class="muted">Suggested TXT. If the domain already has SPF, merge this
server's mechanism into it — do not add a second record.</p>
<label>Host / name</label>
<div class="code-row">
<span class="code">{{.Domain.Name}}</span>
<button type="button" class="copy">Copy</button>
</div>
<label>Value</label>
<div class="code-row">
<span class="code">{{.SPFExample}}</span>
<button type="button" class="copy">Copy</button>
</div>
</div>
<div class="card" id="dmarc">
<h2>DMARC record</h2>
<p class="muted">Policy TXT. SelfPost is send-only — omit <code>rua=</code>
or point it at a mailbox elsewhere (<a href="/settings">Settings</a> default
or custom below).</p>
<form method="post" action="/domains/{{.Domain.ID}}/dmarc">
<label for="dmarc_rua_mode">Aggregate reports (rua=)</label>
<select id="dmarc_rua_mode" name="dmarc_rua_mode">
<option value="inherit"{{if eq .DMARCRuaMode "inherit"}} selected{{end}}>Same as Settings{{if .ProfileDMARCEmail}} ({{.ProfileDMARCEmail}}){{end}}</option>
<option value="none"{{if eq .DMARCRuaMode "none"}} selected{{end}}>No aggregate reports</option>
<option value="custom"{{if eq .DMARCRuaMode "custom"}} selected{{end}}>Custom address</option>
</select>
<label for="dmarc_rua_email">Custom report address</label>
<input id="dmarc_rua_email" name="dmarc_rua_email" type="email"
autocapitalize="none" spellcheck="false" value="{{.DMARCRuaCustom}}"
placeholder="reports@your-mail-domain.com">
<button type="submit">Save DMARC report settings</button>
</form>
<label>Host / name</label>
<div class="code-row">
<span class="code">{{.DMARCName}}</span>
<button type="button" class="copy">Copy</button>
</div>
<label>Value{{if eq .DMARCSource "settings"}} <span class="muted">(from Settings)</span>{{else if eq .DMARCSource "custom"}} <span class="muted">(custom)</span>{{else if eq .DMARCSource "none"}} <span class="muted">(no reports)</span>{{end}}</label>
<div class="code-row">
<span class="code">{{.DMARCExample}}</span>
<button type="button" class="copy">Copy</button>
</div>
{{if .SameDomainRUA}}
<p class="error">The report address is on this sending domain. SelfPost does
not receive inbound mail — use a mailbox elsewhere or wait for in-panel report
reception in a future release.</p>
{{end}}
{{if .NeedsReportAuth}}
<label>Report authorization — host / name</label>
<div class="code-row">
<span class="code">{{.ReportAuthName}}</span>
<button type="button" class="copy">Copy</button>
</div>
<label>Report authorization — value</label>
<div class="code-row">
<span class="code">{{.ReportAuthValue}}</span>
<button type="button" class="copy">Copy</button>
</div>
{{end}}
<p class="muted"><code>p=none</code> does not affect delivery. Tighten to
<code>p=quarantine</code> then <code>p=reject</code> once reports look clean.</p>
</div>
</div>
<div class="split">
<div class="card" id="settings">
<h2>Sending server settings</h2>
<p class="muted">Same for every domain. Authenticate with an application
login from below.</p>
<label>Server</label>
<div class="code-row">
<span class="code">{{.Hostname}}</span>
<button type="button" class="copy">Copy</button>
</div>
<label>Port and encryption</label>
<span class="code">465 — SSL/TLS (implicit){{if .SubmissionEnabled}}
587 — STARTTLS (submission){{end}}</span>
<p class="muted">Auth required on every port. The password is shown once at
create or regenerate.</p>
</div>
{{/* Create form beside settings, mirroring "Add a sending domain" above the
domains list. */}}
<div class="card" id="add-application">
<h2>Add an application</h2>
<form method="post" action="/domains/{{.Domain.ID}}/applications">
<label for="login">Login</label>
<input id="login" name="login" type="text" placeholder="prod-server"
autocomplete="off" autocapitalize="none" spellcheck="false"
value="{{.FormLogin}}" required>
<label for="mode">Address mode</label>
<select id="mode" name="mode" data-list-mode="{{.List}}">
<option value="{{.Wildcard}}" {{if eq .FormMode .Wildcard}}selected{{end}}>Any address of the domain</option>
<option value="{{.List}}" {{if eq .FormMode .List}}selected{{end}}>Specific addresses (list)</option>
</select>
<div data-addresses>
<label for="addresses">Addresses (one per line or comma-separated)</label>
<textarea id="addresses" name="addresses" rows="3"
placeholder="alerts@{{.Domain.Name}}">{{.FormAddrs}}</textarea>
</div>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<button type="submit">Create application</button>
</form>
<p class="muted">Password shown once. Login unique across domains; letters,
digits, '.', '-' and '_'.</p>
</div>
</div>
<div class="card" id="applications">
<h2>Applications</h2>
<p class="muted">Each application is a SASL login/password an app or script
uses to send mail as this domain. A login may send from any address of the
domain (<em>wildcard</em>) or only from a fixed list of addresses.</p>
<p class="muted">SASL logins for this domain — wildcard (*@domain) or a fixed
address list.</p>
{{if .Apps}}
<ul class="apps">
@@ -298,79 +297,69 @@
{{end}}
</div>
<div class="card" id="rate-limit">
<h2>Sending rate limit (domain)</h2>
<p class="muted">Optional level-2 limit (spec 7.4): cap how many messages this
domain may send from its expected client IP(s) within a time window, summed
across all its applications. It counts messages — one message to many
recipients counts once. Leave the IP list empty to disable it and rely only on
the global level-1 limit. Applications that send from changing IPs should be
left unbound here.</p>
<div class="split">
<div class="card" id="rate-limit">
<h2>Sending rate limit (domain)</h2>
<p class="muted">Optional level-2 cap across this domain's applications from
the listed client IPs. Empty IP list = inactive (level-1 only).</p>
<p class="muted">Status:
{{if .DomainHasRL}}<strong>active</strong>{{else}}inactive (level-1 only){{end}}.</p>
<p class="muted">Status:
{{if .DomainHasRL}}<strong>active</strong>{{else}}inactive (level-1 only){{end}}.</p>
<form method="post" action="/domains/{{.Domain.ID}}/ratelimit">
<label for="d_ips">Expected client IPs (one per line or comma-separated)</label>
<textarea id="d_ips" name="allowed_ips" rows="2"
placeholder="203.0.113.10">{{.DomainRLIPs}}</textarea>
<form method="post" action="/domains/{{.Domain.ID}}/ratelimit">
<label for="d_ips">Expected client IPs (one per line or comma-separated)</label>
<textarea id="d_ips" name="allowed_ips" rows="2"
placeholder="203.0.113.10">{{.DomainRLIPs}}</textarea>
<label for="d_max">Message limit</label>
<input id="d_max" name="max_messages" type="number" min="1"
value="{{.DomainRLMax}}" placeholder="1000">
<label for="d_max">Message limit</label>
<input id="d_max" name="max_messages" type="number" min="1"
value="{{.DomainRLMax}}" placeholder="1000">
<label for="d_win">Window (seconds)</label>
<input id="d_win" name="window_seconds" type="number" min="1" value="{{.DomainRLWin}}">
<label for="d_win">Window (seconds)</label>
<input id="d_win" name="window_seconds" type="number" min="1" value="{{.DomainRLWin}}">
<button type="submit">Save limit</button>
</form>
{{if .DomainHasRL}}
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/ratelimit"
data-confirm="Remove the domain rate limit? Only the global level-1 limit will apply.">
<input type="hidden" name="clear" value="1">
<button type="submit" class="danger">Remove limit</button>
</form>
{{end}}
</div>
<button type="submit">Save limit</button>
</form>
{{if .DomainHasRL}}
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/ratelimit"
data-confirm="Remove the domain rate limit? Only the global level-1 limit will apply.">
<input type="hidden" name="clear" value="1">
<button type="submit" class="danger">Remove limit</button>
</form>
{{end}}
</div>
<div class="card" id="export">
<h2>Export domain</h2>
<p class="muted">Download this domain to move it to another SelfPost instance:
its DKIM key, selector and every application with its working password. On
import the DNS record stays the same, so no DNS change is needed.</p>
<p class="muted"><strong>The export file is a secret</strong> — it contains the
private DKIM key and application passwords. Transfer it securely and delete it
after the import, or encrypt it below and move a <code>.spde</code> file
(SelfPost domain export) instead; the import form asks for the password.</p>
{{if .ExportErr}}<p class="error">{{.ExportErr}}</p>{{end}}
<form method="post" action="/domains/{{.Domain.ID}}/export">
{{template "encryptfields" .}}
<button type="submit">Export domain</button>
</form>
<div class="card" id="export">
<h2>Export domain</h2>
<p class="muted">DKIM key, selector and application passwords for another
SelfPost instance. DNS stays the same on import.</p>
<p class="muted"><strong>Secret file</strong> — transfer securely, or encrypt
below as <code>.spde</code>.</p>
{{if .ExportErr}}<p class="error">{{.ExportErr}}</p>{{end}}
<form method="post" action="/domains/{{.Domain.ID}}/export">
{{template "encryptfields" .}}
<button type="submit">Export domain</button>
</form>
</div>
</div>
<div class="card" id="danger">
<h2>Danger zone</h2>
<p class="muted">Deleting this domain also deletes its DKIM key and every
application bound to it.</p>
<p class="muted">Deletes the DKIM key and every application on this domain.</p>
<a class="danger" href="/domains/{{.Domain.ID}}/delete">Delete domain</a>
</div>
{{end}}
{{/* The domain page's section index, shown in the navigation column (see the
"sections" block in layout.html). This is the panel's longest page — the
DNS records to publish, the checks on them, the applications and two rate
limits — and setting a domain up means going back and forth between them.
The freshly generated password is only listed while it is on the page: it
is the one card that is not always there, and the one nothing should scroll
away from silently. */}}
{{/* Section index in the navigation column (layout.html). The freshly generated
password is only listed while it is on the page. */}}
{{define "sections"}}
<div class="sections">
<p class="sections-title">On this page</p>
{{if .NewCred}}<a href="#new-credential">New application password</a>{{end}}
<a href="#dkim">DKIM DNS record</a>
<a href="#spf-dmarc">SPF and DMARC records</a>
<a href="#dns-status">DNS status</a>
<a href="#spf">SPF record</a>
<a href="#dmarc">DMARC record</a>
<a href="#settings">Sending server settings</a>
<a href="#add-application">Add an application</a>
<a href="#applications">Applications</a>
+27 -1
View File
@@ -264,7 +264,7 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
if err != nil {
t.Fatalf("New: %v", err)
}
wide := map[string]bool{"settings": true, "deliveries": true, "delivery": true, "mail_queue": true, "status": true, "system_log": true}
wide := map[string]bool{"settings": true, "deliveries": true, "delivery": true, "mail_queue": true, "status": true, "system_log": true, "domain_detail": true}
for name, page := range engine.Pages() {
var buf bytes.Buffer
if err := page.ExecuteTemplate(&buf, "wide", nil); err != nil {
@@ -280,6 +280,32 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
}
}
// The domain page pairs cards the same way Status does: four .split rows
// (DKIM|DNS, SPF|DMARC, settings|add-app, rate-limit|export) with Applications
// and Danger full-width. Losing a row silently stacks the page again.
func TestDomainDetailPageHasPairedCards(t *testing.T) {
body, err := fs.ReadFile(assetsFS, "templates/domain_detail.html")
if err != nil {
t.Fatalf("read domain_detail: %v", err)
}
src := string(body)
if got := strings.Count(src, `class="split"`); got != 4 {
t.Errorf("domain detail has %d .split rows, want 4", got)
}
for _, id := range []string{
`id="dkim"`, `id="dns-status"`, `id="spf"`, `id="dmarc"`,
`id="settings"`, `id="add-application"`, `id="applications"`,
`id="rate-limit"`, `id="export"`, `id="danger"`,
} {
if !strings.Contains(src, id) {
t.Errorf("domain detail is missing %s", id)
}
}
if strings.Contains(src, `id="spf-dmarc"`) {
t.Error("domain detail still has the combined spf-dmarc card; SPF and DMARC are separate")
}
}
// Drill-down pages carry an up-link directly under the heading and above the
// cards. A link at the bottom of a form is easy to miss and drifts from the
// rest of the panel, so the shared back_link template is mandatory on those