audit: decisions of 2026-10-13/14 on what 2.4.0 left undone
test / test (push) Canceled after 0s

Agreed with the owner, one question at a time; documents only, no code.

From from-header-binding's Not done list, each a step of the audit plan:
the Sender check column says "Connections turned away"; a stand test of
which key OpenDKIM signs with; Health connects to a milter socket instead
of stat-ing it; the cards layout orders a phone by the box's number;
a refusal the panel did not write is recovered from mail.log; a log
rotated while the panel was down is read to its end; the deferred counter
keeps a place in the log; downtime is counted when supervisord is silent.
"Today, UTC" became the roadmap item local-time (agreed): one instance
zone, no labels beside a time, days counted in that zone, with the
conditions for per-user zones and offsets, and the trap of mail.log's own
stamps written down.

The six steps on the log tailer are split off stage 1 into stage 2b, a
cut of its own (2.5.1); stages 3 and 4 move to 2.5.2 and 2.5.3.

Also: negative e2e cases pass only on their own refusal (stage 0);
notifications are owed per recipient (stage 4); a DNS outage is unknown,
not wrong (M14, M15 had no step); DMARC reports tested where they come in.
review-2026-08-followups is closed, its rows placed in stages or dropped
with a reason. Certificate warnings by lifetime: left as is, with the
condition for return.

The audit's checklist is 61 lines (was 48).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-14 07:29:13 +03:00
co-authored by Claude Opus 5.5
parent c3d99b1f15
commit 86665f08e6
3 changed files with 306 additions and 51 deletions
+187 -34
View File
@@ -520,12 +520,14 @@ symbol prefixes and key-file format match the Go side byte for byte.
## Plan ## Plan
Six stages, each a self-contained cut, each small enough for one sitting, Seven stages (six, and stage 2b split off stage 1 on 2026-10-14), each a
self-contained cut, each small enough for one sitting,
with model routing per development.md (security/infra → Opus, UI/docs → with model routing per development.md (security/infra → Opus, UI/docs →
Sonnet). "Done when" is the test that proves it, not the edit. Sonnet). "Done when" is the test that proves it, not the edit.
**Agreed with the owner on 2026-10-12** (roadmap row `audit-2026-10-12`): **Agreed with the owner on 2026-10-12** (roadmap row `audit-2026-10-12`):
stage 0 first, then stages 1 and 2 as one cut, the rest after in any order. stage 0 first, then stages 1 and 2 as one cut, the rest after in any order
(stage 2b after `2.5.0`, before the others).
This is the **active plan** in the sense of development.md § Plan checklists: This is the **active plan** in the sense of development.md § Plan checklists:
the `- [ ]` lines below are the implementation checklist, a line is ticked in the `- [ ]` lines below are the implementation checklist, a line is ticked in
the commit that finishes it, and the roadmap's Progress column counts them. the commit that finishes it, and the roadmap's Progress column counts them.
@@ -560,6 +562,16 @@ has already run is never edited — the next file is added; the published
development.md that the image is rebuilt after each Go patch release development.md that the image is rebuilt after each Go patch release
(`golang:1.26-bookworm` floats, so a rebuild suffices). — **Haiku** (`golang:1.26-bookworm` floats, so a rebuild suffices). — **Haiku**
- [ ] The four `staticcheck` items. — **Haiku** - [ ] The four `staticcheck` items. — **Haiku**
- [ ] **A negative e2e case passes only on the refusal it is about** (owner,
2026-10-14; seen, not found by the audit). `negative_level1_ratelimit`
sends up to sixty messages and waits for one to be refused; it once
passed on "limit tripped on attempt 1/60" when the connection was reset
because Postfix was still starting. The case counts as the limit only the
reply code of the limit, and only after at least one message was
accepted; a reset, a timeout or any other reply fails it and says what
came. Then every other negative case of the suite is read for the same
mistake - any failure taken for the expected refusal - and held to its
own reply code. - **Opus**
### Stage 1 — mail that is shown wrong (H3, H2, M12, M13) → `2.5.0` ### Stage 1 — mail that is shown wrong (H3, H2, M12, M13) → `2.5.0`
@@ -597,25 +609,6 @@ has already run is never edited — the next file is added; the published
grown for a tick and the new file has a line). Both are fixes to the grown for a tick and the new file has a line). Both are fixes to the
tailer's blindness and stay on the list whatever the sweep's fate; their tailer's blindness and stay on the list whatever the sweep's fate; their
priority follows `tailer_blind_seconds`. priority follows `tailer_blind_seconds`.
- [ ] **A refused message's page shows what Postfix wrote** (owner,
2026-10-13: "the log box is required" - the box is the reason
delivery-log-storage was built, and its absence from the picture he
approved for 2.4.0 was an oversight, not a decision). Today every
`rejected` row - `wrong From`, `wrong client`, `rate-limited` - has no
queue id, and its page shows the box with one sentence,
"This message never reached the queue, so Postfix wrote no delivery lines
for it" ([handlers_monitor.go](../internal/web/handlers/handlers_monitor.go)).
The sentence is untrue: Postfix wrote a line for each (`milter-reject:
END-OF-MESSAGE ... 5.7.1 From: ...` from cleanup, `NOQUEUE: reject: ...`
from smtpd). The tailer attaches that line to the row it belongs to, by
client address, time and the addresses in the line, and stores it in
`send_log_lines` like any other; the page shows it. First on the stand:
read `mail.log` for the three kinds of refusal and write down what
identifies each line. Then the mockup: the refused variant of `out-message`
gets the box with the line in it (`design:` commit), then the code. The
sentence stays only for a row whose line was truly never written or is
older than this change, and says that. Done when the e2e shows the stored
line on the page of each of the three refusals. - **Opus**
- [ ] **Anchor `deliveryRe`** at the head of the line like its two siblings; - [ ] **Anchor `deliveryRe`** at the head of the line like its two siblings;
test with the probed HELO and quoted-sender lines. — **Opus** test with the probed HELO and quoted-sender lines. — **Opus**
- [ ] **TLS re-copy** (H2): `postfix-cert-reload.sh` compares the mounted files - [ ] **TLS re-copy** (H2): `postfix-cert-reload.sh` compares the mounted files
@@ -642,7 +635,8 @@ has already run is never edited — the next file is added; the published
`tx`, count and act inside it; `CreateGlobalUser` likewise. A concurrency `tx`, count and act inside it; `CreateGlobalUser` likewise. A concurrency
test with two goroutines. — **Opus** test with two goroutines. — **Opus**
- [ ] **`submitUserUpdate` in one transaction** (store method that takes the - [ ] **`submitUserUpdate` in one transaction** (store method that takes the
whole change). — **Opus** whole change). The users pages get direct handler tests while they are
rewired - a row of review-2026-08-followups, placed here 2026-10-14. — **Opus**
- [ ] **Import validates like the form**: route `DMARCRua`, IPs, limit mode, - [ ] **Import validates like the form**: route `DMARCRua`, IPs, limit mode,
window and count through the same parsers; refuse the file with the window and count through the same parsers; refuse the file with the
validator's text. Tests per field. — **Opus** validator's text. Tests per field. — **Opus**
@@ -662,17 +656,121 @@ has already run is never edited — the next file is added; the published
are still in the global log. — **Opus** are still in the global log. — **Opus**
- [ ] Docs: security.md § Roles (the guard) and the struck accepted risk, guide - [ ] Docs: security.md § Roles (the guard) and the struck accepted risk, guide
§ Domain export, schema-migrations.md. — **Sonnet** § Domain export, schema-migrations.md. — **Sonnet**
- [ ] **Which key OpenDKIM signs with, seen and not reasoned** (owner,
2026-10-13, from the Not verified list of from-header-binding). The check
and OpenDKIM read `From:` with two parsers; that they agree was argued
from the check's two rules, never run. An e2e case sends, as one
application, the `From:` lines the check passes - quoted strings,
comments, folded lines, groups, escapes, encoded words, an address
extension - and reads the `d=` of the signature on what arrives: any
domain but the application's fails the test. The lines live in one table
shared with the check's unit test, so a line added to one is run by the
other. A disagreement found is fixed in the check before the cut, and
security.md § Mail path says the claim is tested. - **Opus**
- [ ] **The 550 reply names no other domain** (L8): `sender.go:334` stops at - [ ] **The 550 reply names no other domain** (L8): `sender.go:334` stops at
"it names another sender of this server"; the refused message's page "it names another sender of this server"; the refused message's page
still shows the `From:` line as written (`header_from`), so the operator still shows the `From:` line as written (`header_from`), so the operator
loses nothing. Test pins the reply text; e2e's sender-check negatives loses nothing. Test pins the reply text; e2e's sender-check negatives
assert the reply does not contain the other tenant's name. — **Opus** assert the reply does not contain the other tenant's name. — **Opus**
### Stage 3 — persistence and restore (M1–M5, L1–L4, L7) → `2.5.1` ### Stage 2b — the log tailer, and what the panel shows of a refusal → `2.5.1`
Split off stage 1 on 2026-10-14 at the owner's word: the six steps below were
agreed on 2026-10-13/14, after the plan, and had made stages 1 and 2 a cut of
twenty-one steps. Stages 1 and 2 ship as `2.5.0` in their first size; this
stage follows as a cut of its own, with its own reviews. Five of the six
change one thing, the tailer and the state it saves, and are done in this
order - each stands on the one before.
- [ ] **A log rotated while the panel was down is read to its end** (owner,
2026-10-13; the Not done list of from-header-binding had it as "deferrals
are lost", and it is wider). At start, a saved offset whose fingerprint
is not the current file's means "start on the new file"
([offset.go](../internal/logtail/offset.go)); what Postfix wrote between
the panel's stop and the rotation is never read - delivery statuses,
stored delivery lines, refusals, inbound journal rows, the deferred
counter, and the recovery of unwritten refusals above. The tailer first
opens the rotated file (`mail.log.1`), checks by fingerprint that it is
the one the offset belongs to, reads it from the offset to its end, then
goes on to the new file. The fingerprint gains the inode, which closes
the roadmap's review-2026-08-followups row on collisions. Not found, or
rotated twice: logged, with from when to when nothing was read. A test
that stops the tailer, appends, rotates, appends, starts it, and finds
both halves once. - **Opus**
- [ ] **The deferred counter remembers a place in the log, not a second**
(owner, 2026-10-14, from the Not done list of from-header-binding; he
chose it over leaving the case written down). After an unclean stop the
tailer re-reads lines it had read, and the counter passes over every
warning no later than the last one it counted
([logtail/sendercheck.go](../internal/logtail/sendercheck.go), `last`);
`mail.log` stamps whole seconds, so a warning of that very second, later
in the file, is lost. The count and the place in the log it has reached
are saved in one transaction, and what to pass over is decided by the
place. Done after the rotation step above, on the same saved state - the
place must stay true across a rotated file. A test that stops the tailer
between two warnings of one second and finds both counted, once each.
- **Opus**
- [ ] **A refused message's page shows what Postfix wrote** (owner,
2026-10-13: "the log box is required" - the box is the reason
delivery-log-storage was built, and its absence from the picture he
approved for 2.4.0 was an oversight, not a decision). Today every
`rejected` row - `wrong From`, `wrong client`, `rate-limited` - has no
queue id, and its page shows the box with one sentence,
"This message never reached the queue, so Postfix wrote no delivery lines
for it" ([handlers_monitor.go](../internal/web/handlers/handlers_monitor.go)).
The sentence is untrue: Postfix wrote a line for each (`milter-reject:
END-OF-MESSAGE ... 5.7.1 From: ...` from cleanup, `NOQUEUE: reject: ...`
from smtpd). The tailer attaches that line to the row it belongs to, by
client address, time and the addresses in the line, and stores it in
`send_log_lines` like any other; the page shows it. First on the stand:
read `mail.log` for the three kinds of refusal and write down what
identifies each line. Then the mockup: the refused variant of `out-message`
gets the box with the line in it (`design:` commit), then the code. The
sentence stays only for a row whose line was truly never written or is
older than this change, and says that. Done when the e2e shows the stored
line on the page of each of the three refusals. - **Opus**
- [ ] **A refusal the panel did not get to write is written from
`mail.log`** (owner, 2026-10-13, from the Not done list of
from-header-binding; built on the step above). The check answers 550 and
writes the row a moment later, through a buffer; a panel that stops in
between - a rollout, a crash - leaves an application that was refused
and a log that does not know it. When the tailer meets a refusal line
that has no row, it makes the row from the line (client, sender,
recipient, reason), marked as recovered. The tailer resumes from its
saved offset, so this covers a kill as well as a clean stop - which a
flush of the buffer at shutdown would not. Done when the e2e refuses a
message, kills the panel before the row is written, and finds the row
after the restart, once. - **Opus**
- [ ] **Downtime is counted when supervisord does not answer at start**
(owner, 2026-10-13, from the Not done list of from-header-binding). The
panel asks supervisord once since when Postfix runs
([sendercheck.go](../cmd/panel/sendercheck.go), `postfixStarted`); an
error is logged and that start's downtime is not counted at all - the
number the owner will decide by reads low. The question is repeated for
about thirty seconds. Still no answer: the downtime is taken from the
alive mark alone - an upper bound, Postfix may not have been running -
and the day is marked as estimated (a column in `sender_check_days`, the
next migration), which the card shows. The mark on the card is drawn in
the mockup with the renamed column above (one `design:` commit). - **Opus**
- [ ] **The Sender check card names what it counts** (owner, 2026-10-13, from
the Not done list of from-header-binding): the column *Messages deferred*
becomes *Connections turned away*. Postfix refuses from the connection on
while the check does not answer, before any message is offered, so a
client that connects three times with one message is three, and a monitor
that only connects is one; messages cannot be counted. The mockup `health`
and help topic `sender-check` first (`design:` commit), then the page and
`view/help.go`, word for word. - **Sonnet**
- [ ] Docs: architecture § Log tailer (the rotated file, the saved place, a
refusal's line and a recovered row), guide § Outbound log and § Health,
schema-migrations.md for the mark on `sender_check_days`. — **Sonnet**
### Stage 3 — persistence and restore (M1–M5, L1–L4, L7) → `2.5.2`
- [ ] **Consume the manifest after the resync succeeds** (`CheckRestore` reports; - [ ] **Consume the manifest after the resync succeeds** (`CheckRestore` reports;
`run` deletes after `resyncAfterRestore` returns nil). Test: Resync fails → `run` deletes after `resyncAfterRestore` returns nil). Test: Resync fails →
manifest kept → second boot resyncs. — **Opus** manifest kept → second boot resyncs. And a corrupted or truncated archive
is refused on restore and on import with its message, nothing half
written (a row of review-2026-08-followups, placed here 2026-10-14). — **Opus**
- [ ] **Indexes**: `idx_send_log_domain_created_at (domain_id, created_at)` — - [ ] **Indexes**: `idx_send_log_domain_created_at (domain_id, created_at)` —
on the id column stage 2 adds, replacing `idx_send_log_domain`; on the id column stage 2 adds, replacing `idx_send_log_domain`;
partial `idx_send_log_queued (created_at) WHERE status = 'queued'`; partial `idx_send_log_queued (created_at) WHERE status = 'queued'`;
@@ -691,7 +789,7 @@ has already run is never edited — the next file is added; the published
package. — **Opus** package. — **Opus**
- [ ] Docs: security.md § Backup; guide § Backup. — **Sonnet** - [ ] Docs: security.md § Backup; guide § Backup. — **Sonnet**
### Stage 4 — panel behaviour (M8, M11, L10–L19) → `2.5.2` ### Stage 4 — panel behaviour (M8, M11, L10–L19) → `2.5.3`
- [ ] Login: dummy bcrypt on unknown user; a test that measures both paths within - [ ] Login: dummy bcrypt on unknown user; a test that measures both paths within
a tolerance. Account password change gets its own limiter bucket. Login a tolerance. Account password change gets its own limiter bucket. Login
@@ -705,7 +803,9 @@ has already run is never edited — the next file is added; the published
- [ ] `parsePage` clamp; `NaN` refused; `rate-limited` tag red (add the status to - [ ] `parsePage` clamp; `NaN` refused; `rate-limited` tag red (add the status to
`components.go`, flip the test); the recalc button posts only what it `components.go`, flip the test); the recalc button posts only what it
needs or warns; `HandleReload` either covers DMARC maps and filter lists or needs or warns; `HandleReload` either covers DMARC maps and filter lists or
the flash and the two comments say what it covers. — **Sonnet** the flash and the two comments say what it covers. The log's paging is
tested at its edges: first page, last, past the last, an empty log (a row
of review-2026-08-followups, placed here 2026-10-14). — **Sonnet**
- [ ] `domain.Service.Delete`: say the domain is deleted when a later step fails, - [ ] `domain.Service.Delete`: say the domain is deleted when a later step fails,
and run `apps.Resync()` anyway. — **Opus** and run `apps.Resync()` anyway. — **Opus**
- [ ] **A status filter and a day filter in the Outbound log** (owner, 2026-10-13, from the - [ ] **A status filter and a day filter in the Outbound log** (owner, 2026-10-13, from the
@@ -720,6 +820,61 @@ has already run is never edited — the next file is added; the published
commit). And a filter by day, as the Inbound log has it (owner, the same commit). And a filter by day, as the Inbound log has it (owner, the same
day): the same control, drawn in the mockup first. Both filters survive day): the same control, drawn in the mockup first. Both filters survive
paging and the rows' polling. - **Sonnet** paging and the rows' polling. - **Sonnet**
- [ ] **Milter sockets: "Listening" is asked of the socket** (owner,
2026-10-13, from the Not done list of from-header-binding).
`health.CheckSocket` ([socket.go](../internal/health/socket.go)) stats the
file and writes "Listening"; a process that died and left its socket
reads ok while mail is deferred or goes out unsigned. It connects and
closes instead, and a refused connection is an error that says nobody
answers. Find out first, on the stand: whether the panel's user may
connect to OpenDKIM's socket, and whether a connection closed without a
byte ends the sender check (security.md § Accepted risks: the milter
library panics on an empty packet) - if it does, that is fixed first, in
this step. A test with a socket file nobody listens on. - **Opus**
- [ ] **The `cards` layout takes its phone order from the box's number**
(owner, 2026-10-13, from the Not done list of from-header-binding). The
order on a phone is written in `panel.css` for four boxes to a stack
(`.sp-cards > .column:first-child > .box:nth-child(1..4)`); a fifth box
gets no rule and lands out of place with nothing failing. The box partial
sets an order class from its own number (a class, not a style: the CSP
allows no inline styles), the stylesheet orders by that class, and a test
checks that the numbers of a rendered `cards` page run in order. A change
to a component: the mockup's components page and `theme.css` first
(`design:` commit). - **Sonnet**
- [ ] **A notification is owed to each recipient, not to the subject**
(owner, 2026-10-14; from the reviews of panel-notifications, not found by
the audit). `Notifier.send` ([notify.go](../internal/notify/notify.go))
counts a subject as said when one recipient's copy was handed to Postfix;
a recipient whose copy failed, or was "held back" by the hourly ceiling,
is never told. Unseen while the owner is the only recipient; the first
domain administrator makes it "the global administrator knows the
domain's DNS broke, its administrator does not". The state of a subject
is kept per recipient (the next migration), and a later check sends to
those not yet told - so the ceiling holds a copy back instead of dropping
it. A user who gains the right to hear of a subject while it is already
open is not mailed about it. "Cleared" goes to those who were told of the
problem. Tests: one copy fails and goes out at the next check, once; the
ceiling; a user added mid-subject. - **Opus**
- [ ] **A DNS outage is "unknown", not "wrong"** (M14, M15; owner,
2026-10-14 - the two findings had no step). Each resolver gets a time
budget of its own, so a silent first resolver no longer leaves the second
an expired context ([dnscheck/resolver.go](../internal/dnscheck/resolver.go));
a timeout is Unknown, not "does not resolve" or "no PTR record"
([dnscheck/server.go](../internal/dnscheck/server.go)); a failed lookup
keeps the server's addresses it had instead of emptying them, so SPF does
not go Unknown with it. Notifications: a domain's DNS is reported broken
only on a result that is wrong, never on Unknown - checked in `notify`,
and tested. Tests: a first resolver that drops packets, a PTR timeout.
architecture.md:339-340 and the `DefaultResolvers` comment say what is
true. — **Opus**
- [ ] **DMARC reports tested where they come in** (owner, 2026-10-14; rows of
review-2026-08-followups). The panel parses reports that arrive by mail
from anyone, and the `internal/dmarc` service (`service.go`,
`addresses.go`) and the DMARC handlers have no tests; no other step
touches them. Tests of the service, and of a report that is malformed,
oversized, compressed past its limit, or names a domain the instance does
not have: refused or skipped with a reason, nothing stored, the panel
alive. A finding is fixed in this step. — **Opus**
- [ ] **The last untyped page data** (owner, 2026-10-13): `healthChecks` - [ ] **The last untyped page data** (owner, 2026-10-13): `healthChecks`
([handlers_status.go](../internal/web/handlers/handlers_status.go)) returns ([handlers_status.go](../internal/web/handlers/handlers_status.go)) returns
a struct instead of `map[string]any`, so a mistyped key in `healthPage` / a struct instead of `map[string]any`, so a mistyped key in `healthPage` /
@@ -764,14 +919,12 @@ has already run is never edited — the next file is added; the published
### Stage 6 — documentation and roadmap sweep (no product code, no cut) ### Stage 6 — documentation and roadmap sweep (no product code, no cut)
- [ ] Every item under *Doc drift* above, file by file. — **Sonnet** - [ ] Every item under *Doc drift* above, file by file. — **Sonnet**
- [ ] roadmap.md: `template-data-typing` is struck already (owner, 2026-10-13; - [ ] roadmap.md: done ahead on 2026-10-13/14 with the owner -
its last map is a step of stage 4); `structured-logging` is dropped (owner, `template-data-typing` struck, `structured-logging` dropped,
2026-10-13, roadmap § Dropped) - nothing to reword; in `review-2026-08-followups` closed with each row placed in a stage or
`review-2026-08-followups` mark `parsePage` and `secretfile` rows as dropped with its reason. What is left: check that every *Test gaps* entry
addressed by stages 3-4, keep the `offset.go` inode row (still open: above is named by the step that touches its code, and add it to that step
fingerprint of the first 512 bytes only), and the `dnscheck` fan-out row where it is not. — **Sonnet**
now points at M14. Add the open tests list above as the test row's new
content. — **Sonnet**
- [ ] Comments named above (`milter.go`, `sendlog.go`, `store.go`, `schema_test.go`, - [ ] Comments named above (`milter.go`, `sendlog.go`, `store.go`, `schema_test.go`,
`machine.go`, `inboundspam.go`, `logtail.go`, supervisord.conf, `machine.go`, `inboundspam.go`, `logtail.go`, supervisord.conf,
entrypoint.sh, docker-compose.yml). — **Haiku** entrypoint.sh, docker-compose.yml). — **Haiku**
+29 -8
View File
@@ -174,7 +174,7 @@ silence; each line says what is missing and what it would take.
2026-10-13 (owner):** the box is required - he had overlooked its absence 2026-10-13 (owner):** the box is required - he had overlooked its absence
from the picture, it was never a decision to drop it. The tailer attaches from the picture, it was never a decision to drop it. The tailer attaches
the line to the row, for all three kinds of refusal; a step of the line to the row, for all three kinds of refusal; a step of
[audit-2026-10-12](../audit-2026-10-12.md) stage 1. [audit-2026-10-12](../audit-2026-10-12.md) stage 2b.
- **The Outbound log has no status filter.** The mockup draws one; the built - **The Outbound log has no status filter.** The mockup draws one; the built
log filters by domain and application. With three kinds of refusal now log filters by domain and application. With three kinds of refusal now
told apart by tag, a filter by status and reason would be worth having. told apart by tag, a filter by status and reason would be worth having.
@@ -183,32 +183,51 @@ silence; each line says what is missing and what it would take.
- **"Messages deferred" counts connections Postfix turned away.** Postfix - **"Messages deferred" counts connections Postfix turned away.** Postfix
refuses from the connection on, before a message is offered, so a monitor refuses from the connection on, before a message is offered, so a monitor
that only connects during an outage is counted. The guide says so; the that only connects during an outage is counted. The guide says so; the
card's column head and the help topic still say "messages". card's column head and the help topic still say "messages". **Decided
2026-10-13 (owner):** the column is renamed *Connections turned away*; a
step of [audit-2026-10-12](../audit-2026-10-12.md) stage 2b.
### Known and small ### Known and small
- **"Today" on the card is the UTC day**, and the card does not say so. - **"Today" on the card is the UTC day**, and the card does not say so.
**Decided 2026-10-13 (owner):** not a label - the local day; it became the
roadmap item `local-time`, for the whole panel.
- **A deferral in the very second of the last one counted before an unclean - **A deferral in the very second of the last one counted before an unclean
stop is passed over** at the restart: `mail.log` stamps whole seconds. stop is passed over** at the restart: `mail.log` stamps whole seconds.
Chosen over counting a stretch of the log twice. Chosen over counting a stretch of the log twice. **Decided 2026-10-14
(owner):** counted exactly - the counter keeps a place in the log, saved
with the count; a step of [audit-2026-10-12](../audit-2026-10-12.md)
stage 2b.
- **Deferrals are lost when `mail.log` is rotated while the panel is down**: - **Deferrals are lost when `mail.log` is rotated while the panel is down**:
the tailer starts on the new file. the tailer starts on the new file. **Decided 2026-10-13 (owner):** the
tailer reads the rotated file to its end first - not the deferrals alone,
everything the panel takes from the log; a step of
[audit-2026-10-12](../audit-2026-10-12.md) stage 2b.
- **A start at which `supervisorctl` fails counts no downtime**; it is - **A start at which `supervisorctl` fails counts no downtime**; it is
logged. logged. **Decided 2026-10-13 (owner):** the question is repeated, and
without an answer the downtime is counted from the alive mark and the day
marked as estimated; a step of
[audit-2026-10-12](../audit-2026-10-12.md) stage 2b.
- **Logins are compared as written**, Postfix compares them without regard - **Logins are compared as written**, Postfix compares them without regard
to case. The panel writes logins one way, so nothing differs today. to case. The panel writes logins one way, so nothing differs today.
- **The phone order of the `cards` layout is written for four boxes to a - **The phone order of the `cards` layout is written for four boxes to a
stack** and takes every child of a stack for a box (said at the partial). stack** and takes every child of a stack for a box (said at the partial).
The rule the technical review suggested instead - the order from the The rule the technical review suggested instead - the order from the
box's own number - changes the component and would be drawn first. box's own number - changes the component and would be drawn first.
**Decided 2026-10-13 (owner):** that rule is built; a step of
[audit-2026-10-12](../audit-2026-10-12.md) stage 4.
- **Health's socket check looks at the file, not at who listens**: a socket - **Health's socket check looks at the file, not at who listens**: a socket
nobody answers on would read "ok". The start now removes stale sockets, nobody answers on would read "ok". The start now removes stale sockets,
which was the one way to get such a file. which was the one way to get such a file. **Decided 2026-10-13 (owner):**
the check connects to the socket; a step of
[audit-2026-10-12](../audit-2026-10-12.md) stage 4.
- **The kit page's table of status tags does not list the new ones** - **The kit page's table of status tags does not list the new ones**
(`wrong From`, `wrong client`, `rate-limited`), and `rate-limited` is grey (`wrong From`, `wrong client`, `rate-limited`), and `rate-limited` is grey
beside two red tags - as the mockup has it, not as a decision. beside two red tags - as the mockup has it, not as a decision.
- **Refusal rows still in the buffer when the panel stops are not written**; - **Refusal rows still in the buffer when the panel stops are not written**;
the refusals themselves were answered. the refusals themselves were answered. **Decided 2026-10-13 (owner):**
the tailer writes such a row from the refusal's line in `mail.log`; a step
of [audit-2026-10-12](../audit-2026-10-12.md) stage 2b.
### Accepted, in security.md ### Accepted, in security.md
@@ -227,7 +246,9 @@ silence; each line says what is missing and what it would take.
parser is not in this repository. The two rules that do not depend on it - parser is not in this repository. The two rules that do not depend on it -
no other signing name after any `@`, one `@` outside quotes - are what the no other signing name after any `@`, one `@` outside quotes - are what the
claim rests on. A test that feeds the stand's OpenDKIM the lines the check claim rests on. A test that feeds the stand's OpenDKIM the lines the check
passes and reads which key it signs with would settle it. passes and reads which key it signs with would settle it. **Decided
2026-10-13 (owner):** that test is built; a step of
[audit-2026-10-12](../audit-2026-10-12.md) stage 2.
- **The pages were not looked at in a browser on the deployed panel**: I - **The pages were not looked at in a browser on the deployed panel**: I
cannot sign in there. They were rendered from the fixtures and cannot sign in there. They were rendered from the fixtures and
screenshotted, the polling and the phone order read from the code and screenshotted, the polling and the phone order read from the code and
+90 -9
View File
@@ -33,15 +33,16 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| 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) | | 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) |
| origin-strict | Refuse a state-changing request that carries neither `Sec-Fetch-Site` nor `Origin` (was csrf-tokens) | agreed (2026-10-13) | — | — | | origin-strict | Refuse a state-changing request that carries neither `Sec-Fetch-Site` nor `Origin` (was csrf-tokens) | agreed (2026-10-13) | — | — |
| backup-reauth | Ask for the account password before a backup or a domain export is downloaded | agreed (2026-10-13) | — | — | | backup-reauth | Ask for the account password before a backup or a domain export is downloaded | agreed (2026-10-13) | — | — |
| review-2026-08-followups | Remaining findings of the 2026-08-19 code review | candidate | — | — | | review-2026-08-followups | Remaining findings of the 2026-08-19 code review | closed 2026-10-14 (rows placed in [audit-2026-10-12](audit-2026-10-12.md)) | — | — |
| panel-notifications | E-mail notifications about important events | shipped `2.3.0` | 10/10 | [plans/panel-notifications.md](plans/panel-notifications.md) | | panel-notifications | E-mail notifications about important events | shipped `2.3.0` | 10/10 | [plans/panel-notifications.md](plans/panel-notifications.md) |
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | shipped `2.2.0` | 8/8 | [plans/delivery-log-storage.md](plans/delivery-log-storage.md) | | delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | shipped `2.2.0` | 8/8 | [plans/delivery-log-storage.md](plans/delivery-log-storage.md) |
| password-reset | Password reset by e-mail | agreed (2026-10-13) | — | — | | password-reset | Password reset by e-mail | agreed (2026-10-13) | — | — |
| local-time | The panel shows time, and counts its days, in a time zone other than UTC | agreed (2026-10-13; design questions open) | — | — |
| phone-desktop-mode | A phone browser in "desktop site" mode gets the phone navigation | agreed (2026-10-13: after stages 1 and 2 of audit-2026-10-12) | — | — | | phone-desktop-mode | A phone browser in "desktop site" mode gets the phone navigation | agreed (2026-10-13: after stages 1 and 2 of audit-2026-10-12) | — | — |
| 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) | | 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) | | 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) | | 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, six stages | agreed (2026-10-12: stage 0 first, then 1+2; the rest after) | 0/48 | [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 | agreed (2026-10-12: stage 0 first, then 1+2; the rest after) | 0/61 | [audit-2026-10-12.md](audit-2026-10-12.md) |
**Recommended order** (not binding; owner, 2026-10-09: feature order follows **Recommended order** (not binding; owner, 2026-10-09: feature order follows
development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and
@@ -257,13 +258,13 @@ is in the table below; the rest of it shipped in
| Area | Item | | Area | Item |
|---|---| |---|---|
| Security | ~~Re-authenticate (ask for the current password) before a backup download~~ — struck: agreed on 2026-10-13 as an item of its own, [backup-reauth](#backup-reauth) | | Security | ~~Re-authenticate (ask for the current password) before a backup download~~ — struck: agreed on 2026-10-13 as an item of its own, [backup-reauth](#backup-reauth) |
| Tests | The `internal/dmarc` service layer (`service.go`, `addresses.go`) has no tests | | Tests | ~~The `internal/dmarc` service layer (`service.go`, `addresses.go`) has no tests~~ — struck: a step of audit-2026-10-12 stage 4 (DMARC reports tested where they come in) |
| Tests | No direct tests for the apps / domains / users / dmarc handlers; `authz_test` covers them only indirectly | | Tests | ~~No direct tests for the apps / domains / users / dmarc handlers; `authz_test` covers them only indirectly~~ — struck: users with stage 2 (`submitUserUpdate`), DMARC with stage 4; apps and domains get theirs when a step changes them - a test without a change finds nothing the e2e does not |
| Tests | Nothing covers error page templates, a corrupted backup archive, malformed or oversized DMARC XML, delivery-log pagination edges, concurrent store operations, or zero-traffic `store` stats | | Tests | ~~Nothing covers error page templates, a corrupted backup archive, malformed or oversized DMARC XML, delivery-log pagination edges, concurrent store operations, or zero-traffic `store` stats~~ — struck: the corrupted archive goes to stage 3, DMARC XML to stage 4, paging edges to stage 4 (`parsePage`), concurrency of the last global to stage 2; error templates, other concurrency and zero-traffic stats are dropped (owner, 2026-10-14) - code no step changes, and a test kept for its own sake finds nothing |
| Edge case | `internal/secretfile` accepts an empty password at library level — guard it, or state that validation is the caller's job | | Edge case | ~~`internal/secretfile` accepts an empty password at library level~~ — struck: audit-2026-10-12 stage 3 (M5) |
| Edge case | The log fingerprint in [logtail/offset.go](../internal/logtail/offset.go) can collide across a rotation — include the inode | | Edge case | ~~The log fingerprint in `logtail/offset.go` can collide across a rotation — include the inode~~ — struck: agreed on 2026-10-13 as part of a step of [audit-2026-10-12](audit-2026-10-12.md) stage 2b (a log rotated while the panel was down is read to its end) |
| Edge case | `parsePage` in [handlers_monitor.go](../internal/web/handlers/handlers_monitor.go) does not clamp to `lastPage` | | Edge case | ~~`parsePage` does not clamp to `lastPage`~~ — struck: audit-2026-10-12 stage 4 |
| Edge case | [dnscheck/resolver.go](../internal/dnscheck/resolver.go) fans out four parallel queries where a fallback chain would do — change it or write down why not | | Edge case | ~~`dnscheck/resolver.go` fans out four parallel queries where a fallback chain would do~~ — struck: finding M14 of [audit-2026-10-12](audit-2026-10-12.md) |
| UI | ~~`.pair` (DMARC templates) and `.split` (every other page) are two names for one layout~~ — struck: retired by [panel-redesign](#panel-redesign); both stylesheets are gone and every page is laid out by the component kit | | UI | ~~`.pair` (DMARC templates) and `.split` (every other page) are two names for one layout~~ — struck: retired by [panel-redesign](#panel-redesign); both stylesheets are gone and every page is laid out by the component kit |
| UI | ~~Two `back_link` elements in a row in `dmarc_domain.html`~~ — struck: retired by [panel-redesign](#panel-redesign); the page is rebuilt on the kit with one breadcrumb in its head | | UI | ~~Two `back_link` elements in a row in `dmarc_domain.html`~~ — struck: retired by [panel-redesign](#panel-redesign); the page is rebuilt on the kit with one breadcrumb in its head |
| UI | ~~`initShowWhen` is not re-run after an htmx swap~~ — struck: the show/hide rules listen on the document and are applied again on `htmx:load` | | UI | ~~`initShowWhen` is not re-run after an htmx swap~~ — struck: the show/hide rules listen on the document and are applied again on `htmx:load` |
@@ -273,6 +274,8 @@ apart from the backup re-auth prompt. Anything here that grows past an hour
becomes its own roadmap item instead. becomes its own roadmap item instead.
**Done when:** each row is either implemented or struck with a written reason. **Done when:** each row is either implemented or struck with a written reason.
**Closed 2026-10-14** with the owner: every row is struck above, placed in a
stage of [audit-2026-10-12](audit-2026-10-12.md) or dropped with its reason.
**Dependencies / risks:** One finding of that review was **declined** and is **Dependencies / risks:** One finding of that review was **declined** and is
deliberately absent: `//go:build linux` for the `/proc` reader in deliberately absent: `//go:build linux` for the `/proc` reader in
@@ -412,6 +415,72 @@ current password in the same cut.
--- ---
## local-time
**Agreed with the owner on 2026-10-13.** It came from one cell: *Today* on
Health's Sender check card is the UTC day and does not say so. Offered a
label ("Today, UTC") or the local day, he chose the local day - which is a
decision about the whole panel, not one cell.
**Today:** every time the panel shows is UTC and says so (*Time, UTC* in the
logs, "UTC" after a date), and everything counted by day - the sender
check's days, the delivery-log measurement, the statistics behind the
charts, retention - is counted by the UTC day.
**Goal:** the panel shows time in a chosen time zone, and "today" is that
zone's day.
**Where the zone comes from (owner, 2026-10-13):** one setting of the
instance, *Time zone* under Server › Settings, UTC by default; it holds for
every user and for notification mail. Not the browser's zone: only a script
could read it, and the panel works without JavaScript. Not a setting of each
user yet - **that is added (the same list under Account, "as the server" by
default) when** a user of the panel lives in another zone than the one set
for the instance, which the first domain administrator who is not the owner
may bring.
**What is written beside a time (owner, 2026-10-13):** nothing. The "UTC"
labels go - the column is *Time*, a date is a date - and the zone is stated
in one place, the setting. His reason: there is nobody to send a line of the
panel to yet. **The offset is written where "UTC" is today ("Time, UTC+3",
"2026-09-21 16:40 UTC+3") when** times of the panel start to be read by
someone who does not know the instance's zone: a second user of the panel,
or lines copied out of it to another person.
**A trap this leaves, known in advance (owner asked for it to be written
down, 2026-10-13):** Server › System log, and the *Delivery log* box on a
message's page, show the lines of `mail.log` as Postfix wrote them, each with
its own stamp ("Oct 13 14:01:52"). Those stamps are the container's time -
UTC unless the container is given a `TZ` - and they are not rewritten. Once
the rest of the panel is in the instance's zone and no time carries a label,
a message that the Outbound log puts at 17:01 has lines stamped 14:01 on its
own page, with nothing on the screen to say why. The plan must close this
before the item ships, one way or another: rewrite the stamp when a line is
shown, say on those two screens which clock the lines use, or run the
container in the instance's zone (which moves `mail.log` itself and what the
tailer reads). The guide's section on time must say the same thing as the
screens.
**How a day is counted (owner, 2026-10-13):** the tables that keep a row
per day - `sender_check_days`, and `delivery_log_days` of
[audit-2026-10-12](audit-2026-10-12.md) stage 1 - key the row by the date in
the instance's zone at the moment of counting, from the release on. Rows
counted before it stay UTC days. Changing the zone later leaves the rows
already written as days of the old zone, a shift of a few hours in a
statistic; accepted - the zone is set once, and rows per hour (which would
survive a change) cost a migration for it. The charts and statistics group
the log by hour already and need nothing.
**Open, to settle at the mockups:** the trap above; which of the screens
that show a date show the year.
**Boundary:** what the panel shows and how it groups by day. `mail.log`,
the database's stored instants and the e2e stay as they are.
**Version:** `2.x` MINOR; `agreed`; the plan is written when it is taken up.
---
## phone-desktop-mode ## phone-desktop-mode
**Problem (owner, 2026-10-11):** the panel opened on a phone with the **Problem (owner, 2026-10-11):** the panel opened on a phone with the
@@ -572,6 +641,18 @@ without that it is not offered again.
behaviour, and gives a new format to everything that reads the output. behaviour, and gives a new format to everything that reads the output.
**Returns if:** the log goes to an outside collector, or it has to be **Returns if:** the log goes to an outside collector, or it has to be
filtered by level. filtered by level.
- **Certificate warnings measured by the certificate's lifetime** (left as
it is, owner, 2026-10-14). The panel warns 14 and 3 days before a
certificate expires, whatever its lifetime
([notify.go](../internal/notify/notify.go), `certFirstDays`,
`certLastDays`). A certificate issued for less than 14 days is inside the
first warning from its first hour, and each renewal of it is a new
certificate and a new message. Not reachable with the 90-day certificate
the instance has. **Returns if:** the instance takes a certificate that
lives less than 30 days. The rule then offered: the first warning at a
quarter of the lifetime left, the last at a tenth, and never earlier than
the 14 and 3 days of today - so a longer certificate, a yearly one
included, is warned about exactly as now.
- **contributing** (dropped 2026-10-13) - a `CONTRIBUTING.md` in the root: - **contributing** (dropped 2026-10-13) - a `CONTRIBUTING.md` in the root:
the dev loop, the checks before a PR, the commit protocol. Dropped because the dev loop, the checks before a PR, the commit protocol. Dropped because
there is one developer and no PRs, and everything the file would say is in there is one developer and no PRs, and everything the file would say is in