panel-redesign: three choices for a domain's report address; row actions

Follows the design and guard commits before it. Written by Sonnet,
reviewed by Opus.

The report address
  - Baseline: users.dmarc_default_mode, users.dmarc_default_address and
    domains.dmarc_rua_user_id are gone. domains.dmarc_rua alone says
    where reports go: '' is no reports (a new domain), the address
    SelfPost derives for the domain is the hosted choice (while hosted
    reports are on), anything else is a typed address.
  - Removed: the user default and its store functions, a domain
    following a user, keep / inherit, followCreator, POST /account/dmarc
    with its handler, the DMARC box of Account, the user-delete item
    about domains losing their address.
  - Domain settings offers the three choices; beside the typed address a
    "Use my e-mail" button fills in the profile e-mail (panel.js: one
    generic data-fill hook; absent without a profile e-mail; it fills,
    it does not save).
  - The DMARC record's note names the address and no user. This closes
    L4 of the security review.
  - Domain export / import carries the address as a plain string.
  - The ingest allow-list is the generic mailbox plus each domain's
    address on this host; the generic mailbox is always open now.
  - dev/migrate1x (not in the tree): a 1.x domain that inherited the
    instance default gets that address as its own, so no published rua=
    changes.

Row actions
  - row_action partial (RowAction: Label, Post, Confirm, Danger);
    panel.css has the mockup's three rules. A row of the Applications
    table is Edit, New password, Delete; the confirmation texts are the
    ones the application page's head uses. Both roles, with the checks
    of the application page.

Tests cover: a new domain has no reports; each choice saved and shown
selected; hosted refused when hosted reports are off; a bad address
refused with what was typed kept; an untouched save changes nothing in
each state; the fill button with and without a profile e-mail; deleting
a user leaves every domain's address; the row's forms, targets and
confirmations for both roles and a 404 for a stranger.

Known: if hosted reports are switched off later, a stored hosted address
reads as a typed one. docs/guide.md still describes the old choices -
the documentation step.

Evidence
  - docs/assets/panel-redesign/evidence/account.png,
    out-domain-settings.png, out-domain.png, user-delete.png, help.png:
    1440 px wide, headless Edge, real templates served locally with
    their fixtures - NOT from the container.
  - panel/audit.js on out-domain, out-domain-settings, account, help,
    components and user-delete at 1440 and 1280: 1420 texts checked,
    problems: [].
  - In a browser: the fill button sets the field and does not submit;
    the row buttons have the font, size and colour of the Edit link,
    Delete the red of the other delete links; data-confirm fires on
    both. Hover and focus states were not observed.
  - go test -count=1 ./internal/web/... ./internal/store/...
    ./internal/dmarc/... ./internal/app/... ./internal/inbound/...
    ./cmd/panel: ok (ten packages; dev/migrate1x also ok).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-10 12:38:35 +03:00
co-authored by Claude Opus 5.5
parent e15f8896c5
commit 96db0b7a86
49 changed files with 768 additions and 1214 deletions
+34 -9
View File
@@ -99,7 +99,7 @@ func TestOutDomainRecordsSayWhatDNSHolds(t *testing.T) {
`sp2026._domainkey.example.org`, `<textarea class="textarea" rows="3" readonly>`,
`<h3>SPF</h3>`, `<span class="tag is-danger is-light">mismatch</span>`, `v=spf1 ip4:203.0.113.25 ~all`,
`In DNS now`, `v=spf1 include:_spf.google.com ~all`, `Shallow check: <code>include:</code> is not followed.`,
`<h3>DMARC</h3>`, `Report address: <a href="/outbound/domains/1/settings#reports">admin&#39;s default</a>`,
`<h3>DMARC</h3>`, `Report address: <a href="/outbound/domains/1/settings#reports">example.org@dmarc.mail.example.org</a>`,
`<code>p=none</code> does not affect delivery.`, `<a href="/outbound/dmarc/domains/1">reports</a>`,
)
if got := strings.Count(out, `In DNS now`); got != 1 {
@@ -115,7 +115,7 @@ func TestOutDomainRecordsSayWhatDNSHolds(t *testing.T) {
DKIMRecord("sp2026", "sp2026._domainkey.example.org", "v=DKIM1; p=KEY", DNSCheck{Status: "error", Detail: "No DKIM record was found."}),
SPFRecord("example.org", "v=spf1 -all", DNSCheck{Status: "warn", Detail: "The record ends in ~all.", Found: []string{"v=spf1 ~all"}}),
DMARCRecord(DMARCRecordInput{Host: "_dmarc.example.org", Value: "v=DMARC1; p=none", Check: DNSCheck{Status: "unknown", Detail: "DNS could not be reached."},
Source: "none", SettingsHref: "/outbound/domains/1/settings", SameDomain: true}),
Source: "no reports", SettingsHref: "/outbound/domains/1/settings", SameDomain: true}),
ReportAuthRecord("_report._dmarc.hub.example", "v=DMARC1;", DNSCheck{Status: "error", Detail: "Not authorized."}),
}
out = renderSignedIn(t, "out-domain", outDomainFixture().WithRecords(records, "just now"))
@@ -133,14 +133,22 @@ func TestOutDomainApplicationsTable(t *testing.T) {
out := renderSignedIn(t, "out-domain", outDomainFixture())
pageHas(t, "Domain", out,
`<th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th>`,
`<td class="sp-mono"><strong>prod-server</strong></td>`, `*@<wbr>example.org`,
`<td class="sp-mono sp-nowrap"><strong>prod-server</strong></td>`, `*@<wbr>example.org`,
`alerts@<wbr>example.org, noc@<wbr>example.org`, "1 102 msg · peak 96/h",
`<span class="tag is-success is-light">200 / h</span>`, `<span class="tag is-light">domain</span> <span class="tag is-success is-light">2 IPs</span>`,
`<a href="/outbound/domains/1/applications/2">Edit</a>`,
)
// A state change is never a link, and a table row has no control for one:
// a new password and deleting an application are on the application's page.
pageLacks(t, "Domain", out, `/password`, `/applications/1/delete`)
// A state change is never a link: New password and Delete are POST forms in
// the row, each with the question panel.js asks first, drawn like the Edit
// link beside them. Edit leads on to the application's page.
pageHas(t, "Domain", out,
`<td class="sp-actions"><a href="/outbound/domains/1/applications/1">Edit</a>`+
`<form method="post" action="/outbound/domains/1/applications/1/password" data-confirm="Generate a new password for prod-server? The current password stops working immediately."><button type="submit">New password</button></form>`+
`<form method="post" action="/outbound/domains/1/applications/1/delete" data-confirm="Delete application prod-server? Its credentials stop working immediately."><button type="submit" class="has-text-danger">Delete</button></form></td>`,
`action="/outbound/domains/1/applications/2/password" data-confirm="Generate a new password for alerts?`,
`action="/outbound/domains/1/applications/2/delete" data-confirm="Delete application alerts?`,
`New password and Delete ask for confirmation; the old password stops working at once.`,
)
p := outDomainFixture().WithApplications(nil)
out = renderSignedIn(t, "out-domain", p)
@@ -210,8 +218,11 @@ func TestOutDomainSettingsHasAFormPerBox(t *testing.T) {
`<a href="/outbound/domains">Outbound domains</a> /`, `<a href="/outbound/domains/1">example.org</a> /`,
`id="reports"`, `id="limit"`, `id="export"`,
`action="/outbound/domains/1/settings/reports"`, `name="dmarc_rua_mode"`, `data-custom-mode="custom"`, `data-custom-address`,
`name="dmarc_rua_email"`, `<option value="inherit" selected>admin&#39;s default — mix@example.org</option>`,
`<option value="hosted">`, `<option value="none">`, `<option value="custom">`, `href="/account#dmarc"`,
`name="dmarc_rua_email"`, `value="reports@acme.io"`, `<option value="none">No reports</option>`,
`<option value="hosted">SelfPost hosted — example.org@dmarc.mail.example.org</option>`,
`<option value="custom" selected>A specific address</option>`,
`<button type="button" class="button" data-fill="rua_email" data-fill-value="mix@example.org"><i class="ti ti-mail"></i>Use my e-mail</button>`,
`SelfPost hosted sends them to an address on this server`, `Changing this changes the DMARC record to publish.`,
`action="/outbound/domains/1/settings/export"`, `name="encrypt" value="1" checked data-encrypt-toggle`, `data-encrypt-fields`,
`name="password"`, `name="password_confirm"`, `minlength="12"`, `at least 12 characters`,
`action="/outbound/domains/1/settings/ratelimit"`, `name="mode"`, `data-ratelimit-mode`, `data-manual-fields`, `data-auto-fields`,
@@ -260,11 +271,25 @@ func TestOutDomainSettingsStatesTheFixtureDoesNotDraw(t *testing.T) {
pageLacks(t, "Settings", out, `Delete domain`, `/delete`)
// A refusal comes back as the flash, with the custom address that was typed.
p = outDomainSettingsFixture().WithReportAddress([]Option{{Value: "custom", Label: "Custom address"}}, "custom", `x"><i>`, nil).
p = outDomainSettingsFixture().WithReportAddress([]Option{{Value: "custom", Label: "A specific address"}}, "custom", `x"><i>`, "").
WithResult("", "Enter a valid e-mail address.")
out = renderSignedIn(t, "out-domain-settings", p)
pageHas(t, "Settings", out, `<div class="notification is-danger is-light">`, `Enter a valid e-mail address.`, `<option value="custom" selected>`)
pageLacks(t, "Settings", out, `<i>`)
// Without a profile e-mail there is nothing to fill with: no button. Without
// hosted reports on the server the sentence about them is not there either.
pageLacks(t, "Settings", out, `data-fill`, `Use my e-mail`, `SelfPost hosted`)
}
// The fill button carries the viewer's profile e-mail escaped, and only when
// there is one.
func TestReportAddressFillButton(t *testing.T) {
opts := []Option{{Value: ReportNone, Label: "No reports"}, {Value: ReportCustom, Label: "A specific address"}}
out := renderSignedIn(t, "out-domain-settings", outDomainSettingsFixture().WithReportAddress(opts, ReportNone, "", `a"><i>@x.example`))
pageHas(t, "Settings", out, `data-fill="rua_email" data-fill-value="a&#34;&gt;&lt;i&gt;@x.example"`)
pageLacks(t, "Settings", out, `<i>@`)
out = renderSignedIn(t, "out-domain-settings", outDomainSettingsFixture().WithReportAddress(opts, ReportNone, "", ""))
pageLacks(t, "Settings", out, `data-fill`, `Use my e-mail`)
}
func TestDeleteBoxNamesTheApplicationsItTakes(t *testing.T) {