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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user