package view import ( "strings" "testing" "time" ) // ---- The log // The log is the whole of what the journal holds about the viewer's inbound mail: // the filters offer what the viewer reaches, the head says how long rows live and // where to change it, and the global role is given the way to correct a decision. func TestInLogShowsTheRowsAndTheirFilters(t *testing.T) { out := renderSignedIn(t, "in-log", inLogFixture()) pageHas(t, "Inbound log", out, `

Inbound log

`, `refused mail is read from mail.log; the filter's verdict appears when the spam filter is on. Rows older than 90 days are deleted — change that in Settings.`, `
`, ``, ``, ``, ``, `href="/help#inbound-log" title="What the decisions mean"`, `Time, UTCFromToSubjectDecisionUpstream`, // what the upstream answered: a tag that opens its last reply, a // plain tag while not tried, "—" for refused mail `
`, `sent

Upstream

`, `Hostmx.upstream.example[198.51.100.5]:25Reply250 2.0.0 Ok: queued as CE05D10261DAt09-21 14:00:19`, ``, `ThenThe sender was sent a bounce`, ``, `Replyconnect to mx.upstream.example[198.51.100.5]:25: Connection refusedAt09-21 13:45:12ThenPostfix tries again; the message is in the queue`, `queued`, `
—`, `09-21 14:00:18`, `t.berg@uni.example
192.0.2.77`, `dev@lists.example.org`, `title="Re: [dev] patch series v3"`, `—`, // the decision is a tag that opens the sheet with who decided and why `
`, `
accept

Why

`, ``, `Decided byrspamdVerdictno action, score 0.40 / 15.00SubjectRe: [dev] patch series v3`, `Verdictadd header, score 9.80 / 15.00SymbolsPHISHING(5.00)
FORGED_SENDER(3.00)
R_SPF_FAIL(1.00)`, `Decided bypostfixReply550 5.1.1 <sales@lists.example.org>: Recipient address rejected: User unknown in relay recipient table`, `Page 1 of 38`, `Older →`, `A decision opens its reason, an upstream result its last reply · Allow / Deny sender opens Filter lists with the entry filled in`, ) pageLacks(t, "Inbound log", out, `← Newer`, `data-poll`, `hx-get`, `Why`) // A message Postfix itself refused offers no way into the lists: one Deny // for each of the four accepted messages, one Allow for each the filter // refused or put off. if deny, allow := strings.Count(out, `>Deny sender`), strings.Count(out, `>Allow sender`); deny != 4 || allow != 2 { t.Errorf("the fixture's seven rows offer Deny %d times and Allow %d times, want 4 and 2", deny, allow) } if n := strings.Count(out, `—`); n != 3 { t.Errorf("%d rows have no upstream result, want the three refused", n) } // Without the global role there is nowhere to change the retention, and // nowhere to send a sender. domain := NewInLog(Meta{User: "ops", HasInbound: true}, 7, []string{"acme.io"}, InLogFilter{}) out = renderSignedIn(t, "in-log", domain.WithRows([]InLogRow{domain.NewRow(InLogRowInput{ At: time.Date(2026, 9, 21, 14, 0, 0, 0, time.UTC), From: "a@example.net", To: "b@acme.io", Decision: "accept", Engine: "rspamd"})}, 1, 1)) pageHas(t, "Inbound log", out, `Rows older than 7 days are deleted.`, `rspamd`) pageLacks(t, "Inbound log", out, `/server/settings`, `/inbound/filter-lists`, `Deny sender`, `Allow sender`, `Allow / Deny`) } // The lead's day count reads right for one day too, though the setting's range // starts at seven: the word follows the number, not the other way round. func TestInLogLeadCountsDays(t *testing.T) { out := renderSignedIn(t, "in-log", NewInLog(Meta{User: "ops", HasInbound: true}, 1, nil, InLogFilter{})) pageHas(t, "Inbound log", out, `Rows older than 1 day are deleted.`) } // The filter in force is the one selected, and the paging and the empty state // carry and name it. func TestInLogCarriesItsFilters(t *testing.T) { f := InLogFilter{Domain: "acme.io", Decision: "reject", From: "2026-09-01", To: "2026-09-30"} p := NewInLog(admin(), 90, []string{"lists.example.org", "acme.io"}, f) p.WithRows([]InLogRow{p.NewRow(InLogRowInput{At: time.Date(2026, 9, 2, 1, 2, 3, 0, time.UTC), From: "a@example.net", To: "b@acme.io", Decision: "reject", Engine: "postfix"})}, 3, 5) out := renderSignedIn(t, "in-log", p) pageHas(t, "Inbound log", out, ``, ``, `name="from" value="2026-09-01"`, `name="to" value="2026-09-30"`, `Page 3 of 5 · ← Newer`, `Older →`, ) if got := InLogHref(InLogFilter{}, 1); got != "/inbound/log" { t.Errorf("the first unfiltered page is %q", got) } if got := InLogHref(InLogFilter{Domain: "a b&c"}, 2); got != "/inbound/log?domain=a+b%26c&p=2" { t.Errorf("a filter is not encoded: %q", got) } out = renderSignedIn(t, "in-log", NewInLog(admin(), 90, nil, f).WithRows(nil, 1, 1)) pageHas(t, "Inbound log", out, `No messages match these filters.`) out = renderSignedIn(t, "in-log", NewInLog(admin(), 90, nil, InLogFilter{}).WithRows(nil, 1, 1)) pageHas(t, "Inbound log", out, `No messages logged yet.`) pageLacks(t, "Inbound log", out, ``) pageLacks(t, "Inbound log", out, `Older →`) } // A row says who decided, and what they said when they said something; a message // refused before its sender was known has no sender to show or to list. func TestInLogRowWording(t *testing.T) { p := NewInLog(admin(), 90, nil, InLogFilter{}) at := time.Date(2026, 9, 21, 14, 0, 18, 0, time.UTC) r := p.NewRow(InLogRowInput{At: at, From: "a@example.net", To: "b@acme.io", Decision: "accept", Engine: "selfpost"}) if len(r.Why.Rows) != 1 || r.Why.Rows[0].Lines[0] != "selfpost" || r.Time != "09-21 14:00:18" || r.Client != "" { t.Errorf("row = %+v", r) } r = p.NewRow(InLogRowInput{ID: 7, At: at, To: "b@acme.io", Decision: "reject", Engine: "postfix", Detail: "550 5.1.1 no"}) if r.Why.ID != "why-7" || r.Why.Tag.Status != "reject" || len(r.Why.Rows) != 2 || r.Why.Rows[1].Label != "Reply" || r.From != "—" || r.Action != nil { t.Errorf("row = %+v", r) } out := renderSignedIn(t, "in-log", p.WithRows([]InLogRow{r}, 1, 1)) pageHas(t, "Inbound log", out, `—`) pageLacks(t, "Inbound log", out, `
`) } // Which decisions lead to which list, for the global role: the sender of mail // that got through can be denied, the sender of mail the filter turned away or // put off can be allowed, and a refusal that is Postfix's own offers neither. func TestInLogSenderAction(t *testing.T) { for _, c := range []struct { decision, engine, from, label, href string }{ {"accept", "rspamd", "a@example.net", "Deny sender", "/inbound/filter-lists?list=deny&kind=address&value=a%40example.net"}, {"accept", "selfpost", "a@example.net", "Deny sender", "/inbound/filter-lists?list=deny&kind=address&value=a%40example.net"}, {"reject", "rspamd", "a+b@example.net", "Allow sender", "/inbound/filter-lists?list=allow&kind=address&value=a%2Bb%40example.net"}, {"tempfail", "rspamd", "a@example.net", "Allow sender", "/inbound/filter-lists?list=allow&kind=address&value=a%40example.net"}, {"quarantine", "rspamd", "a@example.net", "Allow sender", "/inbound/filter-lists?list=allow&kind=address&value=a%40example.net"}, {"reject", "postfix", "a@example.net", "", ""}, {"tempfail", "postfix", "a@example.net", "", ""}, {"reject", "selfpost", "a@example.net", "", ""}, {"accept", "rspamd", "", "", ""}, {"reject", "rspamd", "", "", ""}, } { got := senderAction(c.decision, c.engine, c.from) switch { case c.label == "" && got != nil: t.Errorf("%s by %s from %q is offered %+v", c.decision, c.engine, c.from, *got) case c.label != "" && (got == nil || got.Label != c.label || got.Href != c.href): t.Errorf("%s by %s from %q is offered %+v, want %s -> %s", c.decision, c.engine, c.from, got, c.label, c.href) } } // The address in the link is data: it cannot leave the attribute. p := NewInLog(admin(), 90, nil, InLogFilter{}) // (The handler would not call such a sender listable; the view must not // depend on that.) row := p.NewRow(InLogRowInput{Listable: true, At: time.Now(), From: `">@x`, To: "b@acme.io", Decision: "accept", Engine: "rspamd"}) out := renderSignedIn(t, "in-log", p.WithRows([]InLogRow{row}, 1, 1)) pageLacks(t, "Inbound log", out, `@acme.io`, Subject: ` "quoted"`, Decision: "reject", Engine: "rspamd", Detail: `5.7.1`}) out := renderSignedIn(t, "in-log", p.WithRows([]InLogRow{row}, 1, 1)) pageLacks(t, "Inbound log", out, ``, `a "note"`). WithResult("", "That entry is already on the list.") out := renderSignedIn(t, "in-filter-lists", p) pageHas(t, "Filter lists", out, ``, ``, `value="x"><script>1</script>"`, `value="a "note""`, `
`, `That entry is already on the list.`) pageLacks(t, "Filter lists", out, ``, Added: time.Now()}, }) out := renderSignedIn(t, "in-filter-lists", p) pageLacks(t, "Filter lists", out, ``, `