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:
@@ -13,16 +13,16 @@ func TestE2ESendLogStatusMarkupDrift(t *testing.T) {
|
||||
rendered := getBody(t, h.HandleDeliveriesRows, "/outbound/log/fragment")
|
||||
|
||||
// These mirror test/e2e/main_test.go — keep in sync when fixing the e2e gate.
|
||||
statusCellPattern := regexp.MustCompile(`class="st st-[^"]+">(queued|sent|deferred|bounced|rejected)</span>`)
|
||||
statusCellPattern := regexp.MustCompile(`<span class="tag[^"]*">(queued|sent|deferred|bounced|rejected)</span>`)
|
||||
containsCell := func(html, needle string) bool {
|
||||
return strings.Contains(html, `<span class="st st-`) && strings.Contains(html, `">`+needle+`</span>`)
|
||||
return strings.Contains(html, `<span class="tag`) && strings.Contains(html, `">`+needle+`</span>`)
|
||||
}
|
||||
|
||||
if statusCellPattern.FindStringSubmatch(rendered) == nil {
|
||||
t.Fatalf("e2e statusCellPattern does not match rendered send-log rows:\n%s", snippet(rendered, `class="status"`))
|
||||
t.Fatalf("e2e statusCellPattern does not match rendered send-log rows:\n%s", snippet(rendered, `<td>`))
|
||||
}
|
||||
if !containsCell(rendered, "sent") {
|
||||
t.Fatalf("e2e containsCell does not match rendered send-log rows:\n%s", snippet(rendered, `class="status"`))
|
||||
t.Fatalf("e2e containsCell does not match rendered send-log rows:\n%s", snippet(rendered, `<td>`))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dmarc"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// The DMARC pages, rendered from the real stores and the handlers. Their markup
|
||||
// is held to the mockups by the guards in internal/web/view; what is checked
|
||||
// here is that the handlers fill the typed pages from what is stored and that
|
||||
// each page reaches only what its viewer may see.
|
||||
|
||||
// dmarcStand is a panel with report ingest on and one report stored for the
|
||||
// stand's domain: a relay that passed and a source that failed both checks.
|
||||
type dmarcStand struct {
|
||||
appStand
|
||||
reportID int64
|
||||
}
|
||||
|
||||
func newDMARCStand(t *testing.T) dmarcStand {
|
||||
t.Helper()
|
||||
s := dmarcStand{appStand: newAppStand(t)}
|
||||
s.h.cfg.DMARCEnabled, s.h.cfg.Hostname = true, "mail.example.org"
|
||||
s.h.dmarc = dmarc.NewService(s.h.store, nil, "mail.example.org", true)
|
||||
received := time.Now().UTC().Add(-2 * time.Hour)
|
||||
id, err := s.h.store.InsertDMARCReport(store.DMARCReport{
|
||||
Domain: "example.org", Reporter: "Outlook.com", ReportID: "5f1c9a0e7b2d4e61a3",
|
||||
PeriodBegin: received.Add(-30 * time.Hour), PeriodEnd: received.Add(-6 * time.Hour), ReceivedAt: received,
|
||||
ContactEmail: "noreply-dmarc@outlook.example", PolicyP: "none", PolicySP: "none", PolicyPct: 100,
|
||||
PolicyADKIM: "r", PolicyASPF: "r", PassCount: 1903, FailCount: 7, Recipient: "dmarc-reports@mail.example.org",
|
||||
Records: []store.DMARCReportRecord{
|
||||
{SourceIP: "203.0.113.25", Count: 1903, Disposition: "none", SPFResult: "pass", DKIMResult: "pass", HeaderFrom: "example.org"},
|
||||
{SourceIP: "198.51.100.44", Count: 7, Disposition: "none", SPFResult: "fail", DKIMResult: "fail", HeaderFrom: "example.org"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InsertDMARCReport: %v", err)
|
||||
}
|
||||
s.reportID = id
|
||||
return s
|
||||
}
|
||||
|
||||
func TestDMARCHubShowsIngestAndRecentReports(t *testing.T) {
|
||||
s := newDMARCStand(t)
|
||||
body := getBody(t, s.h.HandleDMARCList, "/outbound/dmarc")
|
||||
has(t, "hub", body,
|
||||
`<h1 class="title is-3">DMARC reports</h1>`, `<h2>Ingest</h2>`, `<span class="tag is-success is-light">ok</span>`,
|
||||
`1 kept · 0 parse failures`, `dmarc-reports@mail.example.org`, `500 reports or 90 days`,
|
||||
`<h2>Recent reports</h2>`, `<a href="/outbound/dmarc/domains/1">example.org</a>`, `Outlook.com`,
|
||||
"<td>1\u00a0903</td>", `<span class="tag is-warning is-light">7</span>`, `href="/outbound/dmarc/reports/`+idStr(s.reportID)+`">View</a>`)
|
||||
lacks(t, "hub", body, `None yet`)
|
||||
}
|
||||
|
||||
// Before the first report the page says how to get one rather than showing an
|
||||
// empty table next to a healthy-looking stamp.
|
||||
func TestDMARCHubBeforeTheFirstReport(t *testing.T) {
|
||||
s := newAppStand(t)
|
||||
s.h.cfg.DMARCEnabled, s.h.cfg.Hostname = true, "mail.example.org"
|
||||
s.h.dmarc = dmarc.NewService(s.h.store, nil, "mail.example.org", true)
|
||||
body := getBody(t, s.h.HandleDMARCList, "/outbound/dmarc")
|
||||
has(t, "hub", body, `<span class="tag is-warning is-light">quiet</span>`, `None yet`, `<code>rua=</code>`,
|
||||
`publish MX for <span class="sp-mono">mail.example.org</span>`, `No reports yet.`)
|
||||
lacks(t, "hub", body, `<table`)
|
||||
}
|
||||
|
||||
// A report names its reporter and its domain in its own words, and what a
|
||||
// remote sender chose to write is text on the page, never markup.
|
||||
func TestDMARCPagesEscapeWhatReportersWrote(t *testing.T) {
|
||||
s := newDMARCStand(t)
|
||||
id, err := s.h.store.InsertDMARCReport(store.DMARCReport{
|
||||
Domain: "example.org", Reporter: `<script>alert(1)</script>`, ReportID: `"><img src=x>`,
|
||||
PeriodBegin: time.Now().Add(-48 * time.Hour), PeriodEnd: time.Now().Add(-24 * time.Hour), ReceivedAt: time.Now(),
|
||||
ContactEmail: `"><b>x</b>`, PolicyP: `<i>none</i>`, PassCount: 1, FailCount: 1,
|
||||
Records: []store.DMARCReportRecord{{SourceIP: `<u>1.2.3.4</u>`, Count: 2, Disposition: `<s>none</s>`, SPFResult: `<em>x`, DKIMResult: "fail", HeaderFrom: `<p>`}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, body := range map[string]string{
|
||||
"hub": getBody(t, s.h.HandleDMARCList, "/outbound/dmarc"),
|
||||
"domain": pageOf(t, s.h.HandleDMARCDomain, "/outbound/dmarc/domains/1"),
|
||||
"report": reportPage(t, s, id),
|
||||
} {
|
||||
lacks(t, name, body, `<script>alert`, `<img src=x>`, `<b>x</b>`, `<i>none</i>`, `<u>1.2.3.4`, `<s>none`, `<em>x`)
|
||||
}
|
||||
has(t, "report", reportPage(t, s, id), `<script>alert(1)</script>`, `<u>1.2.3.4</u>`)
|
||||
}
|
||||
|
||||
func reportPage(t *testing.T, s dmarcStand, id int64) string {
|
||||
t.Helper()
|
||||
rec := send(s.h.HandleDMARCReport, &globalPrincipal, "GET", "/outbound/dmarc/reports/"+idStr(id), map[string]string{"id": idStr(id)}, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("report %d = %d:\n%s", id, rec.Code, rec.Body.String())
|
||||
}
|
||||
return rec.Body.String()
|
||||
}
|
||||
|
||||
// The page of a domain with sources: the table of where its mail came from, the
|
||||
// stamp that says how much of it passed and the advice that follows. A source
|
||||
// that is this server is told apart from one that is not.
|
||||
func TestDMARCDomainShowsSourcesAndWhatToDoAboutThem(t *testing.T) {
|
||||
s := newDMARCStand(t)
|
||||
s.h.dns = offlineDNS
|
||||
body := pageOf(t, s.h.HandleDMARCDomain, "/outbound/dmarc/domains/1")
|
||||
has(t, "domain", body,
|
||||
`<h1 class="title is-3">example.org</h1>`, `<a href="/outbound/dmarc">DMARC reports</a> /`,
|
||||
`class="sp-postmark sp-warn"`, `<b>99.6%</b>`, `PASS · 7 DAYS`,
|
||||
"1\u00a0903 pass · 7 fail in the last 7 days. A third-party source is not aligned. Do not tighten <code>p=</code>",
|
||||
`href="/outbound/domains/1"`, `<h2>Sources · 7 days</h2>`, `198.51.100.44`, `203.0.113.25`, `<h2>Reports</h2>`,
|
||||
`href="/outbound/dmarc/reports/`+idStr(s.reportID)+`">View</a>`)
|
||||
// 198.51.100.44 passed nothing, so its disposition is amber.
|
||||
has(t, "domain", body, `<td>0</td><td>7</td><td><span class="tag is-warning is-light">none</span></td>`)
|
||||
lacks(t, "domain", body, `this relay`)
|
||||
}
|
||||
|
||||
func TestDMARCDomainWithoutReportsSaysSo(t *testing.T) {
|
||||
s := newAppStand(t)
|
||||
s.h.cfg.DMARCEnabled, s.h.cfg.Hostname = true, "mail.example.org"
|
||||
s.h.dmarc = dmarc.NewService(s.h.store, nil, "mail.example.org", true)
|
||||
body := pageOf(t, s.h.HandleDMARCDomain, "/outbound/dmarc/domains/1")
|
||||
has(t, "domain", body, `No reports for this domain yet.`, `No mail from any source in this window.`,
|
||||
`0 pass · 0 fail in the last 7 days. No messages in the reporting window yet.`)
|
||||
lacks(t, "domain", body, `sp-postmark`, `<table`)
|
||||
}
|
||||
|
||||
func TestDMARCReportShowsPolicyAndRecords(t *testing.T) {
|
||||
s := newDMARCStand(t)
|
||||
body := reportPage(t, s, s.reportID)
|
||||
has(t, "report", body,
|
||||
`<h1 class="title is-4">Outlook.com · `, `<a href="/outbound/dmarc">DMARC reports</a> /`, `<a href="/outbound/dmarc/domains/1">example.org</a> /`,
|
||||
"<span class=\"tag is-success is-light\">1\u00a0903 pass</span>", `<span class="tag is-warning is-light">7 fail</span>`,
|
||||
`5f1c9a0e7b2d4e61a3`, `noreply-dmarc@outlook.example`, ` UTC</dd>`,
|
||||
`none / none / 100`, `<dd class="sp-mono">r / r</dd>`, `dmarc-reports@mail.example.org`,
|
||||
`<h2>Records</h2>`, `Parsed from the aggregate XML`, `203.0.113.25`, `198.51.100.44`,
|
||||
`<span class="tag is-danger is-light">fail</span>`, `<span class="tag is-success is-light">pass</span>`)
|
||||
}
|
||||
|
||||
// DMARC is a feature of the deployment: with ingest off none of its pages
|
||||
// exists, whoever asks.
|
||||
func TestDMARCPagesAreAbsentWithIngestOff(t *testing.T) {
|
||||
s := newDMARCStand(t)
|
||||
s.h.cfg.DMARCEnabled = false
|
||||
for name, h := range map[string]http.HandlerFunc{
|
||||
"hub": s.h.HandleDMARCList, "domain": s.h.HandleDMARCDomain, "report": s.h.HandleDMARCReport,
|
||||
} {
|
||||
rec := send(h, &globalPrincipal, "GET", "/outbound/dmarc", map[string]string{"id": "1"}, nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("%s with ingest off = %d, want 404", name, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A domain administrator reaches the reports of the domains assigned to them
|
||||
// and nothing else: not the hub, not another tenant's domain page, not another
|
||||
// tenant's report. A report that does not exist is the same 404 as one that is
|
||||
// not theirs.
|
||||
func TestDMARCPagesStayWithinTheViewersDomains(t *testing.T) {
|
||||
s := newDMARCStand(t)
|
||||
s.h.dns = offlineDNS
|
||||
other, err := s.h.store.AddDomain("other.example.net", "mail")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherReport, err := s.h.store.InsertDMARCReport(store.DMARCReport{
|
||||
Domain: other.Name, Reporter: "google.com", ReportID: "other", ReceivedAt: time.Now(),
|
||||
PeriodBegin: time.Now().Add(-24 * time.Hour), PeriodEnd: time.Now(), PassCount: 9,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ops := domainAdmin(t, s.h.store, "ops", s.d.ID)
|
||||
|
||||
// Their own domain and report are theirs; the way back to the hub is not
|
||||
// offered, since the hub is not theirs.
|
||||
own := send(s.h.HandleDMARCDomain, &ops, "GET", "/outbound/dmarc/domains/1", map[string]string{"id": "1"}, nil)
|
||||
if own.Code != http.StatusOK {
|
||||
t.Fatalf("own domain = %d", own.Code)
|
||||
}
|
||||
has(t, "domain for its administrator", own.Body.String(), `<h1 class="title is-3">example.org</h1>`)
|
||||
lacks(t, "domain for its administrator", own.Body.String(), `<a href="/outbound/dmarc">`)
|
||||
rep := send(s.h.HandleDMARCReport, &ops, "GET", "/outbound/dmarc/reports/"+idStr(s.reportID), map[string]string{"id": idStr(s.reportID)}, nil)
|
||||
if rep.Code != http.StatusOK {
|
||||
t.Fatalf("own report = %d", rep.Code)
|
||||
}
|
||||
lacks(t, "report for its administrator", rep.Body.String(), `<a href="/outbound/dmarc">`)
|
||||
|
||||
for name, code := range map[string]int{
|
||||
"hub": send(s.h.HandleDMARCList, &ops, "GET", "/outbound/dmarc", nil, nil).Code,
|
||||
"another tenant's domain": send(s.h.HandleDMARCDomain, &ops, "GET", "/outbound/dmarc/domains/"+idStr(other.ID),
|
||||
map[string]string{"id": idStr(other.ID)}, nil).Code,
|
||||
"another tenant's report": send(s.h.HandleDMARCReport, &ops, "GET", "/outbound/dmarc/reports/"+idStr(otherReport),
|
||||
map[string]string{"id": idStr(otherReport)}, nil).Code,
|
||||
"a report that does not exist": send(s.h.HandleDMARCReport, &ops, "GET", "/outbound/dmarc/reports/999",
|
||||
map[string]string{"id": "999"}, nil).Code,
|
||||
} {
|
||||
if code != http.StatusNotFound {
|
||||
t.Errorf("%s for a domain administrator = %d, want 404", name, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -10,13 +9,16 @@ import (
|
||||
"github.com/mixeme/selfpost/internal/dmarc"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
"github.com/mixeme/selfpost/internal/web/view"
|
||||
)
|
||||
|
||||
type dmarcListRow struct {
|
||||
store.DMARCReportSummary
|
||||
DomainID int64
|
||||
ReceivedLabel string
|
||||
PeriodLabel string
|
||||
// dmarcRow is a report in a table of the pages.
|
||||
func dmarcRow(rep store.DMARCReportSummary) view.DMARCReportRow {
|
||||
return view.DMARCReportRow{
|
||||
Received: view.FormatReceived(rep.ReceivedAt), Domain: rep.Domain, Reporter: rep.Reporter,
|
||||
Window: formatDMARCWindow(rep.PeriodBegin, rep.PeriodEnd), Pass: rep.PassCount, Fail: rep.FailCount,
|
||||
Href: view.DMARCReportHref(rep.ID),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) requireDMARC(w http.ResponseWriter, r *http.Request) (auth.Principal, bool) {
|
||||
@@ -69,27 +71,22 @@ func (h *Handlers) HandleDMARCList(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
rows := make([]dmarcListRow, len(reports))
|
||||
rows := make([]view.DMARCReportRow, len(reports))
|
||||
for i, rep := range reports {
|
||||
rows[i] = dmarcListRow{
|
||||
DMARCReportSummary: rep,
|
||||
DomainID: domainIDs[rep.Domain],
|
||||
ReceivedLabel: rep.ReceivedAt.UTC().Format("2006-01-02 15:04"),
|
||||
PeriodLabel: formatDMARCWindow(rep.PeriodBegin, rep.PeriodEnd),
|
||||
rows[i] = dmarcRow(rep)
|
||||
if id := domainIDs[rep.Domain]; id != 0 {
|
||||
rows[i].DomainHref = view.DMARCDomainHref(id)
|
||||
}
|
||||
}
|
||||
data := h.pageBase(r)
|
||||
data["Title"] = "SelfPost — DMARC reports"
|
||||
data["Active"] = "dmarc"
|
||||
data["Reports"] = rows
|
||||
data["IngestStats"] = stats
|
||||
data["HostedAddress"] = h.dmarc.DefaultHostedSuggestion()
|
||||
data["RetentionMax"] = store.DMARCReportsMaxKeep
|
||||
data["RetentionDays"] = store.DMARCReportsMaxAgeDays
|
||||
if stats.LastReceivedAt != nil {
|
||||
data["LastReceivedLabel"] = stats.LastReceivedAt.UTC().Format("2006-01-02 15:04")
|
||||
in := view.IngestInput{
|
||||
OK: stats.IngestOK, KeptThisWeek: stats.KeptThisWeek, ParseFailures: stats.ParseFailures,
|
||||
Hosted: h.dmarc.DefaultHostedSuggestion(), Host: h.cfg.Hostname,
|
||||
RetentionMax: store.DMARCReportsMaxKeep, RetentionDays: store.DMARCReportsMaxAgeDays,
|
||||
}
|
||||
h.view.Render(w, http.StatusOK, "dmarc", data)
|
||||
if stats.LastReceivedAt != nil {
|
||||
in.Last = *stats.LastReceivedAt
|
||||
}
|
||||
h.view.Render(w, http.StatusOK, "dmarc", view.NewDMARCHub(h.shellMeta(r), in).WithReports(rows))
|
||||
}
|
||||
|
||||
// HandleDMARCDomain shows roll-ups for one sending domain.
|
||||
@@ -125,34 +122,23 @@ func (h *Handlers) HandleDMARCDomain(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
reportRows := make([]dmarcListRow, len(reports))
|
||||
reportRows := make([]view.DMARCReportRow, len(reports))
|
||||
for i, rep := range reports {
|
||||
reportRows[i] = dmarcListRow{
|
||||
DMARCReportSummary: rep,
|
||||
ReceivedLabel: rep.ReceivedAt.UTC().Format("2006-01-02 15:04"),
|
||||
PeriodLabel: formatDMARCWindow(rep.PeriodBegin, rep.PeriodEnd),
|
||||
}
|
||||
reportRows[i] = dmarcRow(rep)
|
||||
}
|
||||
relay := h.relayIPs()
|
||||
hints := make([]dmarc.SourceHint, len(sources))
|
||||
sourceRows := make([]view.DMARCSourceRow, len(sources))
|
||||
for i, s := range sources {
|
||||
hints[i] = dmarc.SourceHint{
|
||||
SourceIP: s.SourceIP,
|
||||
PassCount: s.PassCount,
|
||||
FailCount: s.FailCount,
|
||||
ThisRelay: h.sourceIsThisRelay(s.SourceIP),
|
||||
hints[i] = dmarc.SourceHint{SourceIP: s.SourceIP, PassCount: s.PassCount, FailCount: s.FailCount, ThisRelay: relay[s.SourceIP]}
|
||||
sourceRows[i] = view.DMARCSourceRow{
|
||||
Source: s.SourceIP, ThisRelay: relay[s.SourceIP], Pass: s.PassCount, Fail: s.FailCount, Disposition: s.Disposition,
|
||||
}
|
||||
}
|
||||
data := h.pageBase(r)
|
||||
data["Title"] = "SelfPost — " + d.Name + " DMARC"
|
||||
data["Active"] = "dmarc"
|
||||
data["Domain"] = d
|
||||
data["Reports"] = reportRows
|
||||
data["Pass7d"] = pass
|
||||
data["Fail7d"] = fail
|
||||
data["Sources"] = hints
|
||||
data["PolicyHint"] = dmarc.TightenPolicyHint(pass, fail, hints)
|
||||
data["WindowDays"] = windowDays
|
||||
h.view.Render(w, http.StatusOK, "dmarc_domain", data)
|
||||
page := view.NewDMARCDomain(h.shellMeta(r), d.ID, d.Name, p.IsGlobal(), pass, fail, windowDays,
|
||||
dmarc.TightenPolicyHint(pass, fail, hints)).
|
||||
WithSources(sourceRows).WithReports(reportRows)
|
||||
h.view.Render(w, http.StatusOK, "dmarc-domain", page)
|
||||
}
|
||||
|
||||
// HandleDMARCReport shows one parsed aggregate report.
|
||||
@@ -195,30 +181,40 @@ func (h *Handlers) HandleDMARCReport(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
data := h.pageBase(r)
|
||||
data["Title"] = fmt.Sprintf("SelfPost — %s report", rep.Reporter)
|
||||
data["Active"] = "dmarc"
|
||||
data["Report"] = rep
|
||||
data["Domain"] = d
|
||||
data["Hostname"] = h.cfg.Hostname
|
||||
data["WindowLabel"] = formatDMARCWindow(rep.PeriodBegin, rep.PeriodEnd)
|
||||
data["ReceivedLabel"] = rep.ReceivedAt.UTC().Format("2006-01-02 15:04")
|
||||
data["PeriodBeginLabel"] = rep.PeriodBegin.UTC().Format("2006-01-02 15:04")
|
||||
data["PeriodEndLabel"] = rep.PeriodEnd.UTC().Format("2006-01-02 15:04")
|
||||
h.view.Render(w, http.StatusOK, "dmarc_report", data)
|
||||
}
|
||||
|
||||
func (h *Handlers) sourceIsThisRelay(ip string) bool {
|
||||
if ip == "" || h.dns == nil || h.cfg.Hostname == "" {
|
||||
return false
|
||||
}
|
||||
srv := h.dns.Server(h.cfg.Hostname, false)
|
||||
for _, s := range srv.IPs {
|
||||
if ip == s {
|
||||
return true
|
||||
relay := h.relayIPs()
|
||||
records := make([]view.DMARCRecordRow, len(rep.Records))
|
||||
for i, rec := range rep.Records {
|
||||
records[i] = view.DMARCRecordRow{
|
||||
Source: rec.SourceIP, ThisRelay: relay[rec.SourceIP], Count: rec.Count, Disposition: rec.Disposition,
|
||||
SPF: rec.SPFResult, DKIM: rec.DKIMResult, HeaderFrom: rec.HeaderFrom,
|
||||
}
|
||||
}
|
||||
return false
|
||||
const stamp = "2 Jan 15:04"
|
||||
page := view.NewDMARCReport(h.shellMeta(r), view.ReportInput{
|
||||
ID: rep.ID, Reporter: rep.Reporter, ReportID: rep.ReportID, Domain: rep.Domain, DomainID: d.ID,
|
||||
Contact: rep.ContactEmail, Pass: rep.PassCount, Fail: rep.FailCount,
|
||||
Window: formatDMARCWindow(rep.PeriodBegin, rep.PeriodEnd),
|
||||
Period: rep.PeriodBegin.UTC().Format(stamp) + " – " + rep.PeriodEnd.UTC().Format(stamp) + " UTC",
|
||||
Received: rep.ReceivedAt.UTC().Format("2006-01-02 15:04") + " UTC",
|
||||
PolicyP: rep.PolicyP, PolicySP: rep.PolicySP, PolicyPct: rep.PolicyPct,
|
||||
PolicyADKIM: rep.PolicyADKIM, PolicyASPF: rep.PolicyASPF, Recipient: rep.Recipient,
|
||||
Hub: p.IsGlobal(),
|
||||
}).WithRecords(records)
|
||||
h.view.Render(w, http.StatusOK, "dmarc-report", page)
|
||||
}
|
||||
|
||||
// relayIPs is the set of addresses this server sends from, so that a source in
|
||||
// a report can be told to be this relay. Empty while the server's name or its
|
||||
// addresses are unknown.
|
||||
func (h *Handlers) relayIPs() map[string]bool {
|
||||
if h.dns == nil || h.cfg.Hostname == "" {
|
||||
return nil
|
||||
}
|
||||
ips := map[string]bool{}
|
||||
for _, ip := range h.dns.Server(h.cfg.Hostname, false).IPs {
|
||||
ips[ip] = true
|
||||
}
|
||||
return ips
|
||||
}
|
||||
|
||||
func formatDMARCWindow(begin, end time.Time) string {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
"github.com/mixeme/selfpost/internal/web/view"
|
||||
)
|
||||
|
||||
// sendLogPageSize bounds each send-log page (product.md's monitoring screens
|
||||
@@ -27,37 +28,32 @@ const (
|
||||
deliveryLogLines = 200
|
||||
)
|
||||
|
||||
// HandleDeliveries renders the Deliveries page over the send log: server-side
|
||||
// filters by domain/application and pagination (architecture.md §
|
||||
// Persistence). The row table itself is the "deliveries_rows" fragment, shared
|
||||
// verbatim with HandleDeliveriesRows so the initial page and its HTMX-polled
|
||||
// refreshes never diverge.
|
||||
// HandleDeliveries renders the Outbound log over the send log: server-side
|
||||
// filters by domain/application and pagination (architecture.md § Persistence).
|
||||
// The table and the count under it are the polled region, rendered by the same
|
||||
// template for the page and for HandleDeliveriesRows, so the initial page and
|
||||
// its HTMX-polled refreshes never diverge.
|
||||
func (h *Handlers) HandleDeliveries(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.sendLogData(r)
|
||||
page, err := h.outLogPage(r)
|
||||
if err != nil {
|
||||
logf("panel: send log: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data["Title"] = "SelfPost — deliveries"
|
||||
for k, v := range h.pageBase(r) {
|
||||
data[k] = v
|
||||
}
|
||||
data["Active"] = "deliveries"
|
||||
h.view.Render(w, http.StatusOK, "deliveries", data)
|
||||
h.view.Render(w, http.StatusOK, "out-log", page)
|
||||
}
|
||||
|
||||
// HandleDeliveriesRows serves the HTMX polling fragment for the delivery table
|
||||
// HandleDeliveriesRows serves the HTMX polling fragment for the log
|
||||
// (architecture.md § Panel HTTP surface: fragment endpoints return HTML, not
|
||||
// JSON).
|
||||
// JSON): the table, and the foot under it for the page to swap in.
|
||||
func (h *Handlers) HandleDeliveriesRows(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.sendLogData(r)
|
||||
page, err := h.outLogPage(r)
|
||||
if err != nil {
|
||||
logf("panel: send log rows: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.view.RenderFragment(w, http.StatusOK, "deliveries_rows", data)
|
||||
h.view.RenderFragment(w, http.StatusOK, "out_log_rows", page.Fragment())
|
||||
}
|
||||
|
||||
// HandleDelivery renders one send-log row in full. The log itself carries only
|
||||
@@ -94,79 +90,44 @@ func (h *Handlers) HandleDelivery(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !p.IsGlobal() {
|
||||
allowed, err := h.assignedDomains(p)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !domainNameSet(allowed)[row.Domain] {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
allowed, err := h.assignedDomains(p)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !p.IsGlobal() && !domainNameSet(allowed)[row.Domain] {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
row.Subject = mailhdr.DecodeSubject(row.Subject)
|
||||
logRows, logNote := h.deliveryLog(row)
|
||||
h.view.Render(w, http.StatusOK, "delivery", map[string]any{
|
||||
"Title": "SelfPost — delivery",
|
||||
"User": auth.CurrentUser(r),
|
||||
"Active": "deliveries",
|
||||
"IsGlobal": p.IsGlobal(),
|
||||
"SendLogRetentionDays": h.sendLogRetentionDays(),
|
||||
"Row": row,
|
||||
// The status in the panel's own badge vocabulary, so the headline reads
|
||||
// the same way as every other health signal in the panel.
|
||||
"Level": deliveryLevel(row.Status),
|
||||
"Events": deliveryEvents(row, h.cfg.RetryPolicy),
|
||||
logLines, logNote := h.deliveryLog(row)
|
||||
|
||||
in := view.MessageInput{
|
||||
ID: row.ID, QueueID: row.QueueID, Domain: row.Domain, App: row.AppLogin,
|
||||
From: row.From, To: row.To, Subject: row.Subject, Status: row.Status,
|
||||
Accepted: row.CreatedAt, Reported: row.UpdatedAt,
|
||||
// Where the row came from, so the way back returns to the page and filters
|
||||
// the operator was looking at rather than the top of an unfiltered log.
|
||||
BackHref: deliveriesBackURL(r),
|
||||
}
|
||||
// The names link to their pages when there is a page to go to: a domain
|
||||
// that was deleted since, or an application of another domain, is text.
|
||||
for _, d := range allowed {
|
||||
if d.Name != row.Domain {
|
||||
continue
|
||||
}
|
||||
in.DomainHref = view.DomainHref(d.ID)
|
||||
if a, err := h.store.GetApplicationByLogin(row.AppLogin); err == nil && a.DomainID == d.ID {
|
||||
in.AppHref = view.DomainHref(d.ID) + "/applications/" + strconv.FormatInt(a.ID, 10)
|
||||
}
|
||||
break
|
||||
}
|
||||
page := view.NewOutMessage(h.shellMeta(r), in).
|
||||
WithHistory(deliveryEvents(row, h.cfg.RetryPolicy)).
|
||||
// The mail.log lines for this message, and — when there are none — the
|
||||
// reason, which is a normal outcome rather than a failure.
|
||||
"LogRows": logRows,
|
||||
"LogNote": logNote,
|
||||
// Where the row came from, so "Back" returns to the page and filters
|
||||
// the operator was looking at rather than the top of an unfiltered log.
|
||||
"BackURL": deliveriesBackURL(r),
|
||||
})
|
||||
}
|
||||
|
||||
// deliveryLevel maps a send-log status onto the ok/warn/error/unknown badge
|
||||
// vocabulary the status page and the DNS checks already use (see .st in
|
||||
// legacy.css), so a colour means the same thing on every page: delivered is the
|
||||
// good outcome, deferred is not settled yet, and the two refusals are failures.
|
||||
// A queued row is "unknown" rather than "warn" — nothing has gone wrong, it is
|
||||
// simply that nothing has been reported.
|
||||
func deliveryLevel(status string) string {
|
||||
switch status {
|
||||
case store.StatusSent:
|
||||
return "ok"
|
||||
case store.StatusDeferred:
|
||||
return "warn"
|
||||
case store.StatusBounced, store.StatusRejected:
|
||||
return "error"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// sendLogRow is a row of the send log as the table draws it: the stored row
|
||||
// plus the badge level its status maps onto. The level is carried rather than
|
||||
// derived in the template because deliveryLevel is the one place that decides
|
||||
// what a status means — the delivery page already reads it, and a second
|
||||
// mapping written in the template or the stylesheet would be free to drift
|
||||
// from it.
|
||||
type sendLogRow struct {
|
||||
store.SendLogRow
|
||||
Level string // ok / warn / error / unknown, as deliveryLevel returns
|
||||
}
|
||||
|
||||
// deliveryEvent is one step of a message's history, as the timeline on the
|
||||
// delivery page draws it. At is zero for the step that has not happened yet —
|
||||
// the delivery report a queued message is still waiting for.
|
||||
type deliveryEvent struct {
|
||||
At time.Time
|
||||
Level string // ok / warn / error / unknown, as deliveryLevel returns
|
||||
Status string // the send-log status value this step reached
|
||||
Title string
|
||||
Detail string
|
||||
WithDeliveryLog(logLines, logNote)
|
||||
h.view.Render(w, http.StatusOK, "out-message", page)
|
||||
}
|
||||
|
||||
// deliveryEvents turns a row's two timestamps into the history the page shows.
|
||||
@@ -177,91 +138,60 @@ type deliveryEvent struct {
|
||||
// six hours, then delivered" rather than as two dates in a list of fields.
|
||||
// policy supplies the human intervals for deferred and bounced copy, the same
|
||||
// strings the Mail queue card prints, so the two cannot drift.
|
||||
func deliveryEvents(row store.SendLogRow, policy postfix.RetryPolicy) []deliveryEvent {
|
||||
func deliveryEvents(row store.SendLogRow, policy postfix.RetryPolicy) []view.Step {
|
||||
// step states what happened and when; outcome is ok, warn, error or "" for a
|
||||
// step that reports nothing either way, and a zero time is a step that has
|
||||
// not happened yet.
|
||||
step := func(at time.Time, outcome, title, detail string) view.Step {
|
||||
return view.Step{
|
||||
Time: view.StepTime(at),
|
||||
Strong: title,
|
||||
Text: view.Plain(" — " + detail),
|
||||
Level: view.StepLevel(outcome, !at.IsZero()),
|
||||
}
|
||||
}
|
||||
|
||||
// A rejected message has no second step, and its first one is not an
|
||||
// acceptance: the journal-milter refused it, so Postfix never queued it.
|
||||
if row.Status == store.StatusRejected {
|
||||
return []deliveryEvent{{
|
||||
At: row.CreatedAt,
|
||||
Level: "error",
|
||||
Status: store.StatusRejected,
|
||||
Title: "Refused before queueing",
|
||||
Detail: "The journal-milter refused the message under a rate limit. It was never queued, so there is no queue id and Postfix never attempted delivery.",
|
||||
}}
|
||||
return []view.Step{step(row.CreatedAt, "error", "Refused before queueing",
|
||||
"The journal-milter refused the message under a rate limit. It was never queued, so there is no queue id and Postfix never attempted delivery.")}
|
||||
}
|
||||
|
||||
events := []deliveryEvent{{
|
||||
At: row.CreatedAt,
|
||||
Level: "unknown",
|
||||
Status: store.StatusQueued,
|
||||
Title: "Accepted and queued",
|
||||
Detail: "Postfix accepted the message over an authenticated submission and the journal-milter recorded it. Delivery to the recipient had not been attempted yet.",
|
||||
}}
|
||||
events := []view.Step{step(row.CreatedAt, "", "Accepted and queued",
|
||||
"Postfix accepted the message over an authenticated submission and the journal-milter recorded it. Delivery to the recipient had not been attempted yet.")}
|
||||
|
||||
switch row.Status {
|
||||
case store.StatusQueued:
|
||||
// The step that has not happened. Drawn as an open dot with no time.
|
||||
return append(events, deliveryEvent{
|
||||
Level: "unknown",
|
||||
Status: store.StatusQueued,
|
||||
Title: "Waiting for a delivery report",
|
||||
Detail: "Postfix has not reported an attempt for this recipient yet. The Mail queue page shows what it is still holding.",
|
||||
})
|
||||
return append(events, step(time.Time{}, "", "Waiting for a delivery report",
|
||||
"Postfix has not reported an attempt for this recipient yet. The Mail queue page shows what it is still holding."))
|
||||
case store.StatusSent:
|
||||
return append(events, deliveryEvent{
|
||||
At: row.UpdatedAt,
|
||||
Level: "ok",
|
||||
Status: store.StatusSent,
|
||||
Title: "Delivered",
|
||||
Detail: "The receiving server accepted the message. That is as far as this server can see — what the recipient's mailbox then did with it is not reported back.",
|
||||
})
|
||||
return append(events, step(row.UpdatedAt, "ok", "Delivered",
|
||||
"The receiving server accepted the message. That is as far as this server can see — what the recipient's mailbox then did with it is not reported back."))
|
||||
case store.StatusDeferred:
|
||||
return append(events, deliveryEvent{
|
||||
At: row.UpdatedAt,
|
||||
Level: "warn",
|
||||
Status: store.StatusDeferred,
|
||||
Title: "Deferred, will be retried",
|
||||
Detail: fmt.Sprintf("The receiving server could not take the message yet. Postfix retries: first after %s, then with increasing gaps up to %s, for up to %s. There is no fixed attempt count — a deferred message stays in the queue until it is delivered or that lifetime runs out.",
|
||||
policy.FirstRetry(), policy.BackoffCap(), policy.QueueLifetime()),
|
||||
})
|
||||
return append(events, step(row.UpdatedAt, "warn", "Deferred, will be retried",
|
||||
fmt.Sprintf("The receiving server could not take the message yet. Postfix retries: first after %s, then with increasing gaps up to %s, for up to %s. There is no fixed attempt count — a deferred message stays in the queue until it is delivered or that lifetime runs out.",
|
||||
policy.FirstRetry(), policy.BackoffCap(), policy.QueueLifetime())))
|
||||
case store.StatusBounced:
|
||||
return append(events, deliveryEvent{
|
||||
At: row.UpdatedAt,
|
||||
Level: "error",
|
||||
Status: store.StatusBounced,
|
||||
Title: "Bounced",
|
||||
Detail: fmt.Sprintf("Delivery failed for good: the receiving server refused the message permanently, or Postfix gave up after %s in the queue. The reason is in the delivery log below.",
|
||||
policy.QueueLifetime()),
|
||||
})
|
||||
return append(events, step(row.UpdatedAt, "error", "Bounced",
|
||||
fmt.Sprintf("Delivery failed for good: the receiving server refused the message permanently, or Postfix gave up after %s in the queue. The reason is in the delivery log below.",
|
||||
policy.QueueLifetime())))
|
||||
default:
|
||||
// A status the log-tailer learns to write before this switch does.
|
||||
return append(events, deliveryEvent{
|
||||
At: row.UpdatedAt,
|
||||
Level: deliveryLevel(row.Status),
|
||||
Status: row.Status,
|
||||
Title: "Status reported",
|
||||
Detail: "The last state Postfix reported for this recipient.",
|
||||
})
|
||||
return append(events, step(row.UpdatedAt, "", "Status reported",
|
||||
"The last state Postfix reported for this recipient."))
|
||||
}
|
||||
}
|
||||
|
||||
// deliveryLogRow is one mail.log line split for the table on the delivery
|
||||
// page: when it was written, and what it says. Time is empty for a line whose
|
||||
// head is not a timestamp the log format recognises — the line still shows, in
|
||||
// full, under Message.
|
||||
type deliveryLogRow struct {
|
||||
Time string
|
||||
Text string
|
||||
}
|
||||
|
||||
// deliveryLog reads the mail.log lines Postfix wrote about one message and
|
||||
// splits each into the two columns the page shows it in. The second return
|
||||
// value is what to say when there are none: every reason for an empty result
|
||||
// here is an ordinary one — the message never reached the queue, or its lines
|
||||
// have aged out of the log — so none of them is an error on the page. Only a
|
||||
// log that cannot be read at all is reported as a fault, and that one is
|
||||
// splits each into the time and the text the page shows it in. The second
|
||||
// return value is what to say when there are none: every reason for an empty
|
||||
// result here is an ordinary one — the message never reached the queue, or its
|
||||
// lines have aged out of the log — so none of them is an error on the page. Only
|
||||
// a log that cannot be read at all is reported as a fault, and that one is
|
||||
// logged for the operator as well.
|
||||
func (h *Handlers) deliveryLog(row store.SendLogRow) ([]deliveryLogRow, string) {
|
||||
func (h *Handlers) deliveryLog(row store.SendLogRow) ([]view.LogLine, string) {
|
||||
if row.QueueID == "" {
|
||||
return nil, "This message never reached the queue, so Postfix wrote no delivery lines for it."
|
||||
}
|
||||
@@ -274,10 +204,12 @@ func (h *Handlers) deliveryLog(row store.SendLogRow) ([]deliveryLogRow, string)
|
||||
days := h.sendLogRetentionDays()
|
||||
return nil, fmt.Sprintf("Nothing for this queue id in the current mail log. Its lines have most likely been rotated away (send-log rows are kept for %d days).", days)
|
||||
}
|
||||
out := make([]deliveryLogRow, len(lines))
|
||||
out := make([]view.LogLine, len(lines))
|
||||
for i, line := range lines {
|
||||
// A line whose head is not a timestamp the log format recognises keeps its
|
||||
// whole text and no time: nothing is dropped from what the log says.
|
||||
stamp, rest := logtail.SplitTimestamp(line)
|
||||
out[i] = deliveryLogRow{Time: stamp, Text: rest}
|
||||
out[i] = view.LogLine{Time: stamp, Text: rest, Level: view.LogLineLevel(rest)}
|
||||
}
|
||||
return out, ""
|
||||
}
|
||||
@@ -300,9 +232,9 @@ func deliveriesBackURL(r *http.Request) string {
|
||||
return "/outbound/log?" + back.Encode()
|
||||
}
|
||||
|
||||
// sendLogData reads the domain/app filters and page number off the query
|
||||
// string, queries the store, and assembles everything the template needs
|
||||
// (filter dropdown options plus the current selection, rows, and pagination).
|
||||
// outLogPage reads the domain/app filters and page number off the query
|
||||
// string, queries the store, and fills the log page (filter choices plus the
|
||||
// current selection, rows, and pagination).
|
||||
//
|
||||
// The invariant this function owes the journal: a principal who is not global
|
||||
// only ever reads rows for the domains assigned to them. That scope is stated
|
||||
@@ -313,7 +245,7 @@ func deliveriesBackURL(r *http.Request) string {
|
||||
// the assigned domains and their applications before the query runs, because a
|
||||
// dropdown that offers only permitted values is a courtesy to the browser, not
|
||||
// a check on the request.
|
||||
func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
|
||||
func (h *Handlers) outLogPage(r *http.Request) (*view.OutLog, error) {
|
||||
p, ok := h.principal(r)
|
||||
if !ok {
|
||||
return nil, errors.New("no principal")
|
||||
@@ -374,30 +306,21 @@ func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
view := make([]sendLogRow, len(rows))
|
||||
for i := range rows {
|
||||
rows[i].Subject = mailhdr.DecodeSubject(rows[i].Subject)
|
||||
view[i] = sendLogRow{SendLogRow: rows[i], Level: deliveryLevel(rows[i].Status)}
|
||||
}
|
||||
|
||||
lastPage := 1
|
||||
if total > 0 {
|
||||
lastPage = int((total + sendLogPageSize - 1) / sendLogPageSize)
|
||||
}
|
||||
return map[string]any{
|
||||
"Rows": view,
|
||||
"FilterDomains": domainNames,
|
||||
"FilterApps": logins,
|
||||
"FilterDomain": filter.Domain,
|
||||
"FilterApp": filter.AppLogin,
|
||||
"Page": page,
|
||||
"PrevPage": page - 1,
|
||||
"NextPage": page + 1,
|
||||
"LastPage": lastPage,
|
||||
"HasPrev": page > 1,
|
||||
"HasNext": page < lastPage,
|
||||
"SendLogRetentionDays": h.sendLogRetentionDays(),
|
||||
}, nil
|
||||
out := view.NewOutLog(h.shellMeta(r), h.sendLogRetentionDays(), p.IsGlobal(), domainNames, logins, filter.Domain, filter.AppLogin)
|
||||
table := make([]view.OutLogRow, len(rows))
|
||||
for i, row := range rows {
|
||||
table[i] = view.OutLogRow{
|
||||
Time: view.FormatLogTime(row.CreatedAt), From: row.From, To: row.To,
|
||||
Subject: mailhdr.DecodeSubject(row.Subject), Status: view.Tag{Status: row.Status},
|
||||
Href: out.DetailHref(row.ID, page),
|
||||
}
|
||||
}
|
||||
return out.WithRows(table, page, lastPage), nil
|
||||
}
|
||||
|
||||
// parsePage clamps the "p" query parameter to a valid page number, defaulting
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
"github.com/mixeme/selfpost/internal/web/view"
|
||||
)
|
||||
|
||||
// After log rotation renames mail.log away, Postfix takes about a second to
|
||||
@@ -39,8 +40,8 @@ func TestDeliveryLogShowsOnlyTheIdentifyingColumns(t *testing.T) {
|
||||
|
||||
out := getBody(t, h.HandleDeliveries, "/outbound/log")
|
||||
for _, want := range []string{
|
||||
row.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
"noreply@bs.example.ru", "public@example.ru",
|
||||
view.FormatLogTime(row.CreatedAt),
|
||||
"noreply@<wbr>bs.example.ru", "public@<wbr>example.ru",
|
||||
"Проверка", ">sent<", `href="/outbound/log/` + itoa(row.ID),
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
@@ -106,9 +107,9 @@ func TestDeliveryPageTellsTheMessagesHistory(t *testing.T) {
|
||||
"Accepted and queued", "Delivered",
|
||||
row.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
row.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
// A delivered message is "ok" in the panel's own badge vocabulary, the
|
||||
// A delivered message is green in the panel's own tag vocabulary, the
|
||||
// same one the status page and the DNS checks use.
|
||||
`class="st st-ok"`,
|
||||
`<span class="tag is-success is-light">sent</span>`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("delivery page is missing %q:\n%s", want, out)
|
||||
@@ -147,8 +148,8 @@ func TestDeliveryPageMarksAQueuedMessageAsStillWaiting(t *testing.T) {
|
||||
|
||||
// The queue id used to be printed as something to go and search the system log
|
||||
// for by hand; the page does that search now, and shows only this message's
|
||||
// lines — as a table of when and what, so the seconds between the connection
|
||||
// and the reply line up down one edge.
|
||||
// lines — in a log pane, the time of each dimmed in front of it, so the seconds
|
||||
// between the connection and the reply read down one edge.
|
||||
func TestDeliveryPageShowsThisMessagesLogLines(t *testing.T) {
|
||||
h, row := serverWithDelivery(t)
|
||||
h.cfg.MailLogPath = writeMailLog(t,
|
||||
@@ -159,15 +160,15 @@ func TestDeliveryPageShowsThisMessagesLogLines(t *testing.T) {
|
||||
|
||||
out := getBody(t, h.HandleDelivery, "/outbound/log/"+itoa(row.ID))
|
||||
for _, want := range []string{
|
||||
"<th>Time</th>", "<th>Message</th>",
|
||||
// The stamp is split off into its own cell, without the microseconds
|
||||
// and the offset that make it the widest thing on the line.
|
||||
`<td class="time muted">2026-08-03 05:15:52</td>`,
|
||||
`<td class="time muted">2026-08-03 05:16:03</td>`,
|
||||
"client=mail.example.com", "status=sent (250 OK)",
|
||||
`<pre class="sp-log">`,
|
||||
// The stamp is split off from the line, without the microseconds and the
|
||||
// offset that make it the widest thing on it.
|
||||
`<span class="sp-d">2026-08-03 05:15:52</span> host postfix/smtpd[20]: 4A1B2C3D: client=mail.example.com`,
|
||||
`<span class="sp-d">2026-08-03 05:16:03</span> host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>`,
|
||||
"status=sent (250 OK)",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("delivery log table is missing %q:\n%s", want, out)
|
||||
t.Errorf("delivery log is missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "99999999") {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// The Outbound log and the page of one message, rendered from the real journal
|
||||
// by the handlers. Their markup is held to the mockups by the guards in
|
||||
// internal/web/view; what is checked here is the behaviour the pages keep from
|
||||
// the legacy ones — the polled region, the paging, the scoping — on the new
|
||||
// markup.
|
||||
|
||||
// regionOf cuts the polled region (the table and the foot under it) out of a
|
||||
// body: from the table's container to the end of the foot.
|
||||
func regionOf(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
start := strings.Index(body, `<div class="table-container" id="out-log-rows"`)
|
||||
foot := strings.Index(body, `<div class="sp-box-foot"`)
|
||||
if start < 0 || foot < start {
|
||||
t.Fatalf("no polled region in:\n%s", body)
|
||||
}
|
||||
end := strings.Index(body[foot:], "</div>")
|
||||
return body[start : foot+end+len("</div>")]
|
||||
}
|
||||
|
||||
// The fragment answers with the markup the page holds for the same region, so
|
||||
// the first poll changes nothing a person sees: the table, byte for byte, and
|
||||
// the foot, which only gains the attribute that swaps it into place by its id.
|
||||
func TestOutLogFragmentIsTheRegionOfThePage(t *testing.T) {
|
||||
h, _ := serverWithDelivery(t)
|
||||
page := regionOf(t, getBody(t, h.HandleDeliveries, "/outbound/log?domain=bs.example.ru"))
|
||||
fragment := getBody(t, h.HandleDeliveriesRows, "/outbound/log/fragment?domain=bs.example.ru")
|
||||
|
||||
if !strings.Contains(fragment, `id="out-log-foot" hx-swap-oob="true"`) {
|
||||
t.Errorf("the fragment's foot is not marked to be swapped in by its id:\n%s", fragment)
|
||||
}
|
||||
swapped := strings.Replace(fragment, ` hx-swap-oob="true"`, "", 1)
|
||||
if swapped != page {
|
||||
t.Errorf("the fragment is not the page's polled region\npage:\n%s\nfragment:\n%s", page, fragment)
|
||||
}
|
||||
// The poll asks for the same filters again, and never replaces the filter form.
|
||||
has(t, "region", page, `data-poll`, `aria-live="polite"`, `hx-trigger="load"`, `hx-swap="outerHTML"`,
|
||||
`hx-get="/outbound/log/fragment?domain=bs.example.ru"`)
|
||||
lacks(t, "fragment", fragment, `<select`, `<form`)
|
||||
if strings.Contains(page, "hx-swap-oob") {
|
||||
t.Errorf("the page itself marks its foot out of band:\n%s", page)
|
||||
}
|
||||
}
|
||||
|
||||
// With more than a page of rows the foot says where the viewer is and leads to
|
||||
// the newer and older rows with the filters kept, and the poll refreshes the page
|
||||
// being looked at.
|
||||
func TestOutLogPagesKeepTheFiltersAndThePage(t *testing.T) {
|
||||
h, _ := serverWithDelivery(t)
|
||||
for i := 0; i < sendLogPageSize; i++ {
|
||||
if err := h.store.InsertQueued(store.SendLogEntry{
|
||||
QueueID: fmt.Sprintf("Q%04d", i), Domain: "bs.example.ru", AppLogin: "Queuer3C",
|
||||
From: "noreply@bs.example.ru", To: "public@example.ru", Subject: fmt.Sprintf("Message %d", i),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
first := getBody(t, h.HandleDeliveries, "/outbound/log?domain=bs.example.ru")
|
||||
has(t, "page 1", first, `Page 1 of 2`, `<a href="/outbound/log?domain=bs.example.ru&p=2">Older →</a>`, `New rows appear on their own`)
|
||||
lacks(t, "page 1", first, `← Newer`)
|
||||
|
||||
second := getBody(t, h.HandleDeliveries, "/outbound/log?domain=bs.example.ru&p=2")
|
||||
has(t, "page 2", second, `Page 2 of 2`, `<a href="/outbound/log?domain=bs.example.ru">← Newer</a>`,
|
||||
`hx-get="/outbound/log/fragment?domain=bs.example.ru&p=2"`)
|
||||
lacks(t, "page 2", second, `Older →`)
|
||||
// A row's page is opened from the page it is on and leads back to it.
|
||||
has(t, "page 2", second, `?domain=bs.example.ru&p=2">Details</a>`)
|
||||
}
|
||||
|
||||
// Nothing logged yet is a sentence in the polled region, which goes on polling,
|
||||
// not a table with no rows; the foot still says that rows will appear.
|
||||
func TestOutLogEmpty(t *testing.T) {
|
||||
h, _ := serverWithTwoDomains(t)
|
||||
p := domainAdmin(t, h.store, "no-domains")
|
||||
body := getBodyAs(t, h.HandleDeliveries, "/outbound/log", p)
|
||||
has(t, "empty log", body, `No messages logged yet.`, `id="out-log-rows"`, `data-poll`, `New rows appear on their own`)
|
||||
lacks(t, "empty log", body, `<table`, `Page 1`)
|
||||
// Only the global role is pointed at the setting that decides how long rows live.
|
||||
lacks(t, "empty log", body, `href="/server/settings"`)
|
||||
has(t, "log", getBody(t, h.HandleDeliveries, "/outbound/log"), `href="/server/settings">Settings</a>`)
|
||||
}
|
||||
|
||||
// What a sender wrote is text on the page: a subject or an address is never
|
||||
// markup, in the list, in the fragment or on the message's own page.
|
||||
func TestOutLogPagesEscapeWhatSendersWrote(t *testing.T) {
|
||||
h, _ := serverWithDelivery(t)
|
||||
if err := h.store.InsertQueued(store.SendLogEntry{
|
||||
QueueID: "EVIL0001", Domain: "bs.example.ru", AppLogin: "Queuer3C",
|
||||
From: `"><img src=x onerror=alert(1)>@bs.example.ru`, To: `<script>alert(2)</script>@example.ru`,
|
||||
Subject: `<script>alert(3)</script> "quoted"`,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := h.store.QuerySendLog(store.SendLogFilter{AllDomains: true}, 1, 0)
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("query: %v (%d rows)", err, len(rows))
|
||||
}
|
||||
for name, body := range map[string]string{
|
||||
"list": getBody(t, h.HandleDeliveries, "/outbound/log"),
|
||||
"fragment": getBody(t, h.HandleDeliveriesRows, "/outbound/log/fragment"),
|
||||
"message": getBody(t, h.HandleDelivery, "/outbound/log/"+itoa(rows[0].ID)),
|
||||
} {
|
||||
lacks(t, name, body, `<script>alert`, `<img src=x`)
|
||||
has(t, name, body, `<script>alert(3)</script>`)
|
||||
}
|
||||
}
|
||||
|
||||
// The names of the domain and the application on a message's page lead to
|
||||
// theirs when there is a page to go to, and are plain text when there is not.
|
||||
func TestMessagePageLinksTheDomainAndTheApplication(t *testing.T) {
|
||||
h, domains := serverWithTwoDomains(t)
|
||||
first := domains["first.example.ru"]
|
||||
app, err := h.store.GetApplicationByLogin("first-app")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := h.store.QuerySendLog(store.SendLogFilter{Domain: first.Name, AllDomains: true}, 1, 0)
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("query: %v (%d rows)", err, len(rows))
|
||||
}
|
||||
target := "/outbound/log/" + itoa(rows[0].ID)
|
||||
|
||||
body := getBody(t, h.HandleDelivery, target)
|
||||
has(t, "message", body, `<a href="/outbound/domains/`+itoa(first.ID)+`">first.example.ru</a>`,
|
||||
`<a href="/outbound/domains/`+itoa(first.ID)+`/applications/`+itoa(app.ID)+`">first-app</a>`)
|
||||
|
||||
// A domain administrator of that domain gets the same links.
|
||||
ops := domainAdmin(t, h.store, "ops", first.ID)
|
||||
has(t, "message for its administrator", getBodyAs(t, h.HandleDelivery, target, ops),
|
||||
`<a href="/outbound/domains/`+itoa(first.ID)+`">first.example.ru</a>`)
|
||||
|
||||
// The domain was deleted since: its name stays, as text, for the global role.
|
||||
if err := h.store.DeleteDomain(first.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body = getBody(t, h.HandleDelivery, target)
|
||||
has(t, "message of a deleted domain", body, `first.example.ru`)
|
||||
lacks(t, "message of a deleted domain", body, `<a href="/outbound/domains/`)
|
||||
}
|
||||
|
||||
// Each outcome of a delivery is drawn at its own level in the history and in the
|
||||
// log, so a message that did not go through reads so before a word is read.
|
||||
func TestMessagePageDrawsTheOutcomeAtItsLevel(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
status, step, tag string
|
||||
}{
|
||||
{store.StatusSent, `<li><time>`, `<span class="tag is-success is-light">sent</span>`},
|
||||
{store.StatusDeferred, `<li class="sp-warn"><time>`, `<span class="tag is-warning is-light">deferred</span>`},
|
||||
{store.StatusBounced, `<li class="sp-fail"><time>`, `<span class="tag is-danger is-light">bounced</span>`},
|
||||
} {
|
||||
h, row := serverWithDelivery(t)
|
||||
h.cfg.MailLogPath = writeMailLog(t,
|
||||
"2026-08-03T05:16:03.884210+00:00 host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, status="+tc.status+" (reply)")
|
||||
if _, err := h.store.UpdateStatus(row.QueueID, row.To, tc.status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := getBody(t, h.HandleDelivery, "/outbound/log/"+itoa(row.ID))
|
||||
has(t, tc.status, body, tc.step, tc.tag)
|
||||
switch tc.status {
|
||||
case store.StatusDeferred:
|
||||
has(t, tc.status, body, `<span class="sp-w">host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, status=deferred (reply)</span>`)
|
||||
case store.StatusBounced:
|
||||
has(t, tc.status, body, `<span class="sp-e">host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, status=bounced (reply)</span>`)
|
||||
default:
|
||||
lacks(t, tc.status, body, `class="sp-w"`, `class="sp-e"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A message the milter refused never reached the queue: it has no queue id and
|
||||
// no lines to show, and says why instead of showing an empty log.
|
||||
func TestMessagePageOfARefusedMessage(t *testing.T) {
|
||||
h, _ := serverWithDelivery(t)
|
||||
if err := h.store.InsertRejected(store.SendLogEntry{
|
||||
Domain: "bs.example.ru", AppLogin: "Queuer3C", From: "noreply@bs.example.ru", To: "late@example.ru", Subject: "Too many",
|
||||
}); err != nil {
|
||||
t.Skipf("the store has no InsertRejected to refuse a message with: %v", err)
|
||||
}
|
||||
rows, err := h.store.QuerySendLog(store.SendLogFilter{AllDomains: true}, 1, 0)
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("query: %v (%d rows)", err, len(rows))
|
||||
}
|
||||
body := getBody(t, h.HandleDelivery, "/outbound/log/"+itoa(rows[0].ID))
|
||||
has(t, "refused message", body, `Refused before queueing`, `class="sp-fail"`, `never reached the queue`, `<span class="tag is-light">rejected</span>`)
|
||||
lacks(t, "refused message", body, `<pre`)
|
||||
}
|
||||
|
||||
// Whatever the viewer reaches, another tenant's message is a 404 on its own
|
||||
// page whether the row exists or not.
|
||||
func TestMessagePageIsNotFoundForOtherTenants(t *testing.T) {
|
||||
h, domains := serverWithTwoDomains(t)
|
||||
rows, err := h.store.QuerySendLog(store.SendLogFilter{Domain: "second.example.ru", AllDomains: true}, 1, 0)
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("query: %v (%d rows)", err, len(rows))
|
||||
}
|
||||
ops := domainAdmin(t, h.store, "ops", domains["first.example.ru"].ID)
|
||||
for _, id := range []string{itoa(rows[0].ID), "999999"} {
|
||||
rec := send(h.HandleDelivery, &ops, "GET", "/outbound/log/"+id, map[string]string{"id": id}, nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("message %s for another tenant = %d, want 404", id, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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