diff --git a/CHANGELOG.md b/CHANGELOG.md
index 28744f8..4fdb818 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -94,6 +94,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
delete), the application form, the password shown once and the delete
confirmation. `dashboard`, `domain_detail` and `domain_delete` are off
the ratchet.
+- panel-redesign (stage 2): the **Outbound log**, the message page (facts,
+ history, the lines of `mail.log`) and the three **DMARC report** pages are
+ in the new design. The log refreshes its rows without touching the filter
+ being chosen. `deliveries`, `delivery`, `dmarc`, `dmarc_domain` and
+ `dmarc_report` are off the ratchet.
### Changed
@@ -127,6 +132,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
default is its own choice, and "My default instead" is the change.
- The auto rate-limit fields follow the limit mode on the domain and
application forms (the script applied only the first matching rule).
+- The DMARC page of a domain no longer answers 500 once the domain has a
+ reported source.
### Removed
diff --git a/docs/assets/panel-redesign/evidence/dmarc-domain.png b/docs/assets/panel-redesign/evidence/dmarc-domain.png
new file mode 100644
index 0000000..c697ab7
Binary files /dev/null and b/docs/assets/panel-redesign/evidence/dmarc-domain.png differ
diff --git a/docs/assets/panel-redesign/evidence/dmarc-report.png b/docs/assets/panel-redesign/evidence/dmarc-report.png
new file mode 100644
index 0000000..01f1dfe
Binary files /dev/null and b/docs/assets/panel-redesign/evidence/dmarc-report.png differ
diff --git a/docs/assets/panel-redesign/evidence/dmarc.png b/docs/assets/panel-redesign/evidence/dmarc.png
new file mode 100644
index 0000000..21a251f
Binary files /dev/null and b/docs/assets/panel-redesign/evidence/dmarc.png differ
diff --git a/docs/assets/panel-redesign/evidence/out-log.png b/docs/assets/panel-redesign/evidence/out-log.png
new file mode 100644
index 0000000..b76ffc5
Binary files /dev/null and b/docs/assets/panel-redesign/evidence/out-log.png differ
diff --git a/docs/assets/panel-redesign/evidence/out-message.png b/docs/assets/panel-redesign/evidence/out-message.png
new file mode 100644
index 0000000..a33e25c
Binary files /dev/null and b/docs/assets/panel-redesign/evidence/out-message.png differ
diff --git a/docs/plans/panel-redesign.md b/docs/plans/panel-redesign.md
index 552fb02..9b20b05 100644
--- a/docs/plans/panel-redesign.md
+++ b/docs/plans/panel-redesign.md
@@ -516,7 +516,7 @@ only when both are done. Every stage-2 step ends with the evidence of
- [x] Signed-out pages: login, setup — **Sonnet**
- [x] Overview, Health, and the Account / Settings split — **Sonnet**
- [x] Outbound: domains, domain, domain settings, application form, shown-once password, delete — **Sonnet**
-- [ ] Outbound: log, message, DMARC hub / domain / report — **Sonnet**
+- [x] Outbound: log, message, DMARC hub / domain / report — **Sonnet**
- [ ] Inbound: domains, domain with the *Spam filter* box, delete — **Sonnet**
- [ ] Server: system log, backup, users, user form, delete; Help as `help_topic` partials, the drawer removed — **Sonnet**
- [ ] Queues: old content inside the new shell (outbound), Inbound › Queue hidden — **Sonnet**
diff --git a/docs/roadmap.md b/docs/roadmap.md
index c908f45..273d30b 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -39,7 +39,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| panel-notifications | E-mail notifications about important events | candidate | — | — |
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | candidate | — | — |
| password-reset | Password reset by e-mail | candidate | — | — |
-| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 18/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
+| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 19/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
**Recommended order** (not binding; owner, 2026-10-09: feature order follows
diff --git a/internal/web/handlers/e2e_markup_probe_test.go b/internal/web/handlers/e2e_markup_probe_test.go
index c2a50a9..8faf0c3 100644
--- a/internal/web/handlers/e2e_markup_probe_test.go
+++ b/internal/web/handlers/e2e_markup_probe_test.go
@@ -13,16 +13,16 @@ func TestE2ESendLogStatusMarkupDrift(t *testing.T) {
rendered := getBody(t, h.HandleDeliveriesRows, "/outbound/log/fragment")
// These mirror test/e2e/main_test.go — keep in sync when fixing the e2e gate.
- statusCellPattern := regexp.MustCompile(`class="st st-[^"]+">(queued|sent|deferred|bounced|rejected)`)
+ statusCellPattern := regexp.MustCompile(`(queued|sent|deferred|bounced|rejected)`)
containsCell := func(html, needle string) bool {
- return strings.Contains(html, ``+needle+``)
+ return strings.Contains(html, ``+needle+``)
}
if statusCellPattern.FindStringSubmatch(rendered) == nil {
- t.Fatalf("e2e statusCellPattern does not match rendered send-log rows:\n%s", snippet(rendered, `class="status"`))
+ t.Fatalf("e2e statusCellPattern does not match rendered send-log rows:\n%s", snippet(rendered, `
`))
}
if !containsCell(rendered, "sent") {
- t.Fatalf("e2e containsCell does not match rendered send-log rows:\n%s", snippet(rendered, `class="status"`))
+ t.Fatalf("e2e containsCell does not match rendered send-log rows:\n%s", snippet(rendered, ` | `))
}
}
diff --git a/internal/web/handlers/handlers_dmarc_pages_test.go b/internal/web/handlers/handlers_dmarc_pages_test.go
new file mode 100644
index 0000000..29bddf6
--- /dev/null
+++ b/internal/web/handlers/handlers_dmarc_pages_test.go
@@ -0,0 +1,204 @@
+package handlers
+
+import (
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/mixeme/selfpost/internal/dmarc"
+ "github.com/mixeme/selfpost/internal/store"
+)
+
+// The DMARC pages, rendered from the real stores and the handlers. Their markup
+// is held to the mockups by the guards in internal/web/view; what is checked
+// here is that the handlers fill the typed pages from what is stored and that
+// each page reaches only what its viewer may see.
+
+// dmarcStand is a panel with report ingest on and one report stored for the
+// stand's domain: a relay that passed and a source that failed both checks.
+type dmarcStand struct {
+ appStand
+ reportID int64
+}
+
+func newDMARCStand(t *testing.T) dmarcStand {
+ t.Helper()
+ s := dmarcStand{appStand: newAppStand(t)}
+ s.h.cfg.DMARCEnabled, s.h.cfg.Hostname = true, "mail.example.org"
+ s.h.dmarc = dmarc.NewService(s.h.store, nil, "mail.example.org", true)
+ received := time.Now().UTC().Add(-2 * time.Hour)
+ id, err := s.h.store.InsertDMARCReport(store.DMARCReport{
+ Domain: "example.org", Reporter: "Outlook.com", ReportID: "5f1c9a0e7b2d4e61a3",
+ PeriodBegin: received.Add(-30 * time.Hour), PeriodEnd: received.Add(-6 * time.Hour), ReceivedAt: received,
+ ContactEmail: "noreply-dmarc@outlook.example", PolicyP: "none", PolicySP: "none", PolicyPct: 100,
+ PolicyADKIM: "r", PolicyASPF: "r", PassCount: 1903, FailCount: 7, Recipient: "dmarc-reports@mail.example.org",
+ Records: []store.DMARCReportRecord{
+ {SourceIP: "203.0.113.25", Count: 1903, Disposition: "none", SPFResult: "pass", DKIMResult: "pass", HeaderFrom: "example.org"},
+ {SourceIP: "198.51.100.44", Count: 7, Disposition: "none", SPFResult: "fail", DKIMResult: "fail", HeaderFrom: "example.org"},
+ },
+ })
+ if err != nil {
+ t.Fatalf("InsertDMARCReport: %v", err)
+ }
+ s.reportID = id
+ return s
+}
+
+func TestDMARCHubShowsIngestAndRecentReports(t *testing.T) {
+ s := newDMARCStand(t)
+ body := getBody(t, s.h.HandleDMARCList, "/outbound/dmarc")
+ has(t, "hub", body,
+ `DMARC reports`, `Ingest`, `ok`,
+ `1 kept · 0 parse failures`, `dmarc-reports@mail.example.org`, `500 reports or 90 days`,
+ `Recent reports`, `example.org`, `Outlook.com`,
+ " | 1\u00a0903 | ", `7`, `href="/outbound/dmarc/reports/`+idStr(s.reportID)+`">View`)
+ lacks(t, "hub", body, `None yet`)
+}
+
+// Before the first report the page says how to get one rather than showing an
+// empty table next to a healthy-looking stamp.
+func TestDMARCHubBeforeTheFirstReport(t *testing.T) {
+ s := newAppStand(t)
+ s.h.cfg.DMARCEnabled, s.h.cfg.Hostname = true, "mail.example.org"
+ s.h.dmarc = dmarc.NewService(s.h.store, nil, "mail.example.org", true)
+ body := getBody(t, s.h.HandleDMARCList, "/outbound/dmarc")
+ has(t, "hub", body, `quiet`, `None yet`, `rua=`,
+ `publish MX for mail.example.org`, `No reports yet.`)
+ lacks(t, "hub", body, `alert(1)`, ReportID: `">
`,
+ PeriodBegin: time.Now().Add(-48 * time.Hour), PeriodEnd: time.Now().Add(-24 * time.Hour), ReceivedAt: time.Now(),
+ ContactEmail: `">x`, PolicyP: `none`, PassCount: 1, FailCount: 1,
+ Records: []store.DMARCReportRecord{{SourceIP: `1.2.3.4`, Count: 2, Disposition: `none`, SPFResult: `x`, DKIMResult: "fail", HeaderFrom: ``}},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ for name, body := range map[string]string{
+ "hub": getBody(t, s.h.HandleDMARCList, "/outbound/dmarc"),
+ "domain": pageOf(t, s.h.HandleDMARCDomain, "/outbound/dmarc/domains/1"),
+ "report": reportPage(t, s, id),
+ } {
+ lacks(t, name, body, `@example.ru`,
+ Subject: ` "quoted"`,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ rows, err := h.store.QuerySendLog(store.SendLogFilter{AllDomains: true}, 1, 0)
+ if err != nil || len(rows) != 1 {
+ t.Fatalf("query: %v (%d rows)", err, len(rows))
+ }
+ for name, body := range map[string]string{
+ "list": getBody(t, h.HandleDeliveries, "/outbound/log"),
+ "fragment": getBody(t, h.HandleDeliveriesRows, "/outbound/log/fragment"),
+ "message": getBody(t, h.HandleDelivery, "/outbound/log/"+itoa(rows[0].ID)),
+ } {
+ lacks(t, name, body, ``
+ hub := NewDMARCHub(admin(), IngestInput{Hosted: evil}).WithReports([]DMARCReportRow{{Domain: evil, Reporter: evil, Window: evil, Received: evil, Href: "/x"}})
+ dom := NewDMARCDomain(admin(), 1, evil, true, 1, 1, 7, evil).
+ WithSources([]DMARCSourceRow{{Source: evil, Disposition: evil}}).WithReports([]DMARCReportRow{{Reporter: evil, Href: "/x"}})
+ rep := NewDMARCReport(admin(), ReportInput{Reporter: evil, ReportID: evil, Domain: evil, Contact: evil, Period: evil, Received: evil,
+ PolicyP: evil, PolicyADKIM: evil, Recipient: evil}).
+ WithRecords([]DMARCRecordRow{{Source: evil, Disposition: evil, SPF: evil, DKIM: evil, HeaderFrom: evil}})
+ for name, c := range map[string]struct {
+ page string
+ data any
+ }{"hub": {"dmarc", hub}, "domain": {"dmarc-domain", dom}, "report": {"dmarc-report", rep}} {
+ pageLacks(t, name, renderSignedIn(t, c.page, c.data), ``,
+ Status: Tag{Status: ``}, Href: "/outbound/log/1"}}, 1, 1)
+ out := renderSignedIn(t, "out-log", p)
+ pageLacks(t, "Outbound log", out, ``, Status: "sent", Accepted: at, Reported: at, BackHref: "/outbound/log"}).
+ WithHistory([]Step{{Time: "t", Strong: `s`, Text: Plain(`x`)}}).
+ WithDeliveryLog([]LogLine{{Time: ``, Text: ``}}, "")
+ out := renderSignedIn(t, "out-message", p)
+ pageLacks(t, "Message", out, `