From 44e79c4120ebceb221f76063e0248abc26727331 Mon Sep 17 00:00:00 2001 From: Mikhail Yenuchenko Date: Wed, 12 Aug 2026 15:33:41 +0300 Subject: [PATCH] panel: pair domain detail cards like Status (SPF | DMARC) Co-authored-by: Cursor --- CHANGELOG.md | 4 + .../web/view/templates/domain_detail.html | 477 +++++++++--------- internal/web/view/templates_test.go | 28 +- 3 files changed, 264 insertions(+), 245 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 735aad0..609b348 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/internal/web/view/templates/domain_detail.html b/internal/web/view/templates/domain_detail.html index 0f894be..bd20a05 100644 --- a/internal/web/view/templates/domain_detail.html +++ b/internal/web/view/templates/domain_detail.html @@ -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"}}

{{.Domain.Name}}

@@ -9,8 +13,8 @@ {{if .NewCred}}

New application password

-

This password is shown once only and is not - stored. Copy it now — if it is lost, regenerate a new one.

+

Shown once only and not stored. Copy it now + — if it is lost, regenerate a new one.

{{.NewCred.Login}} @@ -24,202 +28,197 @@
{{end}} -
-

DKIM DNS record

-

Publish this TXT record in the DNS for {{.Domain.Name}}. - It is not a secret and can be viewed at any time.

+
+
+

DKIM DNS record

+

Publish this TXT for {{.Domain.Name}}. + It is not a secret.

- -
- {{.Record.Name}} - -
- - - TXT - - -
- {{.Record.Value}} - -
- -

Mail is signed with selector {{.Domain.DKIMSelector}}.

-
- -
-

SPF and DMARC records

-

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 rua= unless a - report address is configured below or in Settings. - Publish both as TXT records.

- - -
- {{.Domain.Name}} - -
- - -
- {{.SPFExample}} - -
- -

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 all term.

- -
- - - - - - - -
- - -
- {{.DMARCName}} - -
- - -
- {{.DMARCExample}} - -
- - {{if .SameDomainRUA}} -

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.

- {{end}} - - {{if .NeedsReportAuth}} - -
- {{.ReportAuthName}} - -
- - -
- {{.ReportAuthValue}} - -
- {{end}} - -

p=none changes nothing about delivery. Omit - rua= on a send-only domain, or point it at a mailbox that - receives inbound mail. Tighten to p=quarantine and then - p=reject once aggregate reports look clean.

-
- -
-

DNS status {{.DNS.Overall}}

-

What DNS publishes for {{.Domain.Name}} right - now, checked against the key this server signs with. Results are cached for a - few minutes — after publishing a record, use Re-check.

- - -

{{.DNS.DKIM.Detail}}

- {{if .DNS.DKIM.Records}}{{range .DNS.DKIM.Records}}{{.}} -{{end}}{{end}} - - -

{{.DNS.SPF.Detail}}

- {{if .DNS.SPF.Records}}{{range .DNS.SPF.Records}}{{.}} -{{end}}{{end}} -

The SPF check is deliberately shallow: it looks for a - mechanism that literally covers this server's address and does not follow - include: or redirect=, so a record that authorises - the server through an include is reported as “cannot tell”, not as a failure.

- - -

{{.DNS.DMARC.Detail}}

- {{if .DNS.DMARC.Records}}{{range .DNS.DMARC.Records}}{{.}} -{{end}}{{end}} - - {{if .DNS.DMARCReportAuth.Status}} - -

{{.DNS.DMARCReportAuth.Detail}}

- {{if .DNS.DMARCReportAuth.Records}}{{range .DNS.DMARCReportAuth.Records}}{{.}} -{{end}}{{end}} - {{end}} - -
- -
-
- -
-

Sending server settings

-

Point the mail client or script at these settings and - authenticate with an application login and password from the - Applications section below. They are the same for every - domain on this server.

- - -
- {{.Hostname}} - -
- - - 465 — SSL/TLS (implicit){{if .SubmissionEnabled}} -587 — STARTTLS (submission){{end}} - -

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.

-
- -{{/* Create form above the list, the same order the domains page uses for - "Add a sending domain" above "Domains". */}} -
-

Add an application

-
- - - - - - -
- - + +
+ {{.Record.Name}} +
- {{if .Error}}

{{.Error}}

{{end}} - - -

A strong password is generated and shown once. The login must - be unique across all domains and may contain letters, digits, '.', '-' and '_'.

+ + TXT + + +
+ {{.Record.Value}} + +
+ +

Signed with selector {{.Domain.DKIMSelector}}.

+
+ +
+

DNS status {{.DNS.Overall}}

+

Cached a few minutes — use Re-check after + publishing.

+ + +

{{.DNS.DKIM.Detail}}

+ {{if .DNS.DKIM.Records}}{{range .DNS.DKIM.Records}}{{.}} +{{end}}{{end}} + + +

{{.DNS.SPF.Detail}}

+ {{if .DNS.SPF.Records}}{{range .DNS.SPF.Records}}{{.}} +{{end}}{{end}} +

Shallow check: literal address only, no + include: / redirect=.

+ + +

{{.DNS.DMARC.Detail}}

+ {{if .DNS.DMARC.Records}}{{range .DNS.DMARC.Records}}{{.}} +{{end}}{{end}} + + {{if .DNS.DMARCReportAuth.Status}} + +

{{.DNS.DMARCReportAuth.Detail}}

+ {{if .DNS.DMARCReportAuth.Records}}{{range .DNS.DMARCReportAuth.Records}}{{.}} +{{end}}{{end}} + {{end}} + +
+ +
+
+
+ +
+
+

SPF record

+

Suggested TXT. If the domain already has SPF, merge this + server's mechanism into it — do not add a second record.

+ + +
+ {{.Domain.Name}} + +
+ + +
+ {{.SPFExample}} + +
+
+ +
+

DMARC record

+

Policy TXT. SelfPost is send-only — omit rua= + or point it at a mailbox elsewhere (Settings default + or custom below).

+ +
+ + + + + + + +
+ + +
+ {{.DMARCName}} + +
+ + +
+ {{.DMARCExample}} + +
+ + {{if .SameDomainRUA}} +

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.

+ {{end}} + + {{if .NeedsReportAuth}} + +
+ {{.ReportAuthName}} + +
+ + +
+ {{.ReportAuthValue}} + +
+ {{end}} + +

p=none does not affect delivery. Tighten to + p=quarantine then p=reject once reports look clean.

+
+
+ +
+
+

Sending server settings

+

Same for every domain. Authenticate with an application + login from below.

+ + +
+ {{.Hostname}} + +
+ + + 465 — SSL/TLS (implicit){{if .SubmissionEnabled}} +587 — STARTTLS (submission){{end}} + +

Auth required on every port. The password is shown once at + create or regenerate.

+
+ + {{/* Create form beside settings, mirroring "Add a sending domain" above the + domains list. */}} +
+

Add an application

+
+ + + + + + +
+ + +
+ + {{if .Error}}

{{.Error}}

{{end}} + +
+

Password shown once. Login unique across domains; letters, + digits, '.', '-' and '_'.

+

Applications

-

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 (wildcard) or only from a fixed list of addresses.

+

SASL logins for this domain — wildcard (*@domain) or a fixed + address list.

{{if .Apps}}
    @@ -298,79 +297,69 @@ {{end}}
-
-

Sending rate limit (domain)

-

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.

+
+
+

Sending rate limit (domain)

+

Optional level-2 cap across this domain's applications from + the listed client IPs. Empty IP list = inactive (level-1 only).

-

Status: - {{if .DomainHasRL}}active{{else}}inactive (level-1 only){{end}}.

+

Status: + {{if .DomainHasRL}}active{{else}}inactive (level-1 only){{end}}.

-
- - + + + - - + + - - + + - -
- {{if .DomainHasRL}} -
- - -
- {{end}} -
+ + + {{if .DomainHasRL}} +
+ + +
+ {{end}} +
-
-

Export domain

-

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.

-

The export file is a secret — 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 .spde file - (SelfPost domain export) instead; the import form asks for the password.

- {{if .ExportErr}}

{{.ExportErr}}

{{end}} -
- {{template "encryptfields" .}} - -
+
+

Export domain

+

DKIM key, selector and application passwords for another + SelfPost instance. DNS stays the same on import.

+

Secret file — transfer securely, or encrypt + below as .spde.

+ {{if .ExportErr}}

{{.ExportErr}}

{{end}} +
+ {{template "encryptfields" .}} + +
+

Danger zone

-

Deleting this domain also deletes its DKIM key and every - application bound to it.

+

Deletes the DKIM key and every application on this domain.

Delete domain
{{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"}}

On this page

{{if .NewCred}}New application password{{end}} DKIM DNS record - SPF and DMARC records DNS status + SPF record + DMARC record Sending server settings Add an application Applications diff --git a/internal/web/view/templates_test.go b/internal/web/view/templates_test.go index 4006885..51d69a4 100644 --- a/internal/web/view/templates_test.go +++ b/internal/web/view/templates_test.go @@ -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