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, `