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 -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>`)
}