panel-redesign: Outbound log, message and the DMARC report pages on the kit

Stage 2, step 4. Written by Sonnet, reviewed by Opus.

  - Five pages named after their mockups: out-log, out-message, dmarc,
    dmarc-domain, dmarc-report, with typed page data
    (view/outbound_log.go, dmarc.go), fixtures and tests. The engine
    pages deliveries and delivery are gone with their templates; the
    three DMARC templates are written anew. All five old names are off
    legacy_pages.txt.
  - The log polls its table (GET /outbound/log/fragment, as before); the
    page count in the box foot follows out of band, and the filter in
    the head is no longer swapped away while it is being chosen.
  - Handlers pass typed data; routes, query parameters, filters, paging
    and authorization are unchanged.
  - Kit: Foot.ID / Foot.OOB (attributes only, as on Head) and Fact.Small,
    the mockup's "sp-mono sp-small" value, for a report id and an rua
    that wrapped mid-word in their column.
  - test/e2e status scrapers match the new tag markup. Not run: no
    Docker here.

Fixed on the way
  - The DMARC domain page read a field its source rows do not have and
    answered 500 as soon as the domain had a source. It builds the rows
    from the store's rollup now, with a test.

Differences from the mockups, for the owner:
  - the log has no Status filter (the store filters by domain and
    application only); a refused message is "rejected", a neutral tag as
    the mockup's "rate-limited" is;
  - the message history keeps the panel's own wording with full dates and
    has no "accepted from <IP>" step - the client address is not stored;
  - dmarc: before the first report the box says "None yet" with what to
    check; a quiet mailbox is an amber "quiet" tag;
  - dmarc-domain: the stamp is pass / (pass + fail) rounded down, amber
    on any failure, red when nothing passed, absent without mail;
  - dmarc-report: the title has no year; "this relay" marks record rows
    as well as sources.

Known and left as it is (not introduced here): the DMARC hub answers 404
to a domain administrator while the menu shows the entry and the plan
gives that role the reports of its own domains. Opening it needs the
report list to refuse an empty domain filter first - for the stage-3
security review.

Evidence
  - docs/assets/panel-redesign/evidence/out-log.png, out-message.png,
    dmarc.png, dmarc-domain.png, dmarc-report.png: 1440 px wide, headless
    Edge, real templates served locally with their fixtures - NOT from
    the container (no Docker on this machine).
  - panel/audit.js on the five pages at 1440 and 1280: 784 texts checked,
    problems: [].
  - Polling of the log was exercised in a browser against a local
    fixture server with document.hidden overridden: four polls, one
    table and one foot after each, the filter untouched.
  - go test -count=1 ./internal/web/... ./cmd/panel
      ok  internal/web
      ok  internal/web/auth
      ok  internal/web/handlers
      ok  internal/web/validate
      ok  internal/web/view
      ok  cmd/panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-10 01:21:06 +03:00
co-authored by Claude Opus 5.5
parent acdfaaecd7
commit 2f1ec65b30
34 changed files with 1848 additions and 630 deletions
+10
View File
@@ -286,12 +286,18 @@ type DateInput struct {
// or "Page 1 of 9"), a Link beside it ("Older →"), an End slot at the right, or
// instead of all of it one Button that finishes the page. Bare puts Text
// directly in the foot instead of in a span, as the legends under tables do.
// ID and OOB make the foot replaceable by a polled fragment, as they do on Head:
// ID is the element's id, and OOB (set on the fragment's copy only, never on the
// page) marks it to be swapped into the page by that id. Used by the Outbound
// log, whose page count follows its rows. Both are attributes, not markup.
type Foot struct {
Text Text
Bare bool
Link *Anchor
End Text
Button *Action
ID string
OOB bool
}
// Anchor is a plain link: where it goes and what it says.
@@ -353,6 +359,10 @@ type Fact struct {
Label string
Value Text
Mono bool
// Small sets a mono value in the supporting size, for long identifiers and
// addresses that would otherwise wrap in a narrow column (the report id and
// rua of a DMARC report).
Small bool
Big bool
Note Text
}
+382
View File
@@ -0,0 +1,382 @@
package view
// The data of the DMARC report pages: the hub (templates/dmarc.html, mockup
// dmarc.html), one sending domain (dmarc_domain.html, mockup dmarc-domain.html)
// and one aggregate report (dmarc_report.html, mockup dmarc-report.html).
//
// A page is a typed struct that embeds Meta, built by a constructor and filled
// by With… methods; the handler reads the report store, the view decides what is
// said and how it is worded.
import (
"fmt"
"math"
"strconv"
"strings"
"time"
)
const outboundDMARC = "/outbound/dmarc"
// DMARCDomainHref is the address of a sending domain's reports.
func DMARCDomainHref(id int64) string { return outboundDMARC + "/domains/" + strconv.FormatInt(id, 10) }
// DMARCReportHref is the address of one report.
func DMARCReportHref(id int64) string { return outboundDMARC + "/reports/" + strconv.FormatInt(id, 10) }
// FormatReceived is the time a report was received, as the tables show it.
func FormatReceived(t time.Time) string { return t.UTC().Format("01-02 15:04") }
// ---- The hub
// DMARCHub is the data of the DMARC reports hub: whether reports are arriving
// and the latest of them.
type DMARCHub struct {
Meta
Head Head
Ingest Box
IngestFacts []Fact
Recent Box
Rows []DMARCReportRow
Empty EmptyState
}
// DMARCReportRow is one report in a table: when it was received, for which
// domain (DomainHref is empty when the domain is gone or not the viewer's), who
// sent it, the window it covers and its pass and fail counts. Domain is left
// out on the page of one domain.
type DMARCReportRow struct {
Received string
Domain string
DomainHref string
Reporter string
Window string
Pass int
Fail int
Href string
}
// PassText is the pass count as the table writes it.
func (r DMARCReportRow) PassText() string { return FormatCount(int64(r.Pass)) }
// FailTag is the fail count as a tag: failures are the thing to look at, so a
// non-zero count is amber and a zero is plain text.
func (r DMARCReportRow) FailTag() Tag {
return Tag{Status: "warn", Label: FormatCount(int64(r.Fail))}
}
// IngestInput is the state of report ingest: whether it is healthy, when the
// last report arrived (zero when none has), what the week kept, the address
// reports can be sent to and how long they are kept.
type IngestInput struct {
OK bool
Last time.Time
KeptThisWeek int
ParseFailures int
Hosted string
// Host is the name that must have an MX record on this server for the hosted
// address to receive anything.
Host string
RetentionMax int
RetentionDays int
}
// NewDMARCHub builds the hub.
func NewDMARCHub(m Meta, in IngestInput) *DMARCHub {
m.Title, m.Section, m.Page = "DMARC reports", "outbound", "dmarc"
status, label := "ok", "ok"
if !in.OK {
status, label = "quiet", "quiet"
}
last := Fact{Label: "Last report", Value: Plain("None yet")}
if in.Last.IsZero() {
last.Note = Rich("Point ", Code("rua="), " at the hosted address below and publish MX for ", Mono(in.Host), " on this server.")
} else {
last.Value = Plain(in.Last.UTC().Format("2006-01-02 15:04") + " UTC")
}
return &DMARCHub{
Meta: m,
Head: Head{
Kicker: "Outbound",
Title: "DMARC reports",
Lead: Rich("How receivers saw mail claiming to be from your domains. Aggregate (", Code("rua="),
") only; open a report for the parsed XML."),
},
Ingest: Box{No: "01", Title: "Ingest", End: Rich(TagOf(status, label))},
IngestFacts: []Fact{
last,
{Label: "This week", Value: Plain(FormatCount(int64(in.KeptThisWeek)) + " kept · " + plural(int64(in.ParseFailures), "parse failure", "parse failures"))},
{Label: "Hosted address", Value: Plain(in.Hosted), Mono: true},
{Label: "Retention", Value: Plain(FormatCount(int64(in.RetentionMax)) + " reports or " + plural(int64(in.RetentionDays), "day", "days"))},
},
Recent: Box{No: "02", Title: "Recent reports"},
Empty: EmptyState{Icon: "ti-report-analytics", Text: Plain("No reports yet.")},
}
}
// WithReports sets the table of the latest reports.
func (p *DMARCHub) WithReports(rows []DMARCReportRow) *DMARCHub {
p.Rows = rows
return p
}
// ---- One sending domain
// DMARCDomain is the data of a domain's reports: how its mail fared over the
// window, which sources it came from and the reports that said so.
type DMARCDomain struct {
Meta
Head Head
Sources Box
SourceRows []DMARCSourceRow
SourcesEmpty EmptyState
Reports Box
Rows []DMARCReportRow
ReportsEmpty EmptyState
}
// DMARCSourceRow is one sending address over the window: its pass and fail
// counts and what receivers did with its mail. ThisRelay marks the server the
// panel runs on.
type DMARCSourceRow struct {
Source string
ThisRelay bool
Pass int
Fail int
Disposition string
}
// PassText is the pass count as the table writes it.
func (r DMARCSourceRow) PassText() string { return FormatCount(int64(r.Pass)) }
// FailText is the fail count as the table writes it.
func (r DMARCSourceRow) FailText() string { return FormatCount(int64(r.Fail)) }
// Relay is the tag of the row that is this server.
func (r DMARCSourceRow) Relay() Tag { return Tag{Label: "this relay"} }
// Verdict is the disposition as a tag: green while the source passes, amber
// once it fails.
func (r DMARCSourceRow) Verdict() Tag {
status := "ok"
if r.Fail > 0 {
status = "warn"
}
return Tag{Status: status, Label: r.Disposition}
}
// passRate writes the share of messages that passed as the postmark prints it.
// It rounds down: a domain that has failures is never stamped 100%.
func passRate(pass, fail int) string {
if fail == 0 {
return "100%"
}
total := float64(pass + fail)
return fmt.Sprintf("%.1f%%", math.Floor(float64(pass)*1000/total)/10)
}
// withCode turns every p= in a sentence into a code span.
func withCode(s string) Text {
var t Text
for i, part := range strings.Split(s, "p=") {
if i > 0 {
t = append(t, Code("p="))
}
if part != "" {
t = append(t, Inline{Text: part})
}
}
return t
}
// NewDMARCDomain builds the page of a domain's reports. pass and fail are the
// messages of the last days days, hint the advice on tightening p= that follows
// from them and the sources. hub is whether the viewer reaches the hub: the
// trail leads back to it only then.
func NewDMARCDomain(m Meta, id int64, name string, hub bool, pass, fail, days int, hint string) *DMARCDomain {
m.Title, m.Section, m.Page = name+" · DMARC reports", "outbound", "dmarc"
crumb := Crumb{Label: "DMARC reports"}
if hub {
crumb.Href = outboundDMARC
}
window := plural(int64(days), "day", "days")
p := &DMARCDomain{
Meta: m,
Head: Head{
Crumbs: []Crumb{crumb},
Title: name,
Lead: append(Rich(FormatCount(int64(pass))+" pass · "+FormatCount(int64(fail))+" fail in the last "+window+". "),
withCode(hint)...),
Actions: []Action{{Label: "Domain page", Icon: "ti-world-upload", Href: DomainHref(id)}},
},
Sources: Box{No: "01", Title: fmt.Sprintf("Sources · %d days", days)},
SourcesEmpty: EmptyState{Icon: "ti-world-upload", Text: Plain("No mail from any source in this window.")},
Reports: Box{No: "02", Title: "Reports"},
ReportsEmpty: EmptyState{Icon: "ti-report-analytics", Text: Plain("No reports for this domain yet.")},
}
if pass+fail > 0 {
level := LevelOK
switch {
case pass == 0:
level = LevelFail
case fail > 0:
level = LevelWarn
}
p.Head.Postmark = &Postmark{Top: "DMARC", Word: passRate(pass, fail), Bottom: fmt.Sprintf("PASS · %d DAYS", days), Level: level}
}
return p
}
// WithSources sets the table of the sources.
func (p *DMARCDomain) WithSources(rows []DMARCSourceRow) *DMARCDomain {
p.SourceRows = rows
return p
}
// WithReports sets the table of the domain's reports.
func (p *DMARCDomain) WithReports(rows []DMARCReportRow) *DMARCDomain {
p.Rows = rows
return p
}
// ---- One report
// DMARCReport is the data of one aggregate report: who sent it and when, the
// policy it was written against and the records it holds.
type DMARCReport struct {
Meta
Head Head
Report Box
ReportFacts []Fact
Policy Box
PolicyFacts []Fact
Records Box
RecordRows []DMARCRecordRow
RecordsEmpty EmptyState
}
// DMARCRecordRow is one source in a report: how many messages, what receivers
// did with them and what SPF and DKIM said.
type DMARCRecordRow struct {
Source string
ThisRelay bool
Count int
Disposition string
SPF string
DKIM string
HeaderFrom string
}
// CountText is the message count as the table writes it.
func (r DMARCRecordRow) CountText() string { return FormatCount(int64(r.Count)) }
// Relay is the tag of the row that is this server.
func (r DMARCRecordRow) Relay() Tag { return Tag{Label: "this relay"} }
// Verdict is the disposition as a tag: green when either check passed, amber
// when neither did.
func (r DMARCRecordRow) Verdict() Tag {
status := "warn"
if r.SPF == "pass" || r.DKIM == "pass" {
status = "ok"
}
return Tag{Status: status, Label: r.Disposition}
}
// SPFTag is the SPF result as a tag.
func (r DMARCRecordRow) SPFTag() Tag { return resultTag(r.SPF) }
// DKIMTag is the DKIM result as a tag.
func (r DMARCRecordRow) DKIMTag() Tag { return resultTag(r.DKIM) }
// resultTag is a check's result as a tag: green for pass, red for anything else.
func resultTag(result string) Tag {
if result == "pass" {
return Tag{Status: "ok", Label: result}
}
return Tag{Status: "fail", Label: result}
}
// ReportInput is what a report holds, with the times already written the way
// the page shows them.
type ReportInput struct {
ID int64
Reporter string
ReportID string
Domain string
DomainID int64
Contact string
Pass, Fail int
Window string // the window in the title: "20 Sep"
Period string // the window with its times: "20 Sep 00:00 – 21 Sep 00:00 UTC"
Received string // "2026-09-21 04:40 UTC"
PolicyP string
PolicySP string
PolicyPct int
PolicyADKIM string
PolicyASPF string
Recipient string
// Hub is whether the viewer reaches the hub: the trail leads back to it only then.
Hub bool
}
// NewDMARCReport builds the page of one report.
func NewDMARCReport(m Meta, in ReportInput) *DMARCReport {
title := in.Reporter
if in.Window != "" {
title += " · " + in.Window
}
m.Title, m.Section, m.Page = title+" · DMARC", "outbound", "dmarc"
hub := Crumb{Label: "DMARC reports"}
if in.Hub {
hub.Href = outboundDMARC
}
route := Route{{Text: in.Domain}, {Tag: &Tag{Status: "ok", Label: FormatCount(int64(in.Pass)) + " pass"}}}
if in.Fail > 0 {
route = append(route, RoutePart{Tag: &Tag{Status: "warn", Label: FormatCount(int64(in.Fail)) + " fail"}})
}
report := []Fact{
{Label: "Reporter", Value: Plain(in.Reporter)},
{Label: "Report id", Value: Plain(in.ReportID), Mono: true, Small: true},
{Label: "Window", Value: Plain(in.Period)},
{Label: "Received", Value: Plain(in.Received)},
}
if in.Contact != "" {
report = append(report, Fact{Label: "Contact", Value: Plain(in.Contact)})
}
policy := []Fact{
{Label: "p / sp / pct", Value: Plain(fmt.Sprintf("%s / %s / %d", in.PolicyP, in.PolicySP, in.PolicyPct)), Mono: true},
{Label: "adkim / aspf", Value: Plain(in.PolicyADKIM + " / " + in.PolicyASPF), Mono: true},
}
if in.Recipient != "" {
policy = append(policy, Fact{Label: "rua", Value: Plain(in.Recipient), Mono: true, Small: true})
}
return &DMARCReport{
Meta: m,
Head: Head{
Crumbs: []Crumb{hub, {Href: DMARCDomainHref(in.DomainID), Label: in.Domain}},
Title: title,
Compact: true,
Route: route,
},
Report: Box{No: "01", Title: "Report"},
ReportFacts: report,
Policy: Box{No: "02", Title: "Published policy"},
PolicyFacts: policy,
Records: Box{No: "03", Title: "Records", End: Plain("Parsed from the aggregate XML")},
RecordsEmpty: EmptyState{Icon: "ti-report-analytics", Text: Plain("This report holds no records.")},
}
}
// WithRecords sets the table of the report's records.
func (p *DMARCReport) WithRecords(rows []DMARCRecordRow) *DMARCReport {
p.RecordRows = rows
return p
}
+134
View File
@@ -0,0 +1,134 @@
package view
import (
"testing"
"time"
)
// ---- The hub
func TestDMARCHubShowsIngestAndTheLatestReports(t *testing.T) {
out := renderSignedIn(t, "dmarc", dmarcHubFixture())
pageHas(t, "DMARC reports", out,
`<h1 class="title is-3">DMARC reports</h1>`, `<code>rua=</code>`,
`<h2>Ingest</h2>`, `<span class="tag is-success is-light">ok</span>`,
`<dd>2026-09-21 06:12 UTC</dd>`, `<dd>14 kept · 0 parse failures</dd>`, `<dd class="sp-mono">dmarc@mail.example.org</dd>`, `<dd>500 reports or 90 days</dd>`,
`<th>Received, UTC</th>`, `09-21 06:12`, `<a href="/outbound/dmarc/domains/1">example.org</a>`,
"<td>1 903</td>", `<span class="tag is-warning is-light">7</span>`, `<td>0</td><td class="sp-actions"><a href="/outbound/dmarc/reports/41">View</a>`,
)
}
// Before the first report the box says how to get one, and a quiet ingest is
// amber.
func TestDMARCHubWithoutReports(t *testing.T) {
p := NewDMARCHub(admin(), IngestInput{Hosted: "dmarc@mail.example.org", Host: "mail.example.org", RetentionMax: 500, RetentionDays: 90})
out := renderSignedIn(t, "dmarc", p.WithReports(nil))
pageHas(t, "DMARC reports", out, `<span class="tag is-warning is-light">quiet</span>`, `<dd>None yet</dd>`,
`publish MX for <span class="sp-mono">mail.example.org</span> on this server`, `No reports yet.`, `0 kept · 0 parse failures`)
pageLacks(t, "DMARC reports", out, `<table`, `UTC</dd>`)
one := NewDMARCHub(admin(), IngestInput{OK: true, Last: time.Now(), KeptThisWeek: 1, ParseFailures: 1})
pageHas(t, "DMARC reports", renderSignedIn(t, "dmarc", one), `1 kept · 1 parse failure</dd>`)
}
// A report of a domain the viewer cannot open is not a link.
func TestDMARCHubLinksOnlyDomainsThatExist(t *testing.T) {
p := NewDMARCHub(admin(), IngestInput{}).WithReports([]DMARCReportRow{
{Received: "09-21 06:12", Domain: "gone.example", Reporter: "google.com", Window: "20 Sep", Pass: 1, Href: "/outbound/dmarc/reports/1"},
})
out := renderSignedIn(t, "dmarc", p)
pageHas(t, "DMARC reports", out, `<td>gone.example</td>`)
pageLacks(t, "DMARC reports", out, `/outbound/dmarc/domains/`)
}
// ---- One sending domain
// The stamp, the lead and the tables say the same thing about the window: how
// much passed, what to do about what did not.
func TestDMARCDomainStampAndLead(t *testing.T) {
out := renderSignedIn(t, "dmarc-domain", dmarcDomainFixture())
pageHas(t, "DMARC domain", out,
`<h1 class="title is-3">shop.example.org</h1>`, `<a href="/outbound/dmarc">DMARC reports</a> /`,
`class="sp-postmark sp-warn"`, `<span>DMARC</span><b>99.6%</b><span>PASS · 7 DAYS</span>`,
"11 204 pass · 41 fail in the last 7 days. A third-party source is not aligned. Do not tighten <code>p=</code> until that sender is fixed or removed.",
`<a class="button" href="/outbound/domains/2"><i class="ti ti-world-upload"></i>Domain page</a>`,
`<h2>Sources · 7 days</h2>`, `203.0.113.25 <span class="tag is-light">this relay</span>`,
`<td>0</td><td>41</td><td><span class="tag is-warning is-light">none</span></td>`,
`<td><span class="tag is-success is-light">none</span></td>`, `<h2>Reports</h2>`, `<td class="sp-muted">Outlook.com</td>`,
)
for _, c := range []struct {
pass, fail int
level string
word string
}{
{500, 0, LevelOK, "100%"}, {9999, 1, LevelWarn, "99.9%"}, {1, 99999, LevelWarn, "0.0%"}, {0, 5, LevelFail, "0.0%"},
} {
p := NewDMARCDomain(admin(), 1, "example.org", true, c.pass, c.fail, 7, "")
if m := p.Head.Postmark; m == nil || m.Level != c.level || m.Word != c.word {
t.Errorf("%d pass, %d fail are stamped %+v, want %s %s", c.pass, c.fail, m, c.level, c.word)
}
}
// Rounding never turns a failure into a clean 100%.
if got := passRate(99999, 1); got != "99.9%" {
t.Errorf("99999 of 100000 is stamped %s", got)
}
}
// With nothing in the window there is nothing to stamp; and a viewer who does not
// reach the hub is not offered the way back to it.
func TestDMARCDomainWithoutMailOrWithoutHub(t *testing.T) {
p := NewDMARCDomain(Meta{User: "ops", HasOutbound: true}, 1, "example.org", false, 0, 0, 7, "No messages in the reporting window yet.")
out := renderSignedIn(t, "dmarc-domain", p.WithSources(nil).WithReports(nil))
pageHas(t, "DMARC domain", out, `<p class="sp-kicker">DMARC reports /</p>`, `0 pass · 0 fail in the last 7 days. No messages in the reporting window yet.`,
`No mail from any source in this window.`, `No reports for this domain yet.`)
pageLacks(t, "DMARC domain", out, `sp-postmark`, `<table`, `<a href="/outbound/dmarc">`)
}
func TestWithCodeOnlyMarksP(t *testing.T) {
out := renderSignedIn(t, "dmarc-domain", NewDMARCDomain(admin(), 1, "example.org", true, 1, 0, 7, "Raise p= to quarantine, then p=reject.").WithSources(nil).WithReports(nil))
pageHas(t, "DMARC domain", out, `Raise <code>p=</code> to quarantine, then <code>p=</code>reject.`)
}
// ---- One report
func TestDMARCReportShowsTheReportItsPolicyAndRecords(t *testing.T) {
out := renderSignedIn(t, "dmarc-report", dmarcReportFixture())
pageHas(t, "DMARC report", out,
`<h1 class="title is-4">Outlook.com · 20 Sep</h1>`,
`<a href="/outbound/dmarc">DMARC reports</a> / <a href="/outbound/dmarc/domains/2">shop.example.org</a> /`,
"<span>shop.example.org</span><span class=\"tag is-success is-light\">1 903 pass</span><span class=\"tag is-warning is-light\">7 fail</span>",
`<h2>Report</h2>`, `<dd class="sp-mono sp-small">5f1c9a0e7b2d4e61a3</dd>`, `<dd>20 Sep 00:00 – 21 Sep 00:00 UTC</dd>`, `<dd>2026-09-21 04:40 UTC</dd>`,
`<h2>Published policy</h2>`, `<dd class="sp-mono">none / none / 100</dd>`, `<dd class="sp-mono">r / r</dd>`, `<dd class="sp-mono sp-small">dmarc@mail.example.org</dd>`,
`<h2>Records</h2>`, `Parsed from the aggregate XML`,
`203.0.113.25 <span class="tag is-light">this relay</span>`, "<td>1 903</td>",
`<td><span class="tag is-success is-light">pass</span></td><td><span class="tag is-success is-light">pass</span></td>`,
`<td><span class="tag is-warning is-light">none</span></td><td><span class="tag is-danger is-light">fail</span></td><td><span class="tag is-danger is-light">fail</span></td>`,
)
}
// What a report does not hold is not drawn: no contact, no rua, no fail tag when
// nothing failed, and a report with no records says so.
func TestDMARCReportOmitsWhatItDoesNotHold(t *testing.T) {
p := NewDMARCReport(admin(), ReportInput{ID: 1, Reporter: "google.com", ReportID: "r1", Domain: "example.org", DomainID: 1, Pass: 5,
Period: "20 Sep 00:00 – 20 Sep 23:59 UTC", Received: "2026-09-21 04:40 UTC", PolicyP: "none", PolicySP: "none", PolicyPct: 100, PolicyADKIM: "r", PolicyASPF: "r"})
out := renderSignedIn(t, "dmarc-report", p.WithRecords(nil))
pageHas(t, "DMARC report", out, `<h1 class="title is-4">google.com</h1>`, `<span class="tag is-success is-light">5 pass</span>`, `This report holds no records.`)
pageLacks(t, "DMARC report", out, `fail</span>`, `Contact`, `<dt>rua</dt>`, `<table`, `<a href="/outbound/dmarc">`)
}
func TestDMARCPagesEscapeWhatReportersWrote(t *testing.T) {
evil := `<script>alert(1)</script>`
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), `<script>alert`)
}
}
+101
View File
@@ -34,6 +34,12 @@ var pageFixtures = map[string]func() any{
"out-app": func() any { return outAppFixture() },
"out-app-created": func() any { return outAppCreatedFixture() },
"out-domain-delete": func() any { return outDomainDeleteFixture() },
"out-log": func() any { return outLogFixture() },
"out-message": func() any { return outMessageFixture() },
"dmarc": func() any { return dmarcHubFixture() },
"dmarc-domain": func() any { return dmarcDomainFixture() },
"dmarc-report": func() any { return dmarcReportFixture() },
}
// admin is the signed-in global administrator every fixture is rendered for.
@@ -206,3 +212,98 @@ func outAppCreatedFixture() *OutAppCreated {
func outDomainDeleteFixture() *OutDomainDelete {
return NewOutDomainDelete(admin(), 1, "example.org", []string{"prod-server", "alerts"})
}
// outLogFixture is the mockup's Outbound log: six messages of three domains on
// the first of 412 pages, one deferred, one bounced, one refused by a rate limit.
func outLogFixture() *OutLog {
p := NewOutLog(admin(), 30, true,
[]string{"example.org", "shop.example.org", "notify.acme.io"}, []string{"prod-server", "alerts"}, "", "")
row := func(id int64, at, from, to, subject, status string) OutLogRow {
return OutLogRow{Time: at, From: from, To: to, Subject: subject, Status: Tag{Status: status},
Href: p.DetailHref(id, 1)}
}
return p.WithRows([]OutLogRow{
row(184223, "09-21 14:01:52", "shop@shop.example.org", "m.keller@gmx.de", "Your order #48213 has shipped", "sent"),
row(184222, "09-21 14:01:50", "shop@shop.example.org", "anna.lind@outlook.com", "Your order #48212 has shipped", "sent"),
row(184220, "09-21 13:58:07", "alerts@example.org", "noc@example.org", "[FIRING] disk usage above 90 % on db-2", "deferred"),
row(184211, "09-21 13:41:19", "no-reply@notify.acme.io", "j.doe@nonexistent.example", "Reset your password", "bounced"),
row(184209, "09-21 13:40:02", "prod@example.org", "billing@partner.example", "Invoice 2026-0917", "rejected"),
row(184198, "09-21 13:12:44", "shop@shop.example.org", "p.novak@seznam.cz", "We received your return request", "sent"),
}, 1, 412)
}
// outMessageFixture is the mockup's message page: a deferred alert, with the
// four lines Postfix and OpenDKIM wrote about it.
func outMessageFixture() *OutMessage {
at := time.Date(2026, 9, 21, 13, 58, 7, 0, time.UTC)
p := NewOutMessage(admin(), MessageInput{
ID: 184220, QueueID: "4XcB7k2Jm9z1", Domain: "example.org", DomainHref: "/outbound/domains/1",
App: "alerts", AppHref: "/outbound/domains/1/applications/2",
From: "alerts@example.org", To: "noc@example.org", Subject: "[FIRING] disk usage above 90 % on db-2", Status: "deferred",
Accepted: at, Reported: at.Add(2 * time.Second), BackHref: "/outbound/log",
})
p.WithHistory([]Step{
{Time: StepTime(at), Strong: "Accepted and queued", Text: Plain(" — Postfix accepted the message over an authenticated submission and the journal-milter recorded it.")},
{Time: StepTime(at.Add(2 * time.Second)), Strong: "Deferred, will be retried", Level: LevelWarn,
Text: Plain(" — the receiving server could not take the message yet. Postfix retries: first after 5 minutes, then with increasing gaps up to 1 hour 7 minutes, for up to 5 days.")},
{Time: StepTime(time.Time{}), Strong: "Waiting for a delivery report", Level: LevelPending,
Text: Plain(" — the next attempt is made by Postfix on its own; the queue shows what it is still holding.")},
})
return p.WithDeliveryLog([]LogLine{
{Time: "13:58:07", Text: "postfix/smtpd[2114]: 4XcB7k2Jm9z1: client=unknown[198.51.100.7], sasl_username=alerts"},
{Time: "13:58:07", Text: "postfix/cleanup[2120]: 4XcB7k2Jm9z1: message-id=<a81f0c@alertmanager>"},
{Time: "13:58:07", Text: "opendkim[39]: 4XcB7k2Jm9z1: DKIM-Signature field added (s=sp2026, d=example.org)"},
{Time: "13:58:09", Level: "warn", Text: "postfix/smtp[2123]: 4XcB7k2Jm9z1: to=<noc@example.org>, relay=mx1.example.org[192.0.2.10]:25, delay=1.9, status=deferred (host mx1.example.org said: 451 4.7.1 Greylisted, try again in 5 minutes)"},
}, "")
}
// dmarcHubFixture is the mockup's DMARC hub: ingest healthy, four recent
// reports, one with failures.
func dmarcHubFixture() *DMARCHub {
p := NewDMARCHub(admin(), IngestInput{
OK: true, Last: time.Date(2026, 9, 21, 6, 12, 0, 0, time.UTC), KeptThisWeek: 14, ParseFailures: 0,
Hosted: "dmarc@mail.example.org", Host: "mail.example.org", RetentionMax: 500, RetentionDays: 90,
})
row := func(id int64, at, domain, reporter, window string, pass, fail int) DMARCReportRow {
return DMARCReportRow{Received: at, Domain: domain, DomainHref: "/outbound/dmarc/domains/1", Reporter: reporter,
Window: window, Pass: pass, Fail: fail, Href: DMARCReportHref(id)}
}
return p.WithReports([]DMARCReportRow{
row(41, "09-21 06:12", "example.org", "google.com", "20 Sep", 412, 0),
row(40, "09-21 04:40", "shop.example.org", "Outlook.com", "20 Sep", 1903, 7),
row(39, "09-20 23:58", "example.org", "Yahoo", "19 Sep", 38, 0),
row(38, "09-20 06:09", "notify.acme.io", "google.com", "19 Sep", 61, 0),
})
}
// dmarcDomainFixture is the mockup's domain page: seven days of shop.example.org
// with one source that is not this relay failing, and three reports.
func dmarcDomainFixture() *DMARCDomain {
p := NewDMARCDomain(admin(), 2, "shop.example.org", true, 11204, 41, 7,
"A third-party source is not aligned. Do not tighten p= until that sender is fixed or removed.")
row := func(id int64, at, reporter, window string, pass, fail int) DMARCReportRow {
return DMARCReportRow{Received: at, Reporter: reporter, Window: window, Pass: pass, Fail: fail, Href: DMARCReportHref(id)}
}
return p.WithSources([]DMARCSourceRow{
{Source: "203.0.113.25", ThisRelay: true, Pass: 11204, Fail: 0, Disposition: "none"},
{Source: "198.51.100.44", Pass: 0, Fail: 41, Disposition: "none"},
}).WithReports([]DMARCReportRow{
row(40, "09-21 04:40", "Outlook.com", "20 Sep", 1903, 7),
row(37, "09-21 02:15", "google.com", "20 Sep", 6410, 0),
row(35, "09-20 04:38", "Outlook.com", "19 Sep", 1877, 34),
})
}
// dmarcReportFixture is the mockup's report: Outlook.com's day for
// shop.example.org, two sources, one of them failing both checks.
func dmarcReportFixture() *DMARCReport {
return NewDMARCReport(admin(), ReportInput{
ID: 40, Reporter: "Outlook.com", ReportID: "5f1c9a0e7b2d4e61a3", Domain: "shop.example.org", DomainID: 2,
Pass: 1903, Fail: 7, Window: "20 Sep", Period: "20 Sep 00:00 – 21 Sep 00:00 UTC", Received: "2026-09-21 04:40 UTC",
PolicyP: "none", PolicySP: "none", PolicyPct: 100, PolicyADKIM: "r", PolicyASPF: "r",
Recipient: "dmarc@mail.example.org", Hub: true,
}).WithRecords([]DMARCRecordRow{
{Source: "203.0.113.25", ThisRelay: true, Count: 1903, Disposition: "none", SPF: "pass", DKIM: "pass", HeaderFrom: "shop.example.org"},
{Source: "198.51.100.44", Count: 7, Disposition: "none", SPF: "fail", DKIM: "fail", HeaderFrom: "shop.example.org"},
})
}
-5
View File
@@ -11,11 +11,6 @@
# @routes the pre-2.0 route table is still in place (stage 1)
# <name> a page of the view engine that is still the old template
backup
deliveries
delivery
dmarc
dmarc_domain
dmarc_report
help
inbound
inbound_delete
+297
View File
@@ -0,0 +1,297 @@
package view
// The data of the Outbound log pages: the list of messages
// (templates/out_log.html, mockup out-log.html) and one message
// (out_message.html, mockup out-message.html). The list polls: the table and
// its foot are one region (templates/out_log_rows.html) that the page and its
// fragment, GET /outbound/log/fragment, render alike.
//
// A page is a typed struct that embeds Meta, built by a constructor and filled
// by With… methods; the handler reads the journal, the view decides what is
// said and how it is worded.
import (
"net/url"
"strconv"
"strings"
"time"
)
const outboundLog = "/outbound/log"
// logQuery is the query string of a page of the log: the filters and the page,
// each left out when it says nothing, so the first unfiltered page is the bare
// path.
func logQuery(domain, app string, page int) string {
q := url.Values{}
if domain != "" {
q.Set("domain", domain)
}
if app != "" {
q.Set("app", app)
}
if page > 1 {
q.Set("p", strconv.Itoa(page))
}
if len(q) == 0 {
return ""
}
return "?" + q.Encode()
}
// LogHref is the address of a page of the Outbound log.
func LogHref(domain, app string, page int) string {
return outboundLog + logQuery(domain, app, page)
}
// FormatLogTime is the time of a row of the log: month, day and the time of day
// in UTC, as the table's "Time, UTC" column shows it.
func FormatLogTime(t time.Time) string { return t.UTC().Format("01-02 15:04:05") }
// formatStamp is a moment on the page of one message, with its zone.
func formatStamp(t time.Time) string { return t.UTC().Format("2006-01-02 15:04:05") + " UTC" }
// ---- The log
// OutLog is the data of the Outbound log: the messages of the domains the
// viewer reaches, filtered by domain and application, a page at a time.
type OutLog struct {
Meta
Head Head
Messages Box
// PollURL is the address of the fragment that answers with the table again
// for the same filters and page (the polled region is the table, not the box:
// a filter being chosen in the head must not be reset under the hand).
PollURL string
Rows []OutLogRow
Empty EmptyState
Foot Foot
domain, app string
}
// OutLogRow is one message of the table: when it was accepted, who it was from
// and to, its subject (shown whole in the cell's title when the column clips
// it), its status as a tag and the way to the message's own page.
type OutLogRow struct {
Time string
From string
To string
Subject string
Status Tag
Href string
}
// NewOutLog builds the log for a viewer. domains and apps are the choices of
// the two filters (only what the viewer reaches), domain and app the ones in
// force. retentionDays is how long rows are kept; canSettings, the global role,
// adds the way to change it.
func NewOutLog(m Meta, retentionDays int, canSettings bool, domains, apps []string, domain, app string) *OutLog {
m.Title, m.Section, m.Page = "Outbound log", "outbound", "log"
lead := Rich("Every message an application handed to the relay. Rows older than " + plural(int64(retentionDays), "day", "days") + " are deleted")
if canSettings {
lead = append(lead, Inline{Text: " — change that in "}, Link("/server/settings", "Settings"))
}
lead = append(lead, Inline{Text: "."})
options := func(any string, values []string) []Option {
out := []Option{{Value: "", Label: any}}
for _, v := range values {
out = append(out, Option{Value: v, Label: v})
}
return out
}
return &OutLog{
Meta: m,
Head: Head{Kicker: "Outbound", Title: "Outbound log", Lead: lead},
Messages: Box{No: "01", Title: "Messages", Filter: &Filter{
Action: outboundLog,
Selects: []Select{
{Name: "domain", Label: "Domain", Options: options("All domains", domains), Selected: domain},
{Name: "app", Label: "Application", Options: options("All applications", apps), Selected: app},
},
}},
Empty: EmptyState{Icon: "ti-list-details", Text: Plain("No messages logged yet.")},
PollURL: outboundLog + "/fragment" + logQuery(domain, app, 1),
Foot: Foot{ID: "out-log-foot", End: Plain("New rows appear on their own")},
domain: domain, app: app,
}
}
// DetailHref is the address of one message's page, carrying the filters and the
// page it is opened from so that its way back returns to the same list.
func (p *OutLog) DetailHref(id int64, page int) string {
return outboundLog + "/" + strconv.FormatInt(id, 10) + logQuery(p.domain, p.app, page)
}
// WithRows sets the table for page of last, and the foot under it: where the
// viewer is, and the way to the newer and older rows. The poll asks for the same
// page again.
func (p *OutLog) WithRows(rows []OutLogRow, page, last int) *OutLog {
p.Rows = rows
p.PollURL = outboundLog + "/fragment" + logQuery(p.domain, p.app, page)
if len(rows) == 0 {
return p
}
text := Rich("Page " + strconv.Itoa(page) + " of " + strconv.Itoa(last))
if page > 1 {
text = append(text, Inline{Text: " · "}, Link(LogHref(p.domain, p.app, page-1), "← Newer"))
}
p.Foot.Text = text
if page < last {
p.Foot.Link = &Anchor{Href: LogHref(p.domain, p.app, page+1), Label: "Older →"}
}
return p
}
// Fragment marks the foot to be swapped into the page by its id: the polled
// fragment is the table, and the count under it follows out of band.
func (p *OutLog) Fragment() *OutLog {
p.Foot.OOB = true
return p
}
// ---- One message
// MessageInput is what the journal holds about one message and the pages it can
// be linked to. DomainHref and AppHref are empty when the domain or the
// application no longer exists, or the viewer does not reach it: the name is
// then plain text.
type MessageInput struct {
ID int64
QueueID string
Domain string
DomainHref string
App string
AppHref string
From string
To string
Subject string
Status string
Accepted time.Time
Reported time.Time
// BackHref is the log the message was opened from, filters and page kept.
BackHref string
}
// OutMessage is the data of one message's page: what the journal recorded, the
// steps it went through and what Postfix wrote about it.
type OutMessage struct {
Meta
Head Head
Message Box
Facts []Fact
History Box
Steps []Step
Delivery Box
LogLines []LogLine
// LogEmpty says why there are no lines: a message that never reached the
// queue, or whose lines have been rotated away, is an ordinary outcome.
LogEmpty EmptyState
}
// dash is the value of a field a row does not have.
const dash = "—"
func orDash(s string) string {
if s == "" {
return dash
}
return s
}
// NewOutMessage builds the page of one message. Its name is its subject, and
// the line under it is who it was from and to with its status.
func NewOutMessage(m Meta, in MessageInput) *OutMessage {
title := in.Subject
if title == "" {
title = "(no subject)"
}
m.Title, m.Section, m.Page = title+" · Outbound log", "outbound", "log"
value := func(text, href string) Text {
if href == "" {
return Plain(orDash(text))
}
return Rich(Link(href, text))
}
return &OutMessage{
Meta: m,
Head: Head{
Crumbs: []Crumb{{Href: in.BackHref, Label: "Outbound log"}},
Title: title,
Compact: true,
Route: Route{
{Text: orDash(in.From)}, {Arrow: true}, {Text: orDash(in.To)},
{Tag: &Tag{Status: in.Status}},
},
},
Message: Box{No: "01", Title: "Message"},
Facts: []Fact{
{Label: "Domain", Value: value(in.Domain, in.DomainHref)},
{Label: "Application", Value: value(in.App, in.AppHref), Mono: true},
{Label: "Accepted", Value: Plain(formatStamp(in.Accepted))},
{Label: "Status reported", Value: Plain(formatStamp(in.Reported))},
{Label: "Queue id", Value: Plain(orDash(in.QueueID)), Mono: true},
{Label: "Journal id", Value: Plain(strconv.FormatInt(in.ID, 10)), Mono: true},
},
History: Box{No: "02", Title: "History"},
Delivery: Box{No: "03", Title: "Delivery log", End: Rich("Lines from ", Code("mail.log"), " with this queue id")},
LogEmpty: EmptyState{Icon: "ti-file-text"},
}
}
// WithHistory sets the steps the message went through, oldest first.
func (p *OutMessage) WithHistory(steps []Step) *OutMessage {
p.Steps = steps
return p
}
// WithDeliveryLog sets the lines Postfix wrote about the message. When there are
// none, note says why.
func (p *OutMessage) WithDeliveryLog(lines []LogLine, note string) *OutMessage {
p.LogLines = lines
p.LogEmpty.Text = Plain(note)
return p
}
// StepTime is the time of a step of the history: the moment, or "not yet" for a
// step that has not happened.
func StepTime(t time.Time) string {
if t.IsZero() {
return "not yet"
}
return formatStamp(t)
}
// StepLevel is the level of a history step from the level of its outcome (ok,
// warn, error or anything else for "nothing to report"): a step that has not
// happened is pending whatever its outcome.
func StepLevel(outcome string, happened bool) string {
switch {
case !happened:
return LevelPending
case outcome == "warn":
return LevelWarn
case outcome == "error":
return LevelFail
}
return ""
}
// LogLineLevel is the level a line of the delivery log is drawn at: a delivery
// that was put off is amber, one that failed for good red. Postfix writes the
// outcome of an attempt as status=… in the line itself.
func LogLineLevel(text string) string {
switch {
case strings.Contains(text, "status=bounced"), strings.Contains(text, "status=expired"),
strings.Contains(text, " fatal:"), strings.Contains(text, " error:"):
return "error"
case strings.Contains(text, "status=deferred"), strings.Contains(text, " warning:"):
return "warn"
}
return ""
}
+169
View File
@@ -0,0 +1,169 @@
package view
import (
"strings"
"testing"
"time"
)
// ---- The log
// The log is the whole of what the journal holds about the viewer's mail: the
// filters offer what the viewer reaches, the head says how long rows live and
// where to change it, and every row leads to its own page.
func TestOutLogShowsTheRowsAndTheirFilters(t *testing.T) {
out := renderSignedIn(t, "out-log", outLogFixture())
pageHas(t, "Outbound log", out,
`<h1 class="title is-3">Outbound log</h1>`,
`Rows older than 30 days are deleted — change that in <a href="/server/settings">Settings</a>.`,
`<form class="sp-end" method="get" action="/outbound/log">`,
`<select name="domain" aria-label="Domain"><option value="" selected>All domains</option><option value="example.org">example.org</option>`,
`<select name="app" aria-label="Application"><option value="" selected>All applications</option>`,
`<th>Time, UTC</th>`, `09-21 14:01:52`,
`shop@<wbr>shop.example.org`, `m.keller@<wbr>gmx.de`, `title="[FIRING] disk usage above 90 % on db-2"`,
`<span class="tag is-success is-light">sent</span>`, `<span class="tag is-warning is-light">deferred</span>`,
`<span class="tag is-danger is-light">bounced</span>`, `<span class="tag is-light">rejected</span>`,
`href="/outbound/log/184220">Details</a>`,
`Page 1 of 412`, `<a href="/outbound/log?p=2">Older →</a>`, `New rows appear on their own`,
)
pageLacks(t, "Outbound log", out, `← Newer`)
// Without the global role there is nowhere to change the retention.
p := NewOutLog(Meta{User: "ops", HasOutbound: true}, 7, false, []string{"example.org"}, nil, "", "")
out = renderSignedIn(t, "out-log", p.WithRows(nil, 1, 1))
pageHas(t, "Outbound log", out, `Rows older than 7 days are deleted.`)
pageLacks(t, "Outbound log", out, `/server/settings`)
pageLacks(t, "Outbound log", renderSignedIn(t, "out-log", NewOutLog(Meta{User: "ops", HasOutbound: true}, 1, false, nil, nil, "", "").WithRows(nil, 1, 1)),
`older than 1 days`)
}
// The filter in force is the one selected, and the poll, the paging and the
// links of the rows all carry it — and say nothing for a filter that is not set.
func TestOutLogCarriesItsFilters(t *testing.T) {
p := NewOutLog(admin(), 30, true, []string{"example.org", "shop.example.org"}, []string{"alerts"}, "shop.example.org", "alerts")
p.WithRows([]OutLogRow{{Time: "09-21 14:01:52", From: "a@example.org", To: "b@example.org", Subject: "Hi",
Status: Tag{Status: "sent"}, Href: p.DetailHref(7, 3)}}, 3, 5)
out := renderSignedIn(t, "out-log", p)
pageHas(t, "Outbound log", out,
`<option value="shop.example.org" selected>shop.example.org</option>`, `<option value="alerts" selected>alerts</option>`,
`hx-get="/outbound/log/fragment?app=alerts&amp;domain=shop.example.org&amp;p=3"`,
`href="/outbound/log/7?app=alerts&amp;domain=shop.example.org&amp;p=3"`,
`Page 3 of 5 · <a href="/outbound/log?app=alerts&amp;domain=shop.example.org&amp;p=2">← Newer</a>`,
`<a href="/outbound/log?app=alerts&amp;domain=shop.example.org&amp;p=4">Older →</a>`,
)
if got := LogHref("", "", 1); got != "/outbound/log" {
t.Errorf("the first unfiltered page is %q", got)
}
if got := LogHref("a b&c", "", 2); got != "/outbound/log?domain=a+b%26c&p=2" {
t.Errorf("a filter is not encoded: %q", got)
}
}
// The last page has nothing older, and a page with no rows says so and still
// polls: a log that is empty now is the one that fills while the page is open.
func TestOutLogLastPageAndEmptyPage(t *testing.T) {
p := NewOutLog(admin(), 30, true, nil, nil, "", "")
out := renderSignedIn(t, "out-log", p.WithRows([]OutLogRow{{Subject: "x", Status: Tag{Status: "sent"}, Href: "/outbound/log/1"}}, 2, 2))
pageHas(t, "Outbound log", out, `Page 2 of 2`, `← Newer</a>`)
pageLacks(t, "Outbound log", out, `Older →`)
out = renderSignedIn(t, "out-log", NewOutLog(admin(), 30, true, nil, nil, "", "").WithRows(nil, 1, 1))
pageHas(t, "Outbound log", out, `No messages logged yet.`, `id="out-log-rows"`, `data-poll`, `id="out-log-foot"`, `New rows appear on their own`)
pageLacks(t, "Outbound log", out, `<table`, `Page 1`)
}
// The fragment is the table and, marked to be swapped in by its id, the foot;
// the page's own foot carries the id and no mark.
func TestOutLogFragmentMarksOnlyTheFoot(t *testing.T) {
page := renderSignedIn(t, "out-log", outLogFixture())
frag := renderFragment(t, "out_log_rows", outLogFixture().Fragment())
pageHas(t, "fragment", frag, `<div class="table-container" id="out-log-rows" data-poll`, `<div class="sp-box-foot" id="out-log-foot" hx-swap-oob="true">`)
pageLacks(t, "fragment", frag, `<form`, `<select`, `sp-head`)
pageLacks(t, "page", page, `hx-swap-oob`)
pageHas(t, "page", page, `<div class="sp-box-foot" id="out-log-foot">`)
if strings.Count(frag, `<table`) != 1 {
t.Errorf("the fragment holds %d tables", strings.Count(frag, `<table`))
}
}
func TestOutLogEscapesWhatSendersWrote(t *testing.T) {
p := NewOutLog(admin(), 30, true, []string{`<b>d</b>`}, []string{`"><i>`}, "", "")
p.WithRows([]OutLogRow{{Time: "t", From: `"><img src=x>@a`, To: `<u>@b`, Subject: `<script>alert(1)</script>`,
Status: Tag{Status: `<i>`}, Href: "/outbound/log/1"}}, 1, 1)
out := renderSignedIn(t, "out-log", p)
pageLacks(t, "Outbound log", out, `<script>alert`, `<img src=x>`, `<b>d</b>`, `<u>@b`, `<i>`)
pageHas(t, "Outbound log", out, `&lt;script&gt;alert(1)&lt;/script&gt;`)
}
// ---- One message
func TestOutMessageShowsWhatTheJournalRecorded(t *testing.T) {
out := renderSignedIn(t, "out-message", outMessageFixture())
pageHas(t, "Message", out,
`<h1 class="title is-4">[FIRING] disk usage above 90 % on db-2</h1>`,
`<a href="/outbound/log">Outbound log</a> /`,
`<p class="sp-route"><span>alerts@example.org</span><i class="ti ti-arrow-right sp-muted"></i><span>noc@example.org</span><span class="tag is-warning is-light">deferred</span></p>`,
`<a href="/outbound/domains/1">example.org</a>`, `<dd class="sp-mono"><a href="/outbound/domains/1/applications/2">alerts</a></dd>`,
`2026-09-21 13:58:07 UTC`, `2026-09-21 13:58:09 UTC`, `<dd class="sp-mono">4XcB7k2Jm9z1</dd>`, `<dd class="sp-mono">184220</dd>`,
`<li><time>2026-09-21 13:58:07 UTC</time><strong>Accepted and queued</strong>`,
`<li class="sp-warn"><time>2026-09-21 13:58:09 UTC</time><strong>Deferred, will be retried</strong>`,
`<li class="sp-pending"><time>not yet</time>`,
`Lines from <code>mail.log</code> with this queue id`, `<pre class="sp-log">`, `<span class="sp-d">13:58:07</span> postfix/smtpd[2114]`,
`<span class="sp-w">postfix/smtp[2123]: 4XcB7k2Jm9z1: to=&lt;noc@example.org&gt;`,
)
}
// A message without a subject is named for it, and a row with nothing to show
// for a field shows a dash.
func TestOutMessageWithoutSubjectOrRecipient(t *testing.T) {
at := time.Date(2026, 9, 21, 13, 58, 7, 0, time.UTC)
p := NewOutMessage(admin(), MessageInput{ID: 9, Domain: "gone.example", App: "old", Status: "rejected", Accepted: at, Reported: at,
BackHref: "/outbound/log?domain=gone.example&p=2"}).
WithDeliveryLog(nil, "This message never reached the queue, so Postfix wrote no delivery lines for it.")
out := renderSignedIn(t, "out-message", p)
pageHas(t, "Message", out, `<h1 class="title is-4">(no subject)</h1>`, `<span>—</span><i class="ti ti-arrow-right sp-muted"></i><span>—</span>`,
`<span class="tag is-light">rejected</span>`, `href="/outbound/log?domain=gone.example&amp;p=2"`,
`<dd>gone.example</dd>`, `<dd class="sp-mono">old</dd>`, `<dd class="sp-mono">—</dd>`,
`never reached the queue`)
pageLacks(t, "Message", out, `<pre`, `<a href="/outbound/domains/`)
}
func TestOutMessageEscapesWhatSendersWrote(t *testing.T) {
at := time.Now()
p := NewOutMessage(admin(), MessageInput{ID: 1, QueueID: `"><i>`, Domain: `<b>d</b>`, App: `<u>a</u>`, From: `<s>f</s>`, To: `"><img src=x>`,
Subject: `<script>alert(1)</script>`, Status: "sent", Accepted: at, Reported: at, BackHref: "/outbound/log"}).
WithHistory([]Step{{Time: "t", Strong: `<b>s</b>`, Text: Plain(`<i>x</i>`)}}).
WithDeliveryLog([]LogLine{{Time: `<u>`, Text: `<script>y</script>`}}, "")
out := renderSignedIn(t, "out-message", p)
pageLacks(t, "Message", out, `<script>alert`, `<script>y`, `<b>d</b>`, `<u>a</u>`, `<s>f</s>`, `<img src=x>`, `<b>s</b>`, `<i>x</i>`, `<u>`)
}
func TestStepLevelAndLogLineLevel(t *testing.T) {
for _, c := range []struct {
outcome string
happened bool
want string
}{
{"ok", true, ""}, {"", true, ""}, {"warn", true, LevelWarn}, {"error", true, LevelFail},
{"", false, LevelPending}, {"error", false, LevelPending},
} {
if got := StepLevel(c.outcome, c.happened); got != c.want {
t.Errorf("StepLevel(%q, %v) = %q, want %q", c.outcome, c.happened, got, c.want)
}
}
for text, want := range map[string]string{
"postfix/smtp[1]: Q1: to=<a@b>, status=sent (250 OK)": "",
"postfix/smtp[1]: Q1: to=<a@b>, status=deferred (try later)": "warn",
"postfix/smtp[1]: Q1: to=<a@b>, status=bounced (no such user)": "error",
"postfix/qmgr[1]: Q1: status=expired, returned to sender": "error",
"postfix/smtp[1]: warning: TLS library problem": "warn",
"opendkim[1]: Q1: DKIM-Signature field added": "",
} {
if got := LogLineLevel(text); got != want {
t.Errorf("LogLineLevel(%q) = %q, want %q", text, got, want)
}
}
if got := StepTime(time.Time{}); got != "not yet" {
t.Errorf("a step that has not happened is at %q", got)
}
}
+2 -2
View File
@@ -97,7 +97,7 @@
{{/* box_foot is the line under a box's content (input: Foot): a legend, paging
with an end slot, or the one button that finishes the page. */}}
{{define "box_foot"}}<div class="sp-box-foot">{{if .Button}}{{template "action" .Button}}{{else}}{{if .Bare}}{{template "inline" .Text}}{{else}}<span>{{template "inline" .Text}}</span>{{end}}{{if .Link}}<a href="{{.Link.Href}}">{{.Link.Label}}</a>{{end}}{{if .End}}<span class="sp-end">{{template "inline" .End}}</span>{{end}}{{end}}</div>{{end}}
{{define "box_foot"}}<div class="sp-box-foot"{{if .ID}} id="{{.ID}}"{{end}}{{if .OOB}} hx-swap-oob="true"{{end}}>{{if .Button}}{{template "action" .Button}}{{else}}{{if .Bare}}{{template "inline" .Text}}{{else}}<span>{{template "inline" .Text}}</span>{{end}}{{if .Link}}<a href="{{.Link.Href}}">{{.Link.Label}}</a>{{end}}{{if .End}}<span class="sp-end">{{template "inline" .End}}</span>{{end}}{{end}}</div>{{end}}
{{/* ---- health, records, copy */}}
@@ -142,7 +142,7 @@
{{/* facts is a label / value grid (input: []Fact); Big is for a headline
number, with its supporting line under it. */}}
{{define "facts"}}<dl class="sp-facts">{{range .}}
<div><dt>{{.Label}}</dt><dd{{if .Mono}} class="sp-mono"{{else if .Big}} class="sp-big"{{end}}>{{template "inline" .Value}}</dd>{{if .Note}}<dd class="sp-muted">{{template "inline" .Note}}</dd>{{end}}</div>{{end}}
<div><dt>{{.Label}}</dt><dd{{if and .Mono .Small}} class="sp-mono sp-small"{{else if .Mono}} class="sp-mono"{{else if .Big}} class="sp-big"{{end}}>{{template "inline" .Value}}</dd>{{if .Note}}<dd class="sp-muted">{{template "inline" .Note}}</dd>{{end}}</div>{{end}}
</dl>{{end}}
{{/* side_menu is the Bulma menu of a detail page (input: SideMenu): icons,
@@ -1,35 +0,0 @@
{{/* The send log is six columns of addresses, subjects and timestamps, so this
page takes the whole column rather than the reading measure (see the "wide"
block in layout.html). It is declared per page rather than per page name
because how wide a page needs to be is a property of that page, not of the
navigation entry several pages share. */}}
{{define "wide"}}wide{{end}}
{{define "content"}}
<h1>Deliveries</h1>
<p class="muted">Rows older than {{.SendLogRetentionDays}} days are deleted
automatically.{{if .IsGlobal}} Change retention on
<a href="/server/settings">Settings</a>.{{end}}</p>
<div class="card">
<h2>Filter</h2>
<form method="get" action="/outbound/log">
<label for="domain">Domain</label>
<select id="domain" name="domain">
<option value="">All domains</option>
{{range .FilterDomains}}<option value="{{.}}" {{if eq . $.FilterDomain}}selected{{end}}>{{.}}</option>{{end}}
</select>
<label for="app">Application</label>
<select id="app" name="app">
<option value="">All applications</option>
{{range .FilterApps}}<option value="{{.}}" {{if eq . $.FilterApp}}selected{{end}}>{{.}}</option>{{end}}
</select>
<button type="submit">Filter</button>
</form>
</div>
<div class="card">
{{template "deliveries_rows" .}}
</div>
{{end}}
@@ -1,32 +0,0 @@
{{define "deliveries_rows"}}
<div id="deliveries-rows" data-poll aria-live="polite"
hx-get="/outbound/log/fragment?domain={{.FilterDomain | urlquery}}&app={{.FilterApp | urlquery}}&p={{.Page}}"
hx-trigger="load" hx-swap="outerHTML">
{{if .Rows}}
<table>
<thead>
<tr><th>Time</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{{range .Rows}}
<tr>
<td class="time muted">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</td>
<td>{{.From}}</td>
<td>{{.To}}</td>
<td class="subject"><span title="{{.Subject}}">{{.Subject}}</span></td>
<td class="status"><span class="st st-{{.Level}}">{{.Status}}</span></td>
<td class="actions"><a href="/outbound/log/{{.ID}}?domain={{$.FilterDomain | urlquery}}&app={{$.FilterApp | urlquery}}&p={{$.Page}}">Details</a></td>
</tr>
{{end}}
</tbody>
</table>
<p class="muted">
Page {{.Page}}{{if .LastPage}} of {{.LastPage}}{{end}}
{{if .HasPrev}} &middot; <a href="/outbound/log?domain={{.FilterDomain | urlquery}}&app={{.FilterApp | urlquery}}&p={{.PrevPage}}">&larr; Newer</a>{{end}}
{{if .HasNext}} &middot; <a href="/outbound/log?domain={{.FilterDomain | urlquery}}&app={{.FilterApp | urlquery}}&p={{.NextPage}}">Older &rarr;</a>{{end}}
</p>
{{else}}
<p class="muted">No messages logged yet.</p>
{{end}}
</div>
{{end}}
-124
View File
@@ -1,124 +0,0 @@
{{/* One message's page. It takes the whole column rather than the reading
measure (see the "wide" block in layout.html): the two cards below stand
side by side, and under them are raw mail.log lines, which are long by
nature and were the reason the send log opted out too. */}}
{{define "wide"}}wide{{end}}
{{define "content"}}
{{/* The subject is the page's name — it is what the message was about, and
what an operator arriving from the log is holding in their head. The route
under it is the other half of the identity: who it was from and who it was
for, with the outcome badged on the end so the answer is on the first line
of the page rather than somewhere in the cards below. */}}
<h1 class="subject">{{if .Row.Subject}}{{.Row.Subject}}{{else}}(no subject){{end}}</h1>
<p class="route">
<span class="addr">{{.Row.From}}</span>
<span class="arrow" aria-hidden="true">&rarr;</span>
<span class="addr">{{if .Row.To}}{{.Row.To}}{{else}}—{{end}}</span>
<span class="st st-{{.Level}}">{{.Row.Status}}</span>
</p>
{{template "back_link" (back .BackURL "Back to deliveries")}}
{{/* The two columns: what was recorded on the left, in what order it happened
on the right. They are a pair — the facts are only worth reading against
the history and the history only means anything for a known message — so
they are read together rather than one after the other (.split in
panel.css lays them down into one column when there is no room). */}}
<div class="split">
<div class="card" id="message">
<h2>Message</h2>
<p class="muted">What the journal recorded as Postfix accepted this
message. The delivery log lists only what identifies a row at a glance;
everything the table has no column for is here.</p>
<div class="facts">
<div class="fact">
<span class="fact-label">Domain</span>
<span class="fact-value">{{if .Row.Domain}}{{.Row.Domain}}{{else}}—{{end}}</span>
</div>
<div class="fact">
<span class="fact-label">Application</span>
<span class="fact-value">{{if .Row.AppLogin}}{{.Row.AppLogin}}{{else}}—{{end}}</span>
</div>
<div class="fact">
<span class="fact-label">Accepted</span>
<span class="fact-value">{{.Row.CreatedAt.Format "2006-01-02 15:04:05"}} UTC</span>
</div>
<div class="fact">
<span class="fact-label">Status reported</span>
<span class="fact-value">{{.Row.UpdatedAt.Format "2006-01-02 15:04:05"}} UTC</span>
</div>
{{/* The queue id is monospace because it is an identifier to compare
character by character against a log line, not a word to read. */}}
<div class="fact">
<span class="fact-label">Queue id</span>
<span class="fact-value mono">{{if .Row.QueueID}}{{.Row.QueueID}}{{else}}—{{end}}</span>
</div>
<div class="fact">
<span class="fact-label">Journal id</span>
<span class="fact-value mono">{{.Row.ID}}</span>
</div>
</div>
{{if not .Row.QueueID}}
<p class="muted">A message with no queue id was refused before Postfix
queued it — under a level-2 rate limit — so it has no delivery attempt to
show.</p>
{{end}}
</div>
<div class="card" id="history">
<h2>History</h2>
<p class="muted">A row is written when the message is accepted and updated
once when Postfix reports the attempt for this recipient, so these are the
two moments the journal knows about — not a trace of the SMTP conversation,
which is in the delivery log below.</p>
<ol class="timeline">
{{range .Events}}
<li class="event lvl-{{.Level}}{{if .At.IsZero}} pending{{end}}">
<p class="event-time">{{if .At.IsZero}}not yet{{else}}{{.At.Format "2006-01-02 15:04:05"}} UTC{{end}}</p>
<p class="event-title"><span class="st st-{{.Level}}">{{.Status}}</span> {{.Title}}</p>
<p class="event-detail muted">{{.Detail}}</p>
</li>
{{end}}
</ol>
</div>
</div>
{{/* Under both, at the column's full width: what Postfix itself wrote. The
queue id used to be printed on this page as something to go and search the
system log for by hand — this is that search, already done. */}}
<div class="card" id="delivery-log">
<h2>Delivery log</h2>
<p class="muted">The lines Postfix wrote about this message in
<code>mail.log</code>, oldest first — the connection to the receiving server,
its reply, and the status that reply was filed as. Only lines carrying this
message's queue id are shown; the whole log is on the
<a href="/server/log">System log</a> page.</p>
{{if .LogRows}}
{{/* Two columns, the way the send log itself is a table: the times down one
edge are what the message's pace is read off — the seconds between the
connection and the reply — and lining them up is what makes that
readable. A line whose head was not a timestamp keeps its whole text in
the second column and leaves the first empty. */}}
<table class="log">
<thead>
<tr><th>Time</th><th>Message</th></tr>
</thead>
<tbody>
{{range .LogRows}}
<tr>
<td class="time muted">{{if .Time}}{{.Time}}{{else}}—{{end}}</td>
<td class="log-text">{{.Text}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">{{.LogNote}}</p>
{{end}}
</div>
{{end}}
+20 -59
View File
@@ -1,63 +1,24 @@
{{define "wide"}}wide{{end}}
{{/* DMARC reports (mockup dmarc.html): whether aggregate reports are arriving,
and the latest of them across the domains the viewer reaches. */}}
{{define "content"}}
<h1>DMARC reports</h1>
{{template "page_head" .Head}}
<p class="muted">Aggregate reports SelfPost accepted for <code>rua=</code>.
Forensic (<code>ruf=</code>) is out of scope. Open a report for the parsed
XML — not a dashboard.</p>
{{template "box_open" .Ingest}}
{{template "body_open"}}
{{template "facts" .IngestFacts}}
{{template "body_close"}}
{{template "box_close"}}
<div class="pair">
<div class="card">
<h2>Ingest {{if .IngestStats.IngestOK}}<span class="st st-ok">ok</span>{{else}}<span class="st st-warn">quiet</span>{{end}}</h2>
{{if .LastReceivedLabel}}
<p>Last report {{.LastReceivedLabel}} UTC. Port 25 accepts only configured report addresses.</p>
{{else}}
<p>No reports received yet. Point <code>rua=</code> at
<code>{{.HostedAddress}}</code> (or a per-domain hosted address) and publish
MX for {{.HostedAddress}} on this server.</p>
{{end}}
</div>
<div class="card">
<h2>This week</h2>
<p>{{.IngestStats.KeptThisWeek}} kept{{if .IngestStats.ParseFailures}}, {{.IngestStats.ParseFailures}} parse failures{{end}}.
Older summaries are pruned ({{.RetentionMax}} max, {{.RetentionDays}} days).</p>
</div>
</div>
<div class="card">
<h2>Recent reports</h2>
{{if .Reports}}
<table class="desk-only">
<thead><tr><th>Received</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
<tbody>
{{range .Reports}}
<tr>
<td class="time muted">{{.ReceivedLabel}}</td>
<td>{{if .DomainID}}<a href="/outbound/dmarc/domains/{{.DomainID}}">{{.Domain}}</a>{{else}}{{.Domain}}{{end}}</td>
<td class="muted">{{.Reporter}}</td>
<td>{{.PeriodLabel}}</td>
<td>{{.PassCount}}</td>
<td>{{.FailCount}}</td>
<td class="actions"><a href="/outbound/dmarc/reports/{{.ID}}">View</a></td>
</tr>
{{end}}
</tbody>
</table>
<ul class="phone-list phone-only">
{{range .Reports}}
<li>
<a href="/outbound/dmarc/reports/{{.ID}}">
<span class="st {{if gt .FailCount 0}}st-warn{{else}}st-ok{{end}}">{{.PassCount}} pass</span>
<span class="when">{{.ReceivedLabel}}</span>
<span class="meta">{{.Domain}} · {{.Reporter}}</span>
<span class="subj">{{.FailCount}} fail</span>
</a>
</li>
{{end}}
</ul>
{{else}}
<p class="muted">No reports yet.</p>
{{end}}
</div>
{{template "box_open" .Recent}}
{{if .Rows}}
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Received, UTC</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
<tbody>{{range .Rows}}
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">{{.Received}}</td><td>{{if .DomainHref}}<a href="{{.DomainHref}}">{{.Domain}}</a>{{else}}{{.Domain}}{{end}}</td><td class="sp-muted">{{.Reporter}}</td><td>{{.Window}}</td><td>{{.PassText}}</td><td>{{if .Fail}}{{template "tag" .FailTag}}{{else}}0{{end}}</td><td class="sp-actions"><a href="{{.Href}}">View</a></td></tr>{{end}}
</tbody>
</table></div>
{{else}}
{{template "empty_state" .Empty}}
{{end}}
{{template "box_close"}}
{{end}}
+30 -53
View File
@@ -1,59 +1,36 @@
{{/* One sending domain's DMARC reports (mockup dmarc-domain.html): the sources
its mail came from over the window beside the reports that said so. */}}
{{define "content"}}
<h1>{{.Domain.Name}}</h1>
{{template "back_link" (back "/outbound/dmarc" "All DMARC reports")}}
{{template "back_link" (back (printf "/outbound/domains/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}}
{{template "page_head" .Head}}
<div class="pair">
<div class="card">
<h2>Last {{.WindowDays}} days {{if and (gt .Pass7d 0) (eq .Fail7d 0)}}<span class="st st-ok">pass</span>{{else if gt .Fail7d 0}}<span class="st st-warn">review</span>{{end}}</h2>
<p>{{.PolicyHint}}</p>
<p class="muted"><a href="/outbound/domains/{{.Domain.ID}}#dmarc">Domain DNS and rua=</a></p>
<div class="columns">
<div class="column is-5">
{{template "box_open" .Sources}}
{{if .SourceRows}}
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Source</th><th>Pass</th><th>Fail</th><th>Disposition</th></tr></thead>
<tbody>{{range .SourceRows}}
<tr><td class="sp-mono sp-small">{{.Source}}{{if .ThisRelay}} {{template "tag" .Relay}}{{end}}</td><td>{{.PassText}}</td><td>{{.FailText}}</td><td>{{template "tag" .Verdict}}</td></tr>{{end}}
</tbody>
</table></div>
{{else}}
{{template "empty_state" .SourcesEmpty}}
{{end}}
{{template "box_close"}}
</div>
<div class="card">
<h2>Totals</h2>
<p>{{.Pass7d}} pass · {{.Fail7d}} fail in aggregate summaries.</p>
<div class="column is-7">
{{template "box_open" .Reports}}
{{if .Rows}}
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Received, UTC</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
<tbody>{{range .Rows}}
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">{{.Received}}</td><td class="sp-muted">{{.Reporter}}</td><td>{{.Window}}</td><td>{{.PassText}}</td><td>{{if .Fail}}{{template "tag" .FailTag}}{{else}}0{{end}}</td><td class="sp-actions"><a href="{{.Href}}">View</a></td></tr>{{end}}
</tbody>
</table></div>
{{else}}
{{template "empty_state" .ReportsEmpty}}
{{end}}
{{template "box_close"}}
</div>
</div>
<div class="card">
<h2>Reports</h2>
{{if .Reports}}
<table class="desk-only">
<thead><tr><th>Received</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
<tbody>
{{range .Reports}}
<tr>
<td class="time muted">{{.ReceivedLabel}}</td>
<td class="muted">{{.Reporter}}</td>
<td>{{.PeriodLabel}}</td>
<td>{{.PassCount}}</td>
<td>{{.FailCount}}</td>
<td class="actions"><a href="/outbound/dmarc/reports/{{.ID}}">View</a></td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">No reports for this domain yet.</p>
{{end}}
</div>
{{if .Sources}}
<div class="card">
<h2>Sources · last {{.WindowDays}} days</h2>
<table class="desk-only">
<thead><tr><th>Source</th><th>Pass</th><th>Fail</th><th>Disposition</th></tr></thead>
<tbody>
{{range .Sources}}
<tr>
<td class="muted">{{.SourceIP}}{{if .ThisRelay}} (this relay){{end}}</td>
<td>{{.PassCount}}</td>
<td>{{.FailCount}}</td>
<td><span class="st {{if gt .FailCount 0}}st-warn{{else}}st-ok{{end}}">{{.Disposition}}</span></td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
{{end}}
+28 -45
View File
@@ -1,52 +1,35 @@
{{/* One aggregate report (mockup dmarc-report.html): who sent it and the policy
it was written against side by side, and the records under both. */}}
{{define "content"}}
<h1 class="subject">{{.Report.Reporter}} · {{.WindowLabel}}</h1>
<p class="route">
<span class="addr">{{.Report.Domain}}</span>
<span class="st st-ok">{{.Report.PassCount}} pass</span>
{{if gt .Report.FailCount 0}}<span class="st st-warn">{{.Report.FailCount}} fail</span>{{end}}
</p>
{{template "back_link" (back (printf "/outbound/dmarc/domains/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}}
{{template "page_head" .Head}}
<div class="pair">
<div class="card">
<h2>Report</h2>
<div class="facts">
<div class="fact"><span class="fact-label">Reporter</span><span class="fact-value">{{.Report.Reporter}}</span></div>
<div class="fact"><span class="fact-label">Report id</span><span class="fact-value mono">{{.Report.ReportID}}</span></div>
<div class="fact"><span class="fact-label">Window</span><span class="fact-value">{{.PeriodBeginLabel}} – {{.PeriodEndLabel}} UTC</span></div>
<div class="fact"><span class="fact-label">Received</span><span class="fact-value">{{.ReceivedLabel}} UTC</span></div>
{{if .Report.ContactEmail}}<div class="fact"><span class="fact-label">Contact</span><span class="fact-value">{{.Report.ContactEmail}}</span></div>{{end}}
</div>
<div class="columns">
<div class="column is-6">
{{template "box_open" .Report}}
{{template "body_open"}}
{{template "facts" .ReportFacts}}
{{template "body_close"}}
{{template "box_close"}}
</div>
<div class="card{{if gt .Report.FailCount 0}} attn{{end}}">
<h2>Published policy</h2>
<div class="facts">
<div class="fact"><span class="fact-label">Domain</span><span class="fact-value">{{.Report.Domain}}</span></div>
<div class="fact"><span class="fact-label">p / sp / pct</span><span class="fact-value mono">{{.Report.PolicyP}} / {{.Report.PolicySP}} / {{.Report.PolicyPct}}</span></div>
<div class="fact"><span class="fact-label">adkim / aspf</span><span class="fact-value mono">{{.Report.PolicyADKIM}} / {{.Report.PolicyASPF}}</span></div>
{{if .Report.Recipient}}<div class="fact"><span class="fact-label">rua</span><span class="fact-value">{{.Report.Recipient}}</span></div>{{end}}
</div>
<p class="muted"><code>p={{.Report.PolicyP}}</code> is what receivers enforced in this window.</p>
<div class="column is-6">
{{template "box_open" .Policy}}
{{template "body_open"}}
{{template "facts" .PolicyFacts}}
{{template "body_close"}}
{{template "box_close"}}
</div>
</div>
<div class="card">
<h2>Records</h2>
<table class="desk-only">
<thead><tr><th>Source</th><th>Count</th><th>Disposition</th><th>SPF</th><th>DKIM</th><th>Header from</th></tr></thead>
<tbody>
{{range .Report.Records}}
<tr>
<td class="muted">{{.SourceIP}}</td>
<td>{{.Count}}</td>
<td><span class="st {{if or (eq .SPFResult "pass") (eq .DKIMResult "pass")}}st-ok{{else}}st-warn{{end}}">{{.Disposition}}</span></td>
<td><span class="st {{if eq .SPFResult "pass"}}st-ok{{else}}st-error{{end}}">{{.SPFResult}}</span></td>
<td><span class="st {{if eq .DKIMResult "pass"}}st-ok{{else}}st-error{{end}}">{{.DKIMResult}}</span></td>
<td>{{.HeaderFrom}}</td>
</tr>
{{end}}
</tbody>
</table>
<p class="muted">Parsed from the aggregate XML. Forensic (<code>ruf=</code>) samples are not stored.</p>
</div>
{{template "box_open" .Records}}
{{if .RecordRows}}
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Source</th><th>Count</th><th>Disposition</th><th>SPF</th><th>DKIM</th><th>Header from</th></tr></thead>
<tbody>{{range .RecordRows}}
<tr><td class="sp-mono sp-small">{{.Source}}{{if .ThisRelay}} {{template "tag" .Relay}}{{end}}</td><td>{{.CountText}}</td><td>{{template "tag" .Verdict}}</td><td>{{template "tag" .SPFTag}}</td><td>{{template "tag" .DKIMTag}}</td><td>{{.HeaderFrom}}</td></tr>{{end}}
</tbody>
</table></div>
{{else}}
{{template "empty_state" .RecordsEmpty}}
{{end}}
{{template "box_close"}}
{{end}}
+11
View File
@@ -0,0 +1,11 @@
{{/* Outbound log (mockup out-log.html): every message the relay accepted from an
application, newest first, filtered by domain and application. The table and
the count under it are the polled part (out_log_rows.html); the box head with
its filter is static, so a choice being made there is never swapped away. */}}
{{define "content"}}
{{template "page_head" .Head}}
{{template "box_open" .Messages}}
{{template "out_log_rows" .}}
{{template "box_close"}}
{{end}}
@@ -0,0 +1,15 @@
{{/* The polled part of the Outbound log, in the page and in its fragment (GET
/outbound/log/fragment) alike, so the two cannot drift apart: the table, which
polls (panel.js reads its id and data-poll; the response replaces it), and the
foot under it, which the fragment marks to be swapped in by its id (.Foot.OOB)
so the page count follows the rows. */}}
{{define "out_log_rows"}}<div class="table-container" id="out-log-rows" data-poll aria-live="polite" hx-get="{{.PollURL}}" hx-trigger="load" hx-swap="outerHTML">
{{if .Rows}}<table class="table is-fullwidth is-hoverable">
<thead><tr><th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr></thead>
<tbody>{{range .Rows}}
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">{{.Time}}</td><td>{{wbr_at .From}}</td><td>{{wbr_at .To}}</td><td class="sp-clip" title="{{.Subject}}">{{.Subject}}</td><td>{{template "tag" .Status}}</td><td class="sp-actions"><a href="{{.Href}}">Details</a></td></tr>{{end}}
</tbody>
</table>
{{else}}{{template "empty_state" .Empty}}
{{end}}</div>
{{template "box_foot" .Foot}}{{end}}
@@ -0,0 +1,27 @@
{{/* One message of the Outbound log (mockup out-message.html): what the journal
recorded and the steps it went through side by side, and under both what
Postfix itself wrote about the message. */}}
{{define "content"}}
{{template "page_head" .Head}}
<div class="columns">
<div class="column is-7">
{{template "box_open" .Message}}
{{template "body_open"}}
{{template "facts" .Facts}}
{{template "body_close"}}
{{template "box_close"}}
</div>
<div class="column is-5">
{{template "box_open" .History}}
{{template "body_open"}}
{{template "timeline" .Steps}}
{{template "body_close"}}
{{template "box_close"}}
</div>
</div>
{{template "box_open" .Delivery}}
{{if .LogLines}}{{template "log_pane" .LogLines}}{{else}}{{template "empty_state" .LogEmpty}}{{end}}
{{template "box_close"}}
{{end}}
+2 -5
View File
@@ -225,9 +225,9 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
t.Fatalf("New: %v", err)
}
wide := map[string]bool{
"deliveries": true, "delivery": true, "mail_queue": true,
"mail_queue": true,
"system_log": true,
"inbound": true, "inbound_domain": true, "dmarc": true,
"inbound": true, "inbound_domain": true,
}
for name, page := range engine.Pages() {
if kitPages[name] {
@@ -275,9 +275,6 @@ func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) {
"user_delete.html": true,
"inbound_domain.html": true,
"inbound_delete.html": true,
"dmarc_domain.html": true,
"dmarc_report.html": true,
"delivery.html": true,
}
forEachTemplate(t, func(name, body string) {
if !drillDown[name] {
+12 -6
View File
@@ -55,11 +55,11 @@ var pageFiles = map[string][]string{
"inbound": {"templates/inbound.html"},
"inbound_domain": {"templates/inbound_domain.html"},
"inbound_delete": {"templates/inbound_delete.html"},
"out-log": {"templates/out_log.html", "templates/out_log_rows.html"},
"out-message": {"templates/out_message.html"},
"dmarc": {"templates/dmarc.html"},
"dmarc_domain": {"templates/dmarc_domain.html"},
"dmarc_report": {"templates/dmarc_report.html"},
"deliveries": {"templates/deliveries.html", "templates/deliveries_rows.html"},
"delivery": {"templates/delivery.html"},
"dmarc-domain": {"templates/dmarc_domain.html"},
"dmarc-report": {"templates/dmarc_report.html"},
"mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"},
"system_log": {"templates/system_log.html", "templates/system_log_body.html"},
"help": {"templates/help.html"},
@@ -87,12 +87,18 @@ var kitPages = map[string]bool{
"out-app": true,
"out-app-created": true,
"out-domain-delete": true,
"out-log": true,
"out-message": true,
"dmarc": true,
"dmarc-domain": true,
"dmarc-report": true,
}
// fragmentFiles maps a fragment name (also its {{define}} block name) to its
// template file, for standalone rendering by the HTMX polling endpoints.
var fragmentFiles = map[string]string{
"deliveries_rows": "templates/deliveries_rows.html",
"out_log_rows": "templates/out_log_rows.html",
"mail_queue_body": "templates/mail_queue_body.html",
"system_log_body": "templates/system_log_body.html",
"health_body": "templates/health_body.html",
@@ -100,7 +106,7 @@ var fragmentFiles = map[string]string{
}
// kitFragments are the fragments rendered with the component kit's partials.
var kitFragments = map[string]bool{"health_body": true, "overview_poll": true}
var kitFragments = map[string]bool{"health_body": true, "overview_poll": true, "out_log_rows": true}
// New parses embedded templates. version is stamped into every page footer.
func New(version string) (*Engine, error) {