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
+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}}