inbound-upstream-result: the upstream's answer in the Inbound log
test / test (push) Waiting to run

The owner, 2026-10-16: the Inbound log lacks the upstream's answer - "Не
понятна судьба письма: принято, отклонено, хост не доступен"; the result
in the row only, a page per message later if needed. Mockups 5348330 and
46b90d2, plan 295d72e.

- Migration 0012: inbound_spam_log gains queue_id, upstream_status ('',
  queued, held, sent, deferred, bounced; CHECK), upstream_relay,
  upstream_reply, upstream_at, and a partial index on queue_id. Schema 12.
- The inbound journal milter writes an accepted row with the queue id of
  end-of-message and "queued" ("held" when the filter had it held), the
  recipient as Postfix logs it (recipientAsLogged), after detaching older
  rows of a queue id Postfix uses again; no queue id, no result.
- The tailer: a delivery line no send-log row took goes to the journal -
  status, relay= ("" for none), the reply after the first status=, cut to
  255 printable bytes, the line's time; queued/held/deferred rows only,
  sent and bounced final. qmgr's expiry makes them bounced, keeping the
  last reply. cleanup's "removed (" detaches rows of a message never
  queued. Addresses in delivery lines are read by Postfix's quoting
  (loggedAddress): a quoted RCPT TO cannot end one early.
- The page: an Upstream column - a plain tag for queued and held, else a
  tag opening the host, the reply, when and what follows; "—" for refused
  mail and older rows. Help (word for word with the mockup), guide,
  architecture, security, schema-migrations, CHANGELOG.

Reviewed before the commit (technical and security, 2026-10-16); the plan
lists each finding and what it became.

Tests: store (results, finality, expiry, held, a reused id, the queue-id
index in every plan), milter (queue id, held, quoted recipient, no {i}),
logtail (relay and reply, a forged quoted recipient, Run hands the journal
what the send log did not take, cleanup's removal), view (the column, held,
null sender), e2e (both relayed messages sent with the sink's 250; an
upstream refusing connections deferred with the reason and no host, then
expired to bounced; the refused row has none). go vet + go test (26
packages) and make e2e on the test server for this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-16 16:35:09 +03:00
co-authored by Claude Opus 5.5
parent 46b90d2a8d
commit d51549385b
26 changed files with 966 additions and 72 deletions
+34 -13
View File
@@ -448,29 +448,50 @@ func inLogFixture() *InLog {
p := NewInLog(admin(), 90, []string{"lists.example.org", "acme.io"},
InLogFilter{From: "2026-09-14", To: "2026-09-21"})
var id int64
row := func(clock, from, client, to, subject, decision, engine, detail string) InLogRow {
at, err := time.Parse("2006-01-02 15:04:05", "2026-09-21 "+clock)
clock := func(c string) time.Time {
at, err := time.Parse("2006-01-02 15:04:05", "2026-09-21 "+c)
if err != nil {
panic(err)
}
id++
return p.NewRow(InLogRowInput{ID: id, Listable: true, At: at, From: from, Client: client, To: to, Subject: subject,
Decision: decision, Engine: engine, Detail: detail})
return at
}
return p.WithRows([]InLogRow{
row("14:00:18", "t.berg@uni.example", "192.0.2.77", "dev@lists.example.org", "Re: [dev] patch series v3",
"accept", "rspamd", "no action, score 0.40 / 15.00"),
row("13:59:02", "invoice@partner-billing.example", "203.0.113.140", "owner@lists.example.org", "Outstanding invoice — action required",
"accept", "rspamd", "add header, score 9.80 / 15.00; PHISHING(5.00) FORGED_SENDER(3.00) R_SPF_FAIL(1.00)"),
// up is what the upstream answered: status, host, reply, when.
type up struct{ status, relay, reply, at string }
upRow := func(c, from, client, to, subject, decision, engine, detail string, u up) InLogRow {
id++
in := InLogRowInput{ID: id, Listable: true, At: clock(c), From: from, Client: client, To: to, Subject: subject,
Decision: decision, Engine: engine, Detail: detail,
Upstream: u.status, UpstreamRelay: u.relay, UpstreamReply: u.reply}
if u.at != "" {
in.UpstreamAt = clock(u.at)
}
return p.NewRow(in)
}
row := func(c, from, client, to, subject, decision, engine, detail string) InLogRow {
return upRow(c, from, client, to, subject, decision, engine, detail, up{})
}
const host = "mx.upstream.example[198.51.100.5]:25"
sent := upRow("14:00:18", "t.berg@uni.example", "192.0.2.77", "dev@lists.example.org", "Re: [dev] patch series v3",
"accept", "rspamd", "no action, score 0.40 / 15.00",
up{"sent", host, "250 2.0.0 Ok: queued as CE05D10261D", "14:00:19"})
bounced := upRow("13:59:02", "invoice@partner-billing.example", "203.0.113.140", "owner@lists.example.org", "Outstanding invoice — action required",
"accept", "rspamd", "add header, score 9.80 / 15.00; PHISHING(5.00) FORGED_SENDER(3.00) R_SPF_FAIL(1.00)",
up{"bounced", host, "550 5.7.1 Message rejected as spam by the upstream filter", "13:59:04"})
rest := []InLogRow{
row("13:57:41", "promo@bulk.example", "198.51.100.90", "sales@lists.example.org", "",
"reject", "postfix", "550 5.1.1 <sales@lists.example.org>: Recipient address rejected: User unknown in relay recipient table"),
row("13:51:30", "billing@partner.example", "198.51.100.12", "owner@lists.example.org", "",
"reject", "rspamd", "5.7.1 Spam message rejected"),
row("13:44:02", "hr@partner.example", "192.0.2.31", "jobs@acme.io", "",
"tempfail", "rspamd", "4.7.1 Try again later"),
row("13:30:55", "noreply@github.example", "192.0.2.200", "security@lists.example.org", "[advisory] new report",
"accept", "rspamd", "no action, score -98.10 / 15.00; SELFPOST_ALLOW_FROM(-100.00) R_DKIM_ALLOW(-0.20)"),
}, 1, 38)
upRow("13:30:55", "noreply@github.example", "192.0.2.200", "security@lists.example.org", "[advisory] new report",
"accept", "rspamd", "no action, score -98.10 / 15.00; SELFPOST_ALLOW_FROM(-100.00) R_DKIM_ALLOW(-0.20)",
up{"deferred", "", "connect to mx.upstream.example[198.51.100.5]:25: Connection refused", "13:45:12"}),
}
// The newest row is a message not tried yet; it is the seventh written.
queued := upRow("14:00:31", "ci@builds.example", "192.0.2.90", "dev@lists.example.org", "[ci] nightly build passed",
"accept", "rspamd", "no action, score 1.10 / 15.00", up{status: "queued"})
return p.WithRows(append([]InLogRow{queued, sent, bounced}, rest...), 1, 38)
}
// inFilterListsFixture is the mockup's Filter lists: three senders denied, two
+7 -2
View File
@@ -207,11 +207,16 @@ var inboundTopics = []HelpTopic{
}},
{ID: HelpInboundLog, Title: "Inbound log", Body: []Text{
Rich("One row per recipient of a message offered on port 25 for an inbound domain, with what was decided: ",
TagOf("accept", ""), " — handed on to the upstream; ", TagOf("reject", ""), " — refused for good; ",
TagOf("accept", ""), " — taken for the upstream; ", TagOf("reject", ""), " — refused for good; ",
TagOf("tempfail", ""), " — the sender was asked to come back later; ", TagOf("quarantine", ""), " — held by the filter. ",
Em("Why"), " names who decided. ", Code("rspamd"), " is the spam filter, with its action, the score against the reject threshold and the heaviest symbols; ",
Code("postfix"), " is the relay's own rule — an unknown recipient, a sender domain that does not exist — with its reply; ",
Code("selfpost"), " means nobody judged the message: it was relayed with no filter attached. Accept says what this server did, not what the upstream did with the message afterwards.",
Code("selfpost"), " means nobody judged the message: it was relayed with no filter attached. ",
Em("Upstream"), " says what became of an accepted message: ", TagOf("queued", ""), " — not tried yet; ",
TagOf("held", ""), " — held in the queue by the filter, not tried until it is released; ",
TagOf("sent", ""), " — the upstream took it; ", TagOf("deferred", ""),
" — the upstream put it off or could not be reached, and Postfix tries again; ", TagOf("bounced", ""),
" — the upstream refused it, or it waited in the queue too long, and the sender was sent a bounce, if it had a return address. It opens the last reply and the host that gave it. A refused message has no upstream result, and neither has a row logged before 2.10.0.",
Br(), Br(),
"Accepted mail is recorded as it passes; refused mail is read from ", Code("mail.log"),
", so its row has no subject, and a message refused after its text was sent is one row however many recipients it had. A refusal to relay for a domain that is not an inbound one is every scanner on the internet and is not recorded. Rows are kept for the number of days set in Settings, and never more than 10 000."),
+57 -7
View File
@@ -68,9 +68,9 @@ type InLog struct {
// InLogRow is one recipient of one message: when it was decided, who the mail
// was from (and the address of the server that offered it) and for, its subject,
// the decision as a tag that opens the sheet saying who decided and why, and —
// for the global role, who alone edits the filter lists — the way to correct
// the decision.
// the decision as a tag that opens the sheet saying who decided and why, what
// the upstream made of it, and — for the global role, who alone edits the
// filter lists — the way to correct the decision.
type InLogRow struct {
Time string
From string
@@ -78,7 +78,13 @@ type InLogRow struct {
To string
Subject string
Why Sheet
Action *Anchor
// Upstream is the upstream's answer, a tag that opens a sheet with the
// last reply; Pending is a message not tried yet - queued, or held by the
// filter - a plain tag. Neither for refused mail and rows from before
// 2.10.0, which show "—".
Upstream *Sheet
Pending *Tag
Action *Anchor
}
// InLogRowInput is what the journal holds about one row. Subject, Detail and
@@ -97,6 +103,12 @@ type InLogRowInput struct {
Decision string
Engine string
Detail string
// The upstream's answer (store.Upstream…): its status, "" for none, the
// host that gave it ("" when Postfix had no session), the reply and when.
Upstream string
UpstreamRelay string
UpstreamReply string
UpstreamAt time.Time
}
// NewInLog builds the log for a viewer. domains are the choices of the domain
@@ -105,7 +117,7 @@ type InLogRowInput struct {
// change it and who edits the lists, is read from m.
func NewInLog(m Meta, retentionDays int, domains []string, filter InLogFilter) *InLog {
m.Title, m.Section, m.Page = "Inbound log", "inbound", "log"
lead := Rich("What port 25 decided about each message, one row per recipient. Accepted mail is recorded as it passes, refused mail is read from ",
lead := Rich("What port 25 decided about each message and what the upstream answered, one row per recipient. Accepted mail is recorded as it passes, refused mail is read from ",
Code("mail.log"), "; the filter's verdict appears when the spam filter is on. Rows older than "+plural(int64(retentionDays), "day", "days")+" are deleted")
if m.IsGlobal {
lead = append(lead, Inline{Text: " — change that in "}, Link("/server/settings", "Settings"))
@@ -152,12 +164,50 @@ func (p *InLog) NewRow(in InLogRowInput) InLogRow {
Rows: whyRows(in.Decision, in.Engine, in.Detail, in.Subject),
},
}
switch in.Upstream {
case "queued", "held":
r.Pending = &Tag{Status: in.Upstream}
case "sent", "deferred", "bounced":
r.Upstream = &Sheet{
ID: "up-" + strconv.FormatInt(in.ID, 10), Tag: Tag{Status: in.Upstream}, Title: "Upstream",
Rows: upstreamRows(in),
}
}
if p.IsGlobal && in.Listable {
r.Action = senderAction(in.Decision, in.Engine, in.From)
}
return r
}
// upstreamRows is the content of the Upstream sheet: the host that answered
// (none when Postfix could not open a session: the reply names the host
// then), the last reply, when it came, and what follows a deferral or a
// bounce - a bounce to the sender, unless the message had none to return to
// (Postfix does not bounce mail from the null sender).
func upstreamRows(in InLogRowInput) []SheetRow {
var rows []SheetRow
if in.UpstreamRelay != "" {
rows = append(rows, SheetRow{Label: "Host", Lines: []string{in.UpstreamRelay}, Mono: true})
}
if in.UpstreamReply != "" {
rows = append(rows, SheetRow{Label: "Reply", Lines: []string{in.UpstreamReply}, Mono: true})
}
if !in.UpstreamAt.IsZero() {
rows = append(rows, SheetRow{Label: "At", Lines: []string{FormatLogTime(in.UpstreamAt)}, Mono: true})
}
switch in.Upstream {
case "deferred":
rows = append(rows, SheetRow{Label: "Then", Lines: []string{"Postfix tries again; the message is in the queue"}})
case "bounced":
then := "The sender was sent a bounce"
if in.From == "" {
then = "No bounce: the message had no return address"
}
rows = append(rows, SheetRow{Label: "Then", Lines: []string{then}})
}
return rows
}
// whyRows is the content of a row's sheet: who decided, what they said, and
// the subject whole, which the table clips. What was said has one of two
// shapes, and the decision says which. Mail that passed was written by the
@@ -226,9 +276,9 @@ func (p *InLog) WithRows(rows []InLogRow, page, last int) *InLog {
p.Foot.Link = &Anchor{Href: InLogHref(p.filter, page+1), Label: "Older →"}
}
if p.IsGlobal {
p.Foot.End = Rich("A decision opens its reason · Allow / Deny sender opens ", Link(inboundFilterLists, "Filter lists"), " with the entry filled in")
p.Foot.End = Rich("A decision opens its reason, an upstream result its last reply · Allow / Deny sender opens ", Link(inboundFilterLists, "Filter lists"), " with the entry filled in")
} else {
p.Foot.End = Plain("A decision opens its reason")
p.Foot.End = Plain("A decision opens its reason, an upstream result its last reply")
}
return p
}
+34 -5
View File
@@ -22,7 +22,18 @@ func TestInLogShowsTheRowsAndTheirFilters(t *testing.T) {
`<input class="input" type="date" name="from" value="2026-09-14" aria-label="From date">`,
`<input class="input" type="date" name="to" value="2026-09-21" aria-label="To date">`,
`href="/help#inbound-log" title="What the decisions mean"`,
`<th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th></th></tr>`,
`<th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th>Upstream</th><th></th></tr>`,
// what the upstream answered: a tag that opens its last reply, a
// plain tag while not tried, "—" for refused mail
`<button type="button" class="tag is-success is-light" popovertarget="up-1">sent</button><div class="sp-sheet" id="up-1" popover>`,
`<span class="tag is-success is-light">sent</span><h2>Upstream</h2>`,
`<tr><th>Host</th><td class="sp-mono sp-small">mx.upstream.example[198.51.100.5]:25</td></tr><tr><th>Reply</th><td class="sp-mono sp-small">250 2.0.0 Ok: queued as CE05D10261D</td></tr><tr><th>At</th><td class="sp-mono sp-small">09-21 14:00:19</td></tr></tbody>`,
`<button type="button" class="tag is-danger is-light" popovertarget="up-2">bounced</button>`,
`<tr><th>Then</th><td>The sender was sent a bounce</td></tr>`,
`<button type="button" class="tag is-warning is-light" popovertarget="up-6">deferred</button>`,
`<tbody><tr><th>Reply</th><td class="sp-mono sp-small">connect to mx.upstream.example[198.51.100.5]:25: Connection refused</td></tr><tr><th>At</th><td class="sp-mono sp-small">09-21 13:45:12</td></tr><tr><th>Then</th><td>Postfix tries again; the message is in the queue</td></tr></tbody>`,
`<td><span class="tag is-light">queued</span></td>`,
`</div></div></div></div></td><td class="sp-muted">—</td><td class="sp-actions">`,
`09-21 14:00:18`,
`t.berg@<wbr>uni.example<br><span class="sp-mono sp-muted sp-small">192.0.2.77</span>`,
`dev@<wbr>lists.example.org`, `title="Re: [dev] patch series v3"`,
@@ -36,14 +47,17 @@ func TestInLogShowsTheRowsAndTheirFilters(t *testing.T) {
`<tr><th>Verdict</th><td>add header, score 9.80 / 15.00</td></tr><tr><th>Symbols</th><td class="sp-mono sp-small">PHISHING(5.00)<br>FORGED_SENDER(3.00)<br>R_SPF_FAIL(1.00)</td></tr>`,
`<tr><th>Decided by</th><td>postfix</td></tr><tr><th>Reply</th><td class="sp-mono sp-small">550 5.1.1 &lt;sales@lists.example.org&gt;: Recipient address rejected: User unknown in relay recipient table</td></tr></tbody>`,
`Page 1 of 38`, `<a href="/inbound/log?from=2026-09-14&amp;p=2&amp;to=2026-09-21">Older →</a>`,
`A decision opens its reason · Allow / Deny sender opens <a href="/inbound/filter-lists">Filter lists</a> with the entry filled in`,
`A decision opens its reason, an upstream result its last reply · Allow / Deny sender opens <a href="/inbound/filter-lists">Filter lists</a> with the entry filled in`,
)
pageLacks(t, "Inbound log", out, `← Newer`, `data-poll`, `hx-get`, `<th>Why</th>`)
// A message Postfix itself refused offers no way into the lists: one Deny
// for each of the three accepted messages, one Allow for each the filter
// 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</a>`), strings.Count(out, `>Allow sender</a>`); deny != 3 || allow != 2 {
t.Errorf("the fixture's six rows offer Deny %d times and Allow %d times, want 3 and 2", deny, allow)
if deny, allow := strings.Count(out, `>Deny sender</a>`), strings.Count(out, `>Allow sender</a>`); 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, `<td class="sp-muted">—</td>`); 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
@@ -308,3 +322,18 @@ func TestWhyRows(t *testing.T) {
}
}
}
// A message the filter had held is a plain tag until it is released, and a
// bounce of mail from the null sender says none went back.
func TestInLogHeldAndABounceWithNoReturnAddress(t *testing.T) {
p := NewInLog(admin(), 90, nil, InLogFilter{})
at := time.Date(2026, 9, 21, 14, 0, 0, 0, time.UTC)
out := renderSignedIn(t, "in-log", p.WithRows([]InLogRow{
p.NewRow(InLogRowInput{ID: 1, At: at, From: "a@example.net", To: "b@acme.io", Decision: "quarantine", Engine: "rspamd", Upstream: "held"}),
p.NewRow(InLogRowInput{ID: 2, At: at, To: "b@acme.io", Decision: "accept", Engine: "selfpost",
Upstream: "bounced", UpstreamReply: "550 5.1.1 no such user", UpstreamAt: at}),
}, 1, 1))
pageHas(t, "Inbound log", out, `<td><span class="tag is-primary is-light">held</span></td>`,
`<tr><th>Then</th><td>No bounce: the message had no return address</td></tr>`)
pageLacks(t, "Inbound log", out, `The sender was sent a bounce`, `<th>Host</th>`)
}
+4 -3
View File
@@ -1,7 +1,8 @@
{{/* Inbound log (mockup in-log.html): what port 25 decided about each message
offered for an inbound domain, one row per recipient, newest first,
filtered by domain, decision and day. It does not poll. The decision is a
tag that opens a sheet with who decided and why. The last cell is the way
tag that opens a sheet with who decided and why; the upstream's answer one
with its last reply (queued or held, not tried yet, is a plain tag). The last cell is the way
to correct a decision in Filter lists, and only the global role, who alone
edits the lists, is given it. */}}
{{define "content"}}
@@ -10,9 +11,9 @@
{{template "box_open" .Messages}}
{{if .Rows}}
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th></th></tr></thead>
<thead><tr><th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th>Upstream</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}}{{if .Client}}<br><span class="sp-mono sp-muted sp-small">{{.Client}}</span>{{end}}</td><td>{{wbr_at .To}}</td>{{if .Subject}}<td class="sp-clip" title="{{.Subject}}">{{.Subject}}</td>{{else}}<td class="sp-clip sp-muted">—</td>{{end}}<td>{{template "sheet_tag" .Why}}</td><td class="sp-actions">{{if .Action}}<a href="{{.Action.Href}}">{{.Action.Label}}</a>{{end}}</td></tr>{{end}}
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">{{.Time}}</td><td>{{wbr_at .From}}{{if .Client}}<br><span class="sp-mono sp-muted sp-small">{{.Client}}</span>{{end}}</td><td>{{wbr_at .To}}</td>{{if .Subject}}<td class="sp-clip" title="{{.Subject}}">{{.Subject}}</td>{{else}}<td class="sp-clip sp-muted">—</td>{{end}}<td>{{template "sheet_tag" .Why}}</td>{{if .Upstream}}<td>{{template "sheet_tag" .Upstream}}</td>{{else if .Pending}}<td>{{template "tag" .Pending}}</td>{{else}}<td class="sp-muted">—</td>{{end}}<td class="sp-actions">{{if .Action}}<a href="{{.Action.Href}}">{{.Action.Label}}</a>{{end}}</td></tr>{{end}}
</tbody>
</table></div>
{{template "box_foot" .Foot}}