From 86665f08e6d063041b73695e5ff84c293051a1b5 Mon Sep 17 00:00:00 2001 From: Mix Date: Wed, 14 Oct 2026 07:29:13 +0300 Subject: [PATCH] audit: decisions of 2026-10-13/14 on what 2.4.0 left undone 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 --- docs/audit-2026-10-12.md | 221 +++++++++++++++++++++++++----- docs/plans/from-header-binding.md | 37 +++-- docs/roadmap.md | 99 +++++++++++-- 3 files changed, 306 insertions(+), 51 deletions(-) diff --git a/docs/audit-2026-10-12.md b/docs/audit-2026-10-12.md index 685473a..0392674 100644 --- a/docs/audit-2026-10-12.md +++ b/docs/audit-2026-10-12.md @@ -520,12 +520,14 @@ symbol prefixes and key-file format match the Go side byte for byte. ## 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 → 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`): -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: 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. @@ -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 (`golang:1.26-bookworm` floats, so a rebuild suffices). — **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` @@ -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 tailer's blindness and stay on the list whatever the sweep's fate; their 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; test with the probed HELO and quoted-sender lines. — **Opus** - [ ] **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 test with two goroutines. — **Opus** - [ ] **`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, window and count through the same parsers; refuse the file with the 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** - [ ] Docs: security.md § Roles (the guard) and the struck accepted risk, guide § 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 "it names another sender of this server"; the refused message's page still shows the `From:` line as written (`header_from`), so the operator loses nothing. Test pins the reply text; e2e's sender-check negatives 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; `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)` — on the id column stage 2 adds, replacing `idx_send_log_domain`; 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** - [ ] 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 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 `components.go`, flip the test); the recalc button posts only what it 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, and run `apps.Resync()` anyway. — **Opus** - [ ] **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 day): the same control, drawn in the mockup first. Both filters survive 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` ([handlers_status.go](../internal/web/handlers/handlers_status.go)) returns 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) - [ ] Every item under *Doc drift* above, file by file. — **Sonnet** -- [ ] roadmap.md: `template-data-typing` is struck already (owner, 2026-10-13; - its last map is a step of stage 4); `structured-logging` is dropped (owner, - 2026-10-13, roadmap § Dropped) - nothing to reword; in - `review-2026-08-followups` mark `parsePage` and `secretfile` rows as - addressed by stages 3-4, keep the `offset.go` inode row (still open: - fingerprint of the first 512 bytes only), and the `dnscheck` fan-out row - now points at M14. Add the open tests list above as the test row's new - content. — **Sonnet** +- [ ] 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`, `machine.go`, `inboundspam.go`, `logtail.go`, supervisord.conf, entrypoint.sh, docker-compose.yml). — **Haiku** diff --git a/docs/plans/from-header-binding.md b/docs/plans/from-header-binding.md index ec132a2..ba5819d 100644 --- a/docs/plans/from-header-binding.md +++ b/docs/plans/from-header-binding.md @@ -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 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 - [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 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. @@ -183,32 +183,51 @@ silence; each line says what is missing and what it would take. - **"Messages deferred" counts connections Postfix turned away.** Postfix 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 - 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 - **"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 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**: - 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 - 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 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 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 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 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** (`wrong From`, `wrong client`, `rate-limited`), and `rate-limited` is grey 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**; - 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 @@ -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 - 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 - 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 cannot sign in there. They were rendered from the fixtures and screenshotted, the polling and the phone order read from the code and diff --git a/docs/roadmap.md b/docs/roadmap.md index 7cfeac8..df1f60b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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) | | 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) | — | — | -| 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) | | 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) | — | — | +| 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) | — | — | | 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, 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 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 | |---|---| | 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 | No direct tests for the apps / domains / users / dmarc handlers; `authz_test` covers them only indirectly | -| 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 | -| Edge case | `internal/secretfile` accepts an empty password at library level — guard it, or state that validation is the caller's job | -| Edge case | The log fingerprint in [logtail/offset.go](../internal/logtail/offset.go) can collide across a rotation — include the inode | -| Edge case | `parsePage` in [handlers_monitor.go](../internal/web/handlers/handlers_monitor.go) does not clamp to `lastPage` | -| 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 | +| 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~~ — 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~~ — 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~~ — struck: audit-2026-10-12 stage 3 (M5) | +| 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` does not clamp to `lastPage`~~ — struck: audit-2026-10-12 stage 4 | +| 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 | ~~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` | @@ -273,6 +274,8 @@ apart from the backup re-auth prompt. Anything here that grows past an hour becomes its own roadmap item instead. **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 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 **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. **Returns if:** the log goes to an outside collector, or it has to be 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: 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