audit 6: documents, comments, dead code and staticcheck; the test gaps closed
test / test (push) Canceled after 0s

The last stage of the 2026-10-12 audit; no cut.

Doc drift: architecture.md (the sender check in § OpenDKIM, the limit:
row's 48 hours, the inbound log and filter-list routes and who may open
them, migrations to 0011 and every table, KitPage as production data,
Reload configuration as a restore writes, the panel's roles counted),
schema-migrations.md (tables and columns since v2), guide.md (a delivery's
lines go at the next prune; INBOUND_JOURNAL_MILTER_SOCKET moved to
architecture.md's internal list), development.md (the env guard, the
deferral the e2e proves, its fixed sleeps), roadmap.md.

Comments say what the code does now; TestBaselineIsTheOnlyMigration is
TestChainStartsAtTheBaseline; the milter package doc names its three
milters.

Dead code gone: view.Facts, dmarc HostedSuggestion and
ValidateHostedAddress, app UpdateMode (each with the test that alone kept
it), the method checks the method-bound routes made dead;
Settings.WithResult calls resultFlash. staticcheck 2026.1 in test.yml on
both modules, clean: the e2e package builds without its tests now (a test
and two constants moved out of non-test files), three findings fixed.
The test moved, TestImageBuildPreservesLogrotateMode, had sat in a
non-test file and never run; run, it failed on the entrypoint (docker run
without a hostname) and now asks with --entrypoint stat.

The test gaps no step named, each closed: follow() with mail.log gone past
blindMin and made again; CheckRestore with a manifest that is not JSON;
the origin check with a port; every /server/ pattern in web.go through
server() and in the routes table (it found GET /server/health/fragment
missing from the table - guarded, not tested); L9 as the owner decided it
on 2026-10-12, the comment on senderSession.Headers and the test passing
an h that holds the earlier messages' From:.

Plan stage 6 ticked; roadmap audit row done, 81/81.

Tested on the test server: go vet + go test of 27 packages, make e2e (all
pass, 513 s) on this tree; staticcheck locally on both modules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-15 14:41:26 +03:00
co-authored by Claude Opus 5.5
parent dc78b0ed76
commit 6023feb405
39 changed files with 434 additions and 256 deletions
+37 -19
View File
@@ -207,10 +207,10 @@ The server's own name (`SELFPOST_HOSTNAME`) has a key and a line in both
tables too, without being a row of the registry
(`domain.Service.SetServerName`): the panel's notifications are sent as
`postmaster@` that name and signed with it. No application can use it as an
envelope sender - the sender map is built from the registry - but the
`From:` header is not held against the login, so an application can still
have a message signed as the server ([security.md](security.md) § Accepted
risks). A sending domain of the same name shares its key and selector with
envelope sender - the sender map is built from the registry - and since
`2.4.0` none can have a message signed as the server by writing it into
`From:` either: the sender check holds the header against the login (§ Sender
check). A sending domain of the same name shares its key and selector with
the server; deleting that domain leaves the server signed under the default
selector, which is the same key only if that was the domain's. At its start
the notifier makes sure the tables have the line and name the selector the
@@ -219,7 +219,8 @@ domain is added or deleted.
### Panel binary ([cmd/panel](../cmd/panel))
One process, five roles (six with the inbound relay on):
One process, six roles (seven with the inbound relay on: its inbound journal
milter, § Postfix above, port 25):
1. **HTTP server** — `:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse
proxy only. On start it runs `postconf -h -x` once for the deferred-mail retry
@@ -398,7 +399,8 @@ is sent; the operator's view is guide § Notifications.
minutes after it - a count alone would let the last check before a restart
and the first after it stand for "an hour apart"; back the same way. The
certificate's row remembers which threshold was said for which expiry. A
deleted domain's row goes with it.
deleted domain's `dns:` row goes at the next check; its `limit:` row stays
until it was last said more than 48 hours ago (`CheckLimits`).
- **Said means sent.** A problem that arises while no user can be told, or
while Postfix does not take the message, stays unsaid and is told at a
later check. Its ending, with nobody left to tell, is simply over.
@@ -675,6 +677,7 @@ only reads (the delete confirmation at `GET …/delete` renders a form).
| Outbound › Log, Queue | `GET /outbound/log` · `/outbound/log/fragment` · `/outbound/log/{id}` · `GET /outbound/queue` · `/outbound/queue/fragment` |
| Outbound › DMARC reports | `GET /outbound/dmarc` · `…/dmarc/domains/{id}` · `…/dmarc/reports/{id}` — registered only with `DMARC_REPORTS_ENABLE=true` |
| Inbound › Domains | `GET, POST /inbound/domains` · `/inbound/domains/{id}` · `POST …/dns-recheck`, `…/upstream`, `…/recipients` · `GET, POST …/delete` — registered only with `INBOUND_RELAY_ENABLE=true` |
| Inbound › Log, Filter lists | `GET /inbound/log` · `GET, POST /inbound/filter-lists` · `POST /inbound/filter-lists/{id}/delete` — registered only with `INBOUND_RELAY_ENABLE=true` |
| Server | `GET /server/health` (+ `/fragment`) · `POST /server/health/recheck`, `/server/health/reload` · `GET /server/log` (+ `/fragment`) · `GET, POST /server/backup` · `POST /server/backup/import` · `/server/users`, `…/new`, `…/{uid}`, `…/{uid}/delete` · `GET, POST /server/settings` · `GET /server/components` |
| Account | `GET /account` · `POST /account/profile`, `/account/password` |
| User menu | `GET /help` · `POST /logout` (public mux) |
@@ -718,7 +721,11 @@ is not.
([handlers/handlers_inbound.go](../internal/web/handlers/handlers_inbound.go)):
`requireInbound` (the list), `requireInboundDomain` (one domain — page,
upstream, recipients, DNS re-check) and `requireInboundGlobal` (add, delete).
All three answer 404 alike.
All three answer 404 alike. The **Inbound log** (`/inbound/log`) is open to
anyone who reaches an inbound domain (`requireInbound`) and shows the rows
of their domains only; **Filter lists** (`/inbound/filter-lists`) are the
instance's and the global role's (`requireInboundGlobal`)
([handlers/handlers_inbound_log.go](../internal/web/handlers/handlers_inbound_log.go)).
- **The DMARC hub** is open to anyone with outbound reach for the reports of
their own domains; the *Ingest* box (server-wide statistics) is rendered for
the global role only.
@@ -796,8 +803,9 @@ is one layout and one way to render.
`panel.css` (the mockups' stylesheet, rule for rule), `htmx.min.js`, `panel.js`,
the IBM Plex subset and the logo files. Served with a content-derived `ETag`,
and `/static/` does not list its files. The kit page
`GET /server/components` renders every partial in every state from fixtures
(global role only).
`GET /server/components` renders every partial in every state from the data
of `KitPage` ([kit.go](../internal/web/view/kit.go), production code that the
guard tests render too; global role only).
**Guard tests** hold the design in place; they run in `go test ./...` and CI.
They read their rules from the accepted mockups
@@ -958,9 +966,9 @@ flowchart TB
dmarcSvc --> postfix
```
The four roles inside the `panel` process (HTTP server, journal-milter,
log-tailer, rate-limit recalc) share one binary and one SQLite handle on
purpose — see
The roles inside the `panel` process (HTTP server, sender check,
journal-milter, log-tailer, rate-limit recalc, notifier) share one binary and
one SQLite handle on purpose — see
[Panel binary](#panel-binary-cmdpanel) for why, and *Persistence* below for the
single-connection trade-off that follows from it.
@@ -993,8 +1001,9 @@ 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_inbound_spam_log.sql`), applied by
`store.Open` in file order; `PRAGMA user_version` counts the files applied and
and the migrations after it (`0002` to `0011` today; the next is `0012`),
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
chain. **2.0 starts from an empty data directory:** a database with a schema
version but without the 2.x `application_id` — what every 1.x file looks like —
@@ -1029,7 +1038,12 @@ The tables that carry the panel's model:
`application_addresses`, `rate_limits` (no `allowed_ips` column), `send_log`,
`send_log_lines` (what Postfix logged under a queue id — § Log tailer),
`logtail_state`, `inbound_domains`, `inbound_transports`,
`inbound_recipients`, `dmarc_reports`, `dmarc_report_records`.
`inbound_recipients`, `inbound_spam_log` (port 25's decisions, § Postfix),
`inbound_spam_list` (the filter lists), `dmarc_reports`,
`dmarc_report_records`, `notify_state` and `notify_addressees` (what the
notifier has said and to whom, § Notifications), `sender_check_days` (the
sender check's downtime and the mail it deferred, per UTC day) and
`delivery_log_days` (the queue sweep's measurements, § Log tailer).
Two processes write: the panel, on one connection, and `dmarc-ingest`, which
Postfix starts per report. SQLite runs in WAL mode (see
@@ -1051,13 +1065,14 @@ under `/data`), `docker-compose.yml`, `.env`, and `certs/` when present;
version check on restore. Requires the project directory mounted read-only at
`SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` in the default compose file). On the
first successful boot after restore, the panel runs one **Resync** — OpenDKIM's
tables, Postfix's sender map, inbound relay maps (when
tables, Postfix's sender map, inbound relay maps and the filter lists (when
`INBOUND_RELAY_ENABLE=true`), and DMARC maps (when `DMARC_REPORTS_ENABLE=true`)
are re-derived from SQLite and both daemons are reloaded, so drift between the
extracted archive and the database is healed before mail flows. Manual
`POST /server/health/reload` (the *Reload configuration* button on Server ›
Health) runs the same Resync, the filter lists with it, since `2.5.4`.
Stopped-container
Health) writes the same files in the same order (`HandleReload`; before
`2.5.4` it left out the filter lists and the DMARC maps), and on a failure
stops at that step and names it on the page. Stopped-container
`tar` of `./data` alone remains possible for state-only copies (see guide).
**Optional encryption** of the two secret-bearing downloads
@@ -1095,7 +1110,10 @@ unsupported rather than as a missing doc:
(`/data/selfpost.db`), `SELFPOST_SETUP_TOKEN_FILE`
(`/data/setup-token`), `PANEL_HTTP_ADDR` (`:8080`),
`JOURNAL_MILTER_SOCKET` (`/run/selfpost/journal.sock`),
`SENDER_CHECK_SOCKET` (`/run/selfpost/sender.sock`), `MAIL_LOG`
`SENDER_CHECK_SOCKET` (`/run/selfpost/sender.sock`),
`INBOUND_JOURNAL_MILTER_SOCKET` (`/run/selfpost/inbound-journal.sock` — the
inbound journal milter's socket, read by the panel, `build/postfix-config.sh`
and `build/postfix-wrapper.sh`, which must agree), `MAIL_LOG`
(`/data/log/mail.log` — read by the panel and written by Postfix, so a change
here has to be matched in `build/postfix-config.sh`),
`PANEL_COOKIE_SECURE` (`true`), `OPENDKIM_SOCKET`
+82 -4
View File
@@ -496,6 +496,49 @@ tests; `TestRenderInboundMaps` has no two-routes-one-next-hop case (L5);
rows of `review-2026-08-followups` (apps/domains/users/dmarc handlers
directly, `internal/dmarc` service) are still open.
*Checked in stage 6 (2026-10-15):* **closed** since, each by the test that
now holds it: the H1 stamp (e2e `image_stamped_with_version`); cert re-copy
(e2e `renewed_certificate_picked_up`); a recreated log and lines written to
the old inode after the switch (`TestFollowTailsAndRotates`,
`TestFollowReadsTheRotatedFileAfterTheSwitch`,
`TestALogRotatedWhileDownIsReadToItsEnd`); a truncated `postqueue -p`
(`TestQueueIDsRefusesATruncatedListing`); a `sent` row left alone by the
sweep and by a reused id (`TestCloseUnreportedClosesOnlyQueuedRows`,
`TestUpdateStatusRewritesOnlyTheNewestRow`); H4
(`TestAddApplicationRefusesALoginDifferingOnlyInCase`,
`TestServiceCreateFoldsTheLogin`); M1's ordering
(`TestARestoreStartThatFailsIsARestoreAgain`; the real reload by e2e
`full_backup_restore`); M3 (`TestSnapshotRefusesAMissingDatabase`,
`TestBackupOfAMissingDatabaseIsRefused`); M5 (`TestEncryptionPasswordFloor`);
L1 (`TestDecryptLeavesTheOutputAloneOnFailure`); L2
(`TestCertsLinksAreFollowed`, `TestCheckRestoreReadsOnlyRegularFiles`); L3
(`TestBytesAfterTheLastChunkRejected`); M13 (e2e
`panel_hung_mid_message_is_counted`, and the unit fixture now holds the
`cleanup` lines as the stand wrote them); M15
(`TestServerPTRTimeoutIsUnknown`,
`TestServerAddressTimeoutIsUnknownAndKeepsTheAddresses`);
`TestLimitRefusalsSince` (the real `limitRefusalsQuery` is planned in
`TestSendLogQueriesUseTheirIndexes`); `TestOpenMigratesA20Database` (migrates
to the head, 11); M10 (`TestDomainImportIsCheckedLikeTheForms`); M11
(`TestPanelJS`: `poll_test.js`, `unsaved_test.js`); M8
(`TestLoginTakesAsLongForAnUnknownName`); M7
(`TestTheLastGlobalSurvivesTwoAtOnce`); `internal/atomicfile` and
`internal/supervisor` (tests of their own); L5
(`TestRenderInboundMapsOneTLSLinePerNextHop`); the handler rows of
`review-2026-08-followups` (struck in the roadmap with their reasons; users,
DMARC and apps have handler tests). The five then **still open**, named by
no step, were closed in stage 6 the same day: `follow()` with a log gone
past `blindMin` and made again
(`TestFollowCountsALogItCannotReadAndReadsItAgain`); `CheckRestore` with a
manifest that is not JSON (`TestCheckRestoreRefusesAManifestThatIsNotJSON`);
a Host with a port against an Origin (`TestOriginCheckComparesThePort`);
every `/server/` pattern in web.go registered through `server()` and listed
in the routes table (`TestEveryServerRouteIsGuardedAndListed` - it found
`GET /server/health/fragment` missing from the table; the route was guarded,
not tested); L9 as the owner decided it, the comment on
`senderSession.Headers` and `TestSenderCheckResetsBetweenMessages` passing
an `h` that holds the earlier messages' `From:`.
### Checked and sound
Worth writing down so the next review does not re-read it: the sender check's
@@ -1512,20 +1555,55 @@ log it owns anyway; envdoc_test reads `${VAR:-}` and the Go helpers, not
bare `$VAR` or python (none today); the 600 s pruning in crashexit is not
tested (the count and the spared are).
### Stage 6 — documentation and roadmap sweep (no product code, no cut)
### Stage 6 — documentation and roadmap sweep (no product code, no cut) (done 2026-10-15)
- [ ] Every item under *Doc drift* above, file by file. — **Sonnet**
- [x] Every item under *Doc drift* above, file by file. — **Sonnet**
*Done 2026-10-15:* architecture.md (the sender check in § OpenDKIM, the
`limit:` row's 48 hours, the inbound log and filter-list routes and who
may open them, migrations to `0011` and every table, `KitPage` as
production data, Reload configuration as a restore writes, the panel's
roles counted again); schema-migrations.md (the tables and columns since
v2); guide.md (a delivery's lines go at the next prune;
`INBOUND_JOURNAL_MILTER_SOCKET` out of the operator's table, into
architecture.md's internal list); development.md (the env guard as
envdoc_test reads now, the deferral the e2e proves, the e2e's fixed
sleeps said where they are); roadmap.md's closing paragraph. Already right
by then: the `.alive` mark, `dmarc-ingest` as a second writer, the chain
head, M1's wording, the version stamp.
- [ ] roadmap.md: done ahead on 2026-10-13/14 with the owner -
`template-data-typing` struck, `structured-logging` dropped,
`review-2026-08-followups` closed with each row placed in a stage or
dropped with its reason. What is left: check that every *Test gaps* entry
above is named by the step that touches its code, and add it to that step
where it is not. — **Sonnet**
- [ ] Comments named above (`milter.go`, `sendlog.go`, `store.go`, `schema_test.go`,
*Done 2026-10-15:* see *Checked in stage 6* under *Test gaps*: five were
open and named by no step; each got its test in this stage (L9 as the
owner decided it).
- [x] Comments named above (`milter.go`, `sendlog.go`, `store.go`, `schema_test.go`,
`machine.go`, `inboundspam.go`, `logtail.go`, supervisord.conf,
entrypoint.sh, docker-compose.yml). — **Haiku**
- [ ] Dead code removal from § Nits, with `staticcheck` added to `test.yml` so it
*Done 2026-10-15:* each said what its code does now (the journal-milter
tempfails at MAIL FROM; the rejected status covers the sender check; the
two roles; the log is read whole for a filter list's hits; logrotate
uses `create`; the guide's headings as they are). `TestBaselineIsTheOnlyMigration`
renamed `TestChainStartsAtTheBaseline`. supervisord.conf and
entrypoint.sh were already right.
- [x] Dead code removal from § Nits, with `staticcheck` added to `test.yml` so it
stays gone. — **Haiku**
*Done 2026-10-15:* gone: `view.Facts`, `dmarc.Service.HostedSuggestion`
and `ValidateHostedAddress`, `app.Service.UpdateMode` (each with the
test that alone kept it), the method checks of `HandleDomainDMARC` and the
`default:` branches of the user and settings handlers;
`Settings.WithResult` calls `resultFlash`. Kept: `domainIDSet` (used by
the send log's authorization), `KitPage()`'s `User` (the fixtures render
`KitPage()` without the handler); the `set-permissions` branch was gone
already. staticcheck 2026.1 (the release for Go 1.26) runs in test.yml
on both modules, clean; for the e2e module the package now builds without
its tests (a test and two constants moved out of non-test files), and
three findings there were fixed (ST1005, SA4006). The test moved,
`TestImageBuildPreservesLogrotateMode`, had sat in a non-test file since
it was written, and go test had never run it; run, it failed on the
entrypoint (`docker run` without a hostname) and now asks past it.
### Decided with the owner on 2026-10-12
+17 -7
View File
@@ -420,9 +420,14 @@ go test ./...
### Env documentation regression
`go test ./cmd/panel -run 'TestLoadConfigKeysDocumented|TestBuildScriptKeysDocumented|TestDocumentedKeysAreRead'`
— every new `loadConfig` key must appear in the env lists in [guide.md](guide.md)
([cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go)).
`go test ./cmd/panel -run 'TestEnvironment'` compares what the code reads with
what the documents name ([cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go)).
Read: the `envDefault` / `envInt` / `envBool` / `envSeconds` / `envDuration` /
`os.Getenv` / `os.LookupEnv` calls in `cmd/panel` and `internal` (tests aside),
and `${VAR:-…}` in `build/*.sh`. Named: [guide.md](guide.md) § Environment
variables (the table and the paragraph under it) and the internal list in
[architecture.md](architecture.md) § Configuration. A variable read and named
nowhere fails the first test; one named and read nowhere fails the second.
### Panel pages: the component kit and its guards
@@ -470,12 +475,16 @@ operator would actually use.
**Coverage (summary):** bootstrap → SMTP AUTH → delivery → DKIM verify →
send-log `queued → sent`; negatives (no AUTH, relay, sender/login mismatch,
L1/L2 limits, milter fail-open, bad `SELFPOST_HOSTNAME`, session survives
`docker restart`); startup checks that supervisord actually brought up
L1/L2 limits, the panel down or hung mid-message (Postfix defers the mail
rather than letting it through: `panel_down_defers_mail`,
`panel_hung_mid_message_is_counted`), bad `SELFPOST_HOSTNAME`, session
survives `docker restart`); startup checks that supervisord actually brought up
OpenDKIM, the panel, and Postfix (`checkSupervisorProcesses`), plus logrotate
config-mode and forced-rotation checks (`checkLogrotateConfigMode`,
`checkLogrotateRotation` — [test/e2e/logrotate_check.go](../test/e2e/logrotate_check.go)).
Polling with timeouts only — no fixed `sleep`.
Waits poll with a timeout (`waitFor`); a fixed sleep is the exception and says
why where it stands (`sender_check_test.go`: a downtime worth a second or two,
and 35 s to show a row is not written again).
Requires **Docker + Compose v2** on the machine running the suite.
@@ -573,7 +582,8 @@ Out of scope for v1.x: `CONTRIBUTING.md`, man pages, a separate docs site
behaviour ships together with [guide.md](guide.md) / `.env.example` and a
CHANGELOG entry (see [§ Commits and release build](#commits-and-release-build)).
2. **Env regression:** [cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go)
fails on an undocumented `loadConfig` or build-script key.
fails on a variable the code or a build script reads that no document names,
and on one a document names that nothing reads.
3. **New gaps** go into [roadmap.md](roadmap.md) (or the active plan file), not
silent drive-by edits.
4. **Schema changes:** update [schema-migrations.md](schema-migrations.md) in the
+3 -2
View File
@@ -228,7 +228,6 @@ expected to set; defaults match the code exactly.
| `INBOUND_ANTISPAM_MILTER` | Optional milter on the inbound relay listener only (requires `INBOUND_RELAY_ENABLE=true`; not 465/587 or DMARC-only port 25). Empty = off. Format `inet:host:port` or `unix:/path`. Example with [deploy/antispam/docker-compose.antispam.yml](../deploy/antispam/docker-compose.antispam.yml): `inet:antispam:11332`. The panel reads it to report on the *Spam filter* box of the inbound pages and to check that the filter answers (Server › Health). | *(empty)* | `.env` |
| `INBOUND_ANTISPAM_MILTER_ACTION` | What Postfix does if that milter is down: `accept` (fail-open) or `tempfail` (refuse with a temporary error). The *Spam filter* box states which. | `accept` | `.env` |
| `INBOUND_SPAM_LOG_RETENTION_DAYS` | How long the inbound journal keeps a row, 7-365 days; it also never holds more than 10 000 rows. Seeds the setting on the first start with the inbound relay on. | `90` | `.env` |
| `INBOUND_JOURNAL_MILTER_SOCKET` | Unix socket of the panel's inbound journal milter, the last milter on port 25 while the inbound relay is on. It records accepted inbound mail and never refuses any; there is no reason to change it. | `/run/selfpost/inbound-journal.sock` | compose |
| `INBOUND_RATE_LIMIT_MESSAGES_PER_IP` | Coarse per-client-IP cap on port 25: how many times one client IP may start a message (`MAIL FROM`) per window, accepted or not (`smtpd_client_message_rate_limit`). Uses the same window as `RATE_LIMIT_WINDOW_SECONDS`. | `20` | `.env` |
| `INBOUND_MESSAGE_SIZE_LIMIT` | Maximum message size in bytes on inbound smtpd (`message_size_limit`). When both inbound relay and DMARC ingest are on, the shared listener uses the **greater** of this and `DMARC_MESSAGE_SIZE_LIMIT`. This is port 25's whole limit; 465 and 587 keep Postfix's own, 10 240 000 bytes. | `26214400` (25 MiB) | `.env` |
| `RATE_LIMIT_MESSAGES_PER_IP` | Level-1 backstop: how many times one client IP may start a message (`MAIL FROM`) on one listener per window, accepted or not; 465 and 587 count separately (Postfix `smtpd_client_message_rate_limit`). See [Rate limiting — level 1](#rate-limiting--level-1-ip-backstop). | `100` | `.env` |
@@ -1428,7 +1427,9 @@ with the *domain* role see them here because they cannot open the queue. Under
both sit the `mail.log` lines for its queue id: the connection to the
receiving server, the server's reply, and the status that reply was filed
as. The lines are kept in the database with the row, so they stay on the
page after `mail.log` has rotated and are deleted together with the row; a
page after `mail.log` has rotated. They go at the next six-hourly prune after
the row is deleted by retention, and not while another row carries the same
queue id (Postfix reuses ids); a
message accepted before `2.2.0` shows them only while they are still in the
current file, and the page says so when there are none. Retention is set under **Server › Settings** (global
role); `SEND_LOG_RETENTION_DAYS` in `.env` is only the initial default until it
+14 -15
View File
@@ -46,7 +46,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| from-header-binding | Hold a message's `From:` header against the application that sends it | shipped `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) |
| panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
| audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`, stages 1+2 `2.5.0`, 2b `2.5.1`, 2c `2.5.2`, 3 `2.5.3`, 4 `2.5.4`, 5 `2.6.0`; next stage 6, docs only) | 77/81 | [audit-2026-10-12.md](audit-2026-10-12.md) |
| audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | done 2026-10-15 (stage 0 shipped `2.4.2`, stages 1+2 `2.5.0`, 2b `2.5.1`, 2c `2.5.2`, 3 `2.5.3`, 4 `2.5.4`, 5 `2.6.0`; stage 6 docs and tests, no cut) | 81/81 | [audit-2026-10-12.md](audit-2026-10-12.md) |
**Recommended order** (not binding; owner, 2026-10-09: feature order follows
development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and
@@ -57,9 +57,9 @@ shipped as `2.2.0` the same day, **panel-notifications** as `2.3.0` and
[audit-2026-10-12](audit-2026-10-12.md) shipped as `2.4.2` (2026-10-14), its
stages 1 and 2 as `2.5.0` the same day, stage 2b as `2.5.1`, stage 2c as
`2.5.2` (2026-10-15), and stages 3, 4 and 5 as `2.5.3`, `2.5.4` and
`2.6.0` the same day.
Nothing is agreed after it: every remaining
item is a candidate. panel-docs shipped in
`2.6.0` the same day; stage 6 (documents and tests, no cut) closed the
audit. What is agreed after it is marked so in the index; every other
remaining item is a candidate. panel-docs shipped in
[CHANGELOG.md](../CHANGELOG.md) `[1.8.0]`; dmarc-reports in `[1.7.0]`
(its ingest path first ran in `2.0.0` and was reviewed on 2026-10-11, see
[security.md](security.md)); domain-stats-auto-ratelimit in
@@ -67,17 +67,16 @@ item is a candidate. panel-docs shipped in
queue-retries in `[1.3.1]`; the 2026-08-13 full-tree review follow-ups are in
`[1.3.0]`. Candidates need explicit agreement before they join the queue.
The 2026-08-19 full-tree code review is closed except for four items, all
listed above and none of them urgent: [origin-strict](#origin-strict) (its
"add CSRF tokens", decided otherwise on 2026-10-13),
template-data-typing (done by `2.0.0`; its last map is a step of
[audit-2026-10-12](audit-2026-10-12.md) stage 4),
structured-logging ([dropped](#dropped-with-the-condition-for-return) on
2026-10-13) and
[review-2026-08-followups](#review-2026-08-followups). What that review
produced is in [CHANGELOG.md](../CHANGELOG.md) `[1.9.2]`; its own document
was removed once these four items carried everything still open, and stays in
git history.
The 2026-08-19 full-tree code review is closed except for one item, listed
above and not urgent: [origin-strict](#origin-strict) (its "add CSRF tokens",
decided otherwise on 2026-10-13). Of its other three,
template-data-typing is done (the last untyped map, the health sampler's, went
in [audit-2026-10-12](audit-2026-10-12.md) stage 4, `2.5.4`),
structured-logging is [dropped](#dropped-with-the-condition-for-return)
(2026-10-13) and [review-2026-08-followups](#review-2026-08-followups) is
closed (2026-10-14). What that review produced is in
[CHANGELOG.md](../CHANGELOG.md) `[1.9.2]`; its own document was removed once
these four items carried everything still open, and stays in git history.
After a context reset, pick an item marked `agreed` or `in progress`, then work
the **Implementation checklist** in its linked plan. The `Progress` column above
+7 -4
View File
@@ -94,7 +94,7 @@ for operators upgrading from older 2.x images.
---
## Schema at head (v2)
## Schema at head
| Table | Role |
|-------|------|
@@ -106,7 +106,7 @@ for operators upgrading from older 2.x images.
| `applications` | SASL applications per domain, with the client-IP restriction (`auth_ip_restrict`, `auth_allowed_ips`) |
| `application_addresses` | Explicit From addresses (`list` mode) |
| `rate_limits` | Level-2 limits per domain / application, manual or auto |
| `send_log` | Delivery journal |
| `send_log` | Delivery journal. `reason` says why a row was refused (`limit`, `client`, `from`) or that the sweep closed it (`reconcile`, `reconcile-contradicted`), `header_from` what the From: line of a message refused for it said (v6), `refusal_line`, `client_ip` and `recovered` (v9), and `domain_id` (v8: the row belongs to a domain by id, `ON DELETE SET NULL`) |
| `logtail_state` | Log-tailer read position |
| `inbound_domains` | Inbound relay domains |
| `inbound_transports` | Upstream host / port / TLS per inbound domain |
@@ -114,9 +114,12 @@ for operators upgrading from older 2.x images.
| `user_inbound_domains` | Inbound domains assigned to a `domain` user — granted separately from the outbound ones |
| `dmarc_reports` | Parsed aggregate report summaries |
| `dmarc_report_records` | Per-source rows inside a report |
| `inbound_spam_log` | Inbound antispam journal: one row per recipient of a message offered on port 25, with the decision (`accept`, `reject`, `tempfail`, `quarantine`) and who made it (`rspamd`, `postfix`, `selfpost`). Metadata only (v2) |
| `inbound_spam_log` | Inbound antispam journal: one row per recipient of a message offered on port 25, with the decision (`accept`, `reject`, `tempfail`, `quarantine`) and who made it (`rspamd`, `postfix`, `selfpost`). Metadata only. `inbound_domain_id` (v8) ties a row to its domain by id (v2) |
| `inbound_spam_list` | Allow / deny entries for the external filter (`address`, `domain`, `ip`), instance-wide; unique per list (v2) |
| `notify_state` | What the panel has already written to its users about, one row per subject (`dns:<domain>`, `cert`, `queue`, `limit:<domain>`), so that each thing is said once and a restart says nothing again (v4) |
| `sender_check_days` | What the sender check costs, a row per UTC day that has something to say: seconds the panel did not answer while Postfix ran, connections turned away (`deferred`), messages deferred mid-message (`messages`), and whether the day's downtime is an upper bound (`estimated`). Kept a year (v6, v9) |
| `delivery_log_days` | What the reconcile sweep and the log tailer did, a row per UTC day, kept a year. Read off a copy of the database; no page shows it (v7) |
| `notify_addressees` | Who a subject's notification is owed to and whether each was handed it; goes with the subject's `notify_state` row (v11) |
| `send_log_lines` | The `mail.log` lines Postfix wrote under a queue id, with the line's own time; what a message's page shows once the log has rotated. No reference to `send_log`: a line belongs to a row by queue id and time, and goes when no row carries its id (v3) |
`TestBaselineSchema` (`internal/store/schema_test.go`) pins every table, column
@@ -150,7 +153,7 @@ for the domain. Nothing refers to a user: deleting one changes no domain.
1. Add `internal/store/migrations/00NN_short_name.sql` (next number).
2. Update the table in [Migration chain](#migration-chain), the snapshot at the
top, and the affected rows of [Schema at head](#schema-at-head-v2).
top, and the affected rows of [Schema at head](#schema-at-head).
3. Update `TestBaselineSchema` if tables, columns or indexes change, and add a
test that opens a database at the previous version and migrates it.
4. A *data* migration states in a comment which 2.x versions it is written for.