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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
@@ -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"},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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&domain=shop.example.org&p=3"`,
|
||||
`href="/outbound/log/7?app=alerts&domain=shop.example.org&p=3"`,
|
||||
`Page 3 of 5 · <a href="/outbound/log?app=alerts&domain=shop.example.org&p=2">← Newer</a>`,
|
||||
`<a href="/outbound/log?app=alerts&domain=shop.example.org&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, `<script>alert(1)</script>`)
|
||||
}
|
||||
|
||||
// ---- 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=<noc@example.org>`,
|
||||
)
|
||||
}
|
||||
|
||||
// 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&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)
|
||||
}
|
||||
}
|
||||
@@ -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}} · <a href="/outbound/log?domain={{.FilterDomain | urlquery}}&app={{.FilterApp | urlquery}}&p={{.PrevPage}}">← Newer</a>{{end}}
|
||||
{{if .HasNext}} · <a href="/outbound/log?domain={{.FilterDomain | urlquery}}&app={{.FilterApp | urlquery}}&p={{.NextPage}}">Older →</a>{{end}}
|
||||
</p>
|
||||
{{else}}
|
||||
<p class="muted">No messages logged yet.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -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">→</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}}
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
@@ -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] {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user