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
+30 -8
View File
@@ -23,6 +23,7 @@ import (
type InboundStore interface {
InsertInboundSpam(e store.InboundSpamEntry) error
IsInboundDomain(name string) (bool, error)
ForgetInboundQueueID(queueID string) error
}
// inboundSession is the inbound journal milter: the last milter on port 25,
@@ -79,8 +80,11 @@ func (s *inboundSession) MailFrom(from string, m *milter.Modifier) (milter.Respo
return milter.RespContinue, nil
}
// RcptTo keeps the recipient as Postfix logs it, so that the delivery lines
// that carry the upstream's answer find the row (recipientAsLogged): a quoted
// local part that needs no quotes is written without them.
func (s *inboundSession) RcptTo(rcpt string, m *milter.Modifier) (milter.Response, error) {
s.rcpts = append(s.rcpts, cleanAddress(rcpt))
s.rcpts = append(s.rcpts, recipientAsLogged(rcpt))
return milter.RespContinue, nil
}
@@ -156,11 +160,13 @@ func (s *inboundSession) signed(queueID string) bool {
// rest as session.Body in milter.go.
func (s *inboundSession) Body(m *milter.Modifier) (milter.Response, error) {
// The queue id is known only now, and with it whether the verdict headers
// read on the way here are the filter's.
if !s.signed(macro(m, "i")) {
// read on the way here are the filter's. The rows keep it: the delivery
// lines that put the upstream's answer on them name it (logtail).
queueID := macro(m, "i")
if !s.signed(queueID) {
s.seen = false
}
s.record()
s.record(queueID)
s.reset()
return milter.RespContinue, nil
}
@@ -175,10 +181,17 @@ func (s *inboundSession) reset() {
s.action, s.result, s.seen, s.sig = "", "", false, ""
}
// record writes one row per recipient at an inbound domain. Other recipients
// of port 25 (a DMARC report address at the server's own name) are not the
// relay's mail and get no row. Failures are logged, never propagated.
func (s *inboundSession) record() {
// record writes one row per recipient at an inbound domain, with the queue id
// of the message and "queued" - "held" when the filter's verdict had Postfix
// hold it (src/cleanup/cleanup_milter.c: a milter's HOLD puts the message in
// the hold queue, which qmgr does not schedule). The message is about to be
// queued for its upstream, and the rows are written before Postfix goes on,
// so before any delivery line of it; older rows of a queue id Postfix uses
// again are detached first. With no queue id (none came) the rows have no
// result: nothing could report one. Other recipients of port 25 (a DMARC
// report address at the server's own name) are not the relay's mail and get
// no row. Failures are logged, never propagated.
func (s *inboundSession) record(queueID string) {
e := store.InboundSpamEntry{
ClientIP: s.clientIP,
From: s.from,
@@ -193,6 +206,15 @@ func (s *inboundSession) record() {
e.Decision = store.SpamDecisionQuarantine
}
}
if queueID != "" {
e.QueueID, e.Upstream = queueID, store.UpstreamQueued
if e.Decision == store.SpamDecisionQuarantine {
e.Upstream = store.UpstreamHeld
}
if err := s.rec.ForgetInboundQueueID(queueID); err != nil {
log.Printf("inbound-journal: detach older rows of %s: %v", queueID, err)
}
}
for _, to := range s.rcpts {
domain := domainOf(to)
ok, err := s.rec.IsInboundDomain(domain)
+46
View File
@@ -17,6 +17,7 @@ type fakeInbound struct {
rows []store.InboundSpamEntry
insertErr error
lookupErr error
forgot []string
}
func (f *fakeInbound) InsertInboundSpam(e store.InboundSpamEntry) error {
@@ -27,6 +28,11 @@ func (f *fakeInbound) InsertInboundSpam(e store.InboundSpamEntry) error {
return nil
}
func (f *fakeInbound) ForgetInboundQueueID(queueID string) error {
f.forgot = append(f.forgot, queueID)
return nil
}
func (f *fakeInbound) IsInboundDomain(name string) (bool, error) {
if f.lookupErr != nil {
return false, f.lookupErr
@@ -100,6 +106,8 @@ func TestInboundRecordsAcceptedMessage(t *testing.T) {
InboundDomain: "in.example.com", ClientIP: "203.0.113.7",
From: "sender@example.net", To: "a@in.example.com", Subject: "Schön",
Decision: store.SpamDecisionAccept, Engine: store.SpamEngineSelfPost,
// The queue id of end-of-message, for the delivery lines to come.
QueueID: "ABC123", Upstream: store.UpstreamQueued,
}
if rec.rows[0] != want {
t.Errorf("row = %+v\nwant %+v", rec.rows[0], want)
@@ -204,6 +212,7 @@ func TestInboundResetsBetweenMessages(t *testing.T) {
InboundDomain: "in.example.com", ClientIP: "203.0.113.7",
From: "two@example.net", To: "b@in.example.com",
Decision: store.SpamDecisionAccept, Engine: store.SpamEngineSelfPost,
QueueID: "ABC123", Upstream: store.UpstreamQueued,
}
if rec.rows[1] != want {
t.Errorf("second row = %+v\nwant %+v", rec.rows[1], want)
@@ -354,3 +363,40 @@ func TestFilterSignatureIsWhatTheFilterWrites(t *testing.T) {
t.Errorf("FilterSignature = %s", got)
}
}
// The row is the delivery lines' to find: a recipient is kept as Postfix logs
// it, a held message starts held, older rows of a reused queue id are
// detached first - once per message - and a message with no queue id has no
// result, since nothing could ever report one.
func TestInboundRowsMeetTheirDeliveryLines(t *testing.T) {
rec := &fakeInbound{domains: map[string]bool{"in.example.com": true}}
s := inboundSessionFor(t, rec, true)
message(t, s, "sender@example.net", []string{`<"owner"@in.example.com>`, "b@in.example.com"}, nil)
if len(rec.rows) != 2 || rec.rows[0].To != "owner@in.example.com" {
t.Fatalf("rows = %+v, want the quoted recipient as logged", rec.rows)
}
if len(rec.forgot) != 1 || rec.forgot[0] != "ABC123" {
t.Errorf("detached %q, want ABC123 once", rec.forgot)
}
rec.rows = nil
message(t, s, "sender@example.net", []string{"a@in.example.com"},
[][2]string{{"X-Rspamd-Action", "quarantine"}, testSig})
if len(rec.rows) != 1 || rec.rows[0].Upstream != store.UpstreamHeld {
t.Errorf("a held message's row = %+v, want held", rec.rows)
}
rec.rows, rec.forgot = nil, nil
if _, err := s.MailFrom("sender@example.net", mods(nil)); err != nil {
t.Fatal(err)
}
if _, err := s.RcptTo("a@in.example.com", mods(nil)); err != nil {
t.Fatal(err)
}
if _, err := s.Body(mods(nil)); err != nil {
t.Fatal(err)
}
if len(rec.rows) != 1 || rec.rows[0].QueueID != "" || rec.rows[0].Upstream != "" || len(rec.forgot) != 0 {
t.Errorf("with no queue id: rows %+v, detached %q", rec.rows, rec.forgot)
}
}