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. Mockups5348330and46b90d2, plan295d72e. - 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:
+27
-2
@@ -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
|
||||
|
||||
+6
-1
@@ -1188,7 +1188,12 @@ anyone on the host; they hold addresses, not secrets.
|
||||
|
||||
In the panel the lists are **Inbound › Filter lists** (global role), and what
|
||||
port 25 decided about each message is **Inbound › Log**: accepted mail with the
|
||||
filter's verdict, refused mail with the reply it was given. A row of the log
|
||||
filter's verdict, refused mail with the reply it was given. For accepted mail
|
||||
its *Upstream* column says what the upstream made of it - *queued* (not tried
|
||||
yet), *held* (in the queue by the filter's verdict, until released), *sent*,
|
||||
*deferred* (a greylisting, an upstream that cannot be reached: Postfix tries
|
||||
again) or *bounced* (refused for good, or expired in the queue; the sender
|
||||
was sent a bounce) - and opens the last reply and the host that gave it. Rows logged before 2.10.0 have no upstream result. A row of the log
|
||||
leads to the lists with its sender filled in. Beside each entry of a list is
|
||||
how often it decided a message, counted from the log - so over the days the
|
||||
log keeps - and *never* marks an entry that may have outlived its use. The
|
||||
|
||||
@@ -54,13 +54,44 @@ every line, is for later, if the result is not enough.
|
||||
architecture (journal and tailer), security (what is stored from the
|
||||
upstream and at what rate), CHANGELOG.
|
||||
|
||||
## Reviews (2026-10-16)
|
||||
|
||||
A technical and a security review of the tree before the commit; what each
|
||||
finding became:
|
||||
|
||||
- *A quoted RCPT TO* (both): the milter kept `"owner"@x` as given, every line
|
||||
says `owner@x` - the row never matched. Now `recipientAsLogged`, as the
|
||||
outbound milter does.
|
||||
- *A forged line through a quoted local part* (security, medium): `to=<…>`
|
||||
was read up to its first `>`; a local part in quotes may hold `>`, `,`,
|
||||
`status=`. Port 25 refuses the one that would reach another row (an `@` in
|
||||
the local part is a routed address: `src/trivial-rewrite/resolve.c:467`,
|
||||
`src/smtpd/smtpd_check.c:1724`, `allow_untrusted_routing=no`), and the
|
||||
pattern now reads addresses by Postfix's quoting all the same
|
||||
(`loggedAddress`).
|
||||
- *Held mail* (technical): a message the filter had held showed `queued`,
|
||||
"not tried yet", for as long as it stayed held. Now `held`, a status of its
|
||||
own (the CHECK of 0012, not shipped yet).
|
||||
- *Rows that never close, and queue ids that repeat* (both): before writing
|
||||
a message's rows the milter detaches older rows of its queue id; cleanup's
|
||||
`removed (canceled)` detaches rows of a message never queued; an empty
|
||||
`{i}` writes no result.
|
||||
- *A bounce of null-sender mail* (technical): "The sender was sent a
|
||||
bounce" is wrong there - Postfix does not bounce a bounce. The sheet says
|
||||
so.
|
||||
- Accepted, said in security.md: a row whose insert waited past the milter's
|
||||
30 s timeout can miss its first line and stay `queued`. Not of this row:
|
||||
an old send-log row of a reused queue id and the same recipient takes the
|
||||
line first (sendlog.go, as before).
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] Migration 0012, store: write the queue id, update by queue id and
|
||||
- [x] Migration 0012, store: write the queue id, update by queue id and
|
||||
recipient, read the result back.
|
||||
- [ ] Milter: the queue id and `queued` on accepted rows.
|
||||
- [ ] Tailer: delivery and expiry lines of port-25 mail to the journal.
|
||||
- [ ] View, template, help; tests (store, milter, tailer, view).
|
||||
- [ ] e2e: a relayed message's row shows `sent` with the sink's `250`; an
|
||||
- [x] Milter: the queue id and `queued` on accepted rows.
|
||||
- [x] Tailer: delivery and expiry lines of port-25 mail to the journal.
|
||||
- [x] View, template, help; tests (store, milter, tailer, view).
|
||||
- [x] e2e: a relayed message's row shows `sent` with the sink's `250`; an
|
||||
upstream that refuses connections shows `deferred` with the reason.
|
||||
- [ ] Docs; roll out, read the server's journal; cut `2.10.0`.
|
||||
- [x] Docs. *Done (2026-10-16), with the reviews' findings above.*
|
||||
- [ ] Roll out, read the server's journal; cut `2.10.0`.
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
||||
|
||||
| ID | Topic | Status | Progress | Plan |
|
||||
|---|---|---|---|---|
|
||||
| inbound-upstream-result | Inbound › Log shows what the upstream answered: sent, deferred, bounced, with its last reply | agreed (2026-10-16) | 0/6 | [plans/inbound-upstream-result.md](plans/inbound-upstream-result.md) |
|
||||
| inbound-upstream-result | Inbound › Log shows what the upstream answered: sent, deferred, bounced, with its last reply | agreed (2026-10-16); built, unreleased | 6/7 | [plans/inbound-upstream-result.md](plans/inbound-upstream-result.md) |
|
||||
| health-setup-checks | Server › Health also checks the certificate's name, Postfix's greeting and the trusted proxy (was preflight) | agreed (2026-10-13) | — | — |
|
||||
| test-email | Send a test message from the panel (was preflight) | agreed (2026-10-13) | — | — |
|
||||
| inbound-antispam-panel | Inbound antispam journal + allow/deny lists | shipped `2.1.0` | 12/12 | [plans/inbound-antispam-panel.md](plans/inbound-antispam-panel.md) |
|
||||
|
||||
@@ -17,8 +17,8 @@ When you add or review a migration, update this file in the same change.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Chain head | `user_version = 11` |
|
||||
| Files | `0001_init.sql` (the 2.0 baseline), `0002_inbound_spam_log.sql`, `0003_send_log_lines.sql`, `0004_notifications.sql`, `0005_send_log_rejected_index.sql`, `0006_sender_check.sql`, `0007_delivery_log_days.sql`, `0008_tenant_boundary.sql`, `0009_refusal_line.sql`, `0010_send_log_indexes.sql`, `0011_notify_addressees.sql` |
|
||||
| Chain head | `user_version = 12` |
|
||||
| Files | `0001_init.sql` (the 2.0 baseline), `0002_inbound_spam_log.sql`, `0003_send_log_lines.sql`, `0004_notifications.sql`, `0005_send_log_rejected_index.sql`, `0006_sender_check.sql`, `0007_delivery_log_days.sql`, `0008_tenant_boundary.sql`, `0009_refusal_line.sql`, `0010_send_log_indexes.sql`, `0011_notify_addressees.sql`, `0012_inbound_upstream.sql` |
|
||||
| File mark | `PRAGMA application_id = 0x53503230` ("SP20") |
|
||||
| Database file | `/data/selfpost.db` (bind mount) |
|
||||
| Compatibility | 2.x MINOR releases must boot a `2.0.0` data directory. **Nothing is carried over from 1.x.** |
|
||||
@@ -69,7 +69,8 @@ delivery-log-storage (`2.2.0`), `0004_notifications.sql` and
|
||||
`0007_delivery_log_days.sql` and `0008_tenant_boundary.sql` of the 2026-10-12
|
||||
audit (`2.5.0`), `0009_refusal_line.sql` (`2.5.1`) and
|
||||
`0010_send_log_indexes.sql` (`2.5.3`) and `0011_notify_addressees.sql`
|
||||
(`2.5.4`).
|
||||
(`2.5.4`), and `0012_inbound_upstream.sql` of inbound-upstream-result
|
||||
(`2.10.0`).
|
||||
|
||||
---
|
||||
|
||||
@@ -88,6 +89,7 @@ audit (`2.5.0`), `0009_refusal_line.sql` (`2.5.1`) and
|
||||
| 9 | `0009_refusal_line.sql` | 2.5.1 | DDL | `send_log.refusal_line`: Postfix's line about a refusal at MAIL FROM (a level-2 limit, a client address off the list), which has no queue id; the log tailer puts it on the row; `send_log.recovered`, a refusal row the tailer wrote from `mail.log` because the panel stopped before writing it; `sender_check_days.messages` (messages deferred mid-message, counted apart from the connections turned away) and `sender_check_days.estimated` (a day whose downtime is an upper bound) (audit 2026-10-12, stage 2b) |
|
||||
| 10 | `0010_send_log_indexes.sql` | 2.5.3 | DDL | Four indexes of `send_log`, each for a question that walked the table or sorted a domain's log (audit 2026-10-12, M2, stage 3): `idx_send_log_open`, partial, the queued and deferred rows by time (the tailer's sweep every 5 min); `idx_send_log_domain_id` and `idx_send_log_app_login`, a page of one domain's or one application's log in id order; `idx_send_log_refusal_line`, partial, whether a refusal line is already on a row. `TestSendLogQueriesUseTheirIndexes` checks the plans of these and of the other by-time and by-scope statements the store runs |
|
||||
| 11 | `0011_notify_addressees.sql` | 2.5.4 | DDL | `notify_addressees (key, address, told_at)`: who a subject's notification is owed to and who was handed it, so a copy that failed or was held back by the hourly ceiling goes out at a later check and a subject's end goes to those told (audit 2026-10-12, stage 4); deleted with its `notify_state` row |
|
||||
| 12 | `0012_inbound_upstream.sql` | 2.10.0 | DDL | `inbound_spam_log` gains `queue_id`, `upstream_status` (`''`, `queued`, `held`, `sent`, `deferred`, `bounced`; CHECK), `upstream_relay`, `upstream_reply` and `upstream_at`, and the partial index `idx_inbound_spam_log_queue_id` on rows that have a queue id: what the upstream answered about mail accepted on port 25, put on the row by the log tailer (inbound-upstream-result). Rows from before have none |
|
||||
|
||||
**Kind:** *DDL* — schema only; *data* — `INSERT`/`UPDATE` that must stay correct
|
||||
for operators upgrading from older 2.x images.
|
||||
|
||||
@@ -368,6 +368,28 @@ operator can act on it.
|
||||
ask for it at all, an application with a client-address list is refused when
|
||||
no address is known, and neither milter replies anything but "continue" to a
|
||||
message it lets through. `test/e2e` sends two `MAIL FROM` on one connection.
|
||||
- **The upstream's answer is an update of a row, not a row**
|
||||
(inbound-upstream-result, `2.10.0`). A delivery line of mail accepted on
|
||||
port 25 changes the result of that message's journal rows and adds
|
||||
nothing: a stranger who sends more mail gets more rows from the milter, as
|
||||
before, and each row at most one update per delivery attempt, at Postfix's
|
||||
retry pace. The lines themselves of port-25 mail are still not kept. What
|
||||
is written: the status and the time are Postfix's fields; the host is
|
||||
Postfix's `relay=`, the reply the upstream's words - which may quote the
|
||||
stranger's address - or Postfix's own about a host it could not reach, both
|
||||
printable and cut to 255 bytes, shown in `sp-mono` cells like a refusal's
|
||||
reply (escaped by html/template). The stranger's own text reaches the line
|
||||
only as its `to=` and `orig_to=`, which is the key and nothing else: they
|
||||
are read by Postfix's quoting (`loggedAddress`, `internal/logtail`), so a
|
||||
quoted RCPT TO holding `>`, `,` or `status=` cannot end the address early
|
||||
and pass fields of its own for another recipient's row - and one holding an
|
||||
`@` is a routed address, which port 25 refuses as relaying
|
||||
(`allow_untrusted_routing=no`). Short queue ids repeat (Postfix's default
|
||||
`enable_long_queue_ids=no`): before writing a message's rows the milter
|
||||
detaches older rows of its queue id, so a message that is gone cannot take
|
||||
a new one's results. Accepted: a row whose insert waited longer than the
|
||||
milter's 30 s timeout - the database held - can miss its first delivery
|
||||
line and stay `queued`.
|
||||
- **The inbound journal is written from what strangers send.** The inbound
|
||||
journal milter is the last on port 25, read-only and attached fail-open; it
|
||||
keeps of the filter's verdict only what its patterns recognise. **A verdict
|
||||
|
||||
Reference in New Issue
Block a user