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
+27 -2
View File
@@ -113,7 +113,12 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
verdict headers (`X-Rspamd-Action`, `X-Spamd-Result`) off it. It writes one
`inbound_spam_log` row per recipient at an inbound domain - a DMARC report
address is not one - and never refuses anything
([internal/milter/inbound.go](../internal/milter/inbound.go)). The headers
([internal/milter/inbound.go](../internal/milter/inbound.go)). The row
keeps the message's queue id (`{i}` at end-of-message) and starts
`queued` - `held` when the filter had Postfix hold it - and the recipient
as Postfix logs it (`recipientAsLogged`); the log tailer puts the
upstream's answer on it (§ Log tailer, *The upstream's answer*). Older
rows of a queue id Postfix uses again are detached first. The headers
are read only while a filter is attached, and only next to the filter's
signature: a third header, `X-SelfPost-Filter`, the HMAC-SHA256 of the
message's queue id under the key in `/data/antispam/filter.key`
@@ -585,6 +590,26 @@ inbound journal's. The tail of a line is the far end's words (a reply, a HELO
name), and a pattern that could start there let a stranger's HELO name file a
delivery (audit 2026-10-12, M12).
**The upstream's answer** on the inbound journal (inbound-upstream-result,
`2.10.0`). Mail accepted on port 25 is handed to its upstream by the same
`smtp` client as outbound mail, and its delivery lines look alike; what tells
them apart is the row. A delivery line no send-log row takes (`UpdateStatus`
changed nothing) is offered to the journal: the rows of its queue id and
recipient take the status, the host (`relay=`, `""` for `none` - a line
written without a session, Postfix 3.10.13, `src/smtp/smtp_trouble.c:273,
421`), the reply - the `(%s)` after the first `status=`, made printable and
cut to 255 bytes - and the line's time (`store.UpdateInboundUpstream`). Only
a `queued`, `held` or `deferred` row moves: `sent` and `bounced` are final, so
a line read twice after a restart changes nothing. The addresses are read by
Postfix's quoting (`loggedAddress`), so a quoted local part cannot end one
early. cleanup's `removed (canceled)` - a queue file never queued - detaches
the rows the milter had written for it. qmgr's expiry line, which names no
recipient, makes the rows of its queue id `bounced`, keeping the last reply
after "expired in the queue" (`ExpireInboundUpstream`). It is an update per
attempt, never a row: the journal's caps stand, and port-25 mail's lines are
still not stored (delivery-log-storage). A bounce Postfix sends to a stranger
is mail of its own queue id and matches no row.
**Read offset is persisted** (`logtail_state` table, in the baseline schema): the
tailer stores its position plus a fingerprint of the log - the hash of its first
512 bytes and the file's device and inode, so two logs that begin alike are two
@@ -1119,7 +1144,7 @@ full backups also archive the operator's `./certs` PEM files when present.
### Database
The schema is an embedded baseline, `internal/store/migrations/0001_init.sql`,
and the migrations after it (`0002` to `0011` today; the next is `0012`),
and the migrations after it (`0002` to `0012` today; the next is `0013`),
applied by `store.Open` in file order; `PRAGMA user_version` counts the files
applied and
`PRAGMA application_id` is `0x53503230` ("SP20"). There is no 1.x migration