release: 1.9.4: Panel-redesign plan: component kit, stages, reorder (docs only)

Plan-only cut, owner-requested. The redesign goes first as 2.0.0 and
inbound-antispam-panel follows as 2.1.0 on the 2.0 baseline (migration
0002_inbound_spam_log.sql), built from the redesign's component kit so its
two screens are styled once. No product code changes.

Changed:
  - panel-redesign plan: Component kit section and stage 0 (partials with
    typed inputs in components.go, one panel.css, /server/components kit
    page, acceptance before any page is restyled); staged checklist (5/27);
    Health decided inside 2.0, queue pages left as old content in the new
    shell.
  - panel-redesign plan: Against resurrected pages - shrink-only
    legacyPages ratchet in the guard tests enforced by CI, the outline
    golden, the old-path test and a history-blob check, so an old template
    from git history cannot pass as a redesigned page.
  - roadmap: new candidate delivery-log-storage - the Delivery log box on a
    message page is empty for anything older than the last daily rotation
    because the lines are grepped from the current mail.log on each view
    and never stored.
  - development.md version table, image pin 1.9.4 in compose, README, guide.

Full detail: CHANGELOG.md (1.9.4).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-09 00:15:48 +03:00
co-authored by Claude Fable 5.1
parent 6bfaa518d7
commit 1e92c5b2a2
8 changed files with 226 additions and 33 deletions
+26
View File
@@ -5,6 +5,32 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [Unreleased]
## [1.9.4] - 2026-10-09
Plan-only cut, owner-requested: the redesign goes first as `2.0.0` with a
component-kit stage and guards against resurrected pages; antispam moves to
`2.1.0`; a new candidate explains the empty delivery log. No product code
changes.
### Changed
- Roadmap order (owner, 2026-10-09): the panel redesign ships first as
`2.0.0`; inbound-antispam-panel follows as `2.1.0` on the 2.0 baseline
(its migration becomes `0002_inbound_spam_log.sql`), built from the
redesign's component kit so its two screens are styled once. The redesign
plan gains a **Component kit** section and stage (partials with typed
inputs, one `panel.css`, a `/server/components` kit page, acceptance before
any page is restyled), a staged checklist (5/27), Health decided inside 2.0
and the queue pages left as old content in the new shell, and an **Against
resurrected pages** section: a shrink-only `legacyPages` ratchet in the
guard tests, the outline golden, the old-path test and a history-blob
check in CI, so an old template from git history cannot pass as a
redesigned page.
- New roadmap candidate `delivery-log-storage`: the *Delivery log* box on a
message's page is empty for anything older than the last daily rotation,
because the lines are grepped from the current `mail.log` on each view and
never stored; the item proposes keeping them with the send-log row.
## [1.9.3] - 2026-09-22 ## [1.9.3] - 2026-09-22
Panel-redesign prep, docs only: stage 1-2 mockups on all 33 screens, the Panel-redesign prep, docs only: stage 1-2 mockups on all 33 screens, the
+1 -1
View File
@@ -112,7 +112,7 @@ docker run --rm -d --name selfpost-try \
-e SELFPOST_HOSTNAME=mail.local.test \ -e SELFPOST_HOSTNAME=mail.local.test \
-e PANEL_COOKIE_SECURE=false \ -e PANEL_COOKIE_SECURE=false \
-v selfpost-try-data:/data \ -v selfpost-try-data:/data \
ghcr.io/mixeme/selfpost:1.9.3 ghcr.io/mixeme/selfpost:1.9.4
``` ```
**Get the setup URL** (pick one): **Get the setup URL** (pick one):
+1 -1
View File
@@ -21,7 +21,7 @@
services: services:
selfpost: selfpost:
image: ghcr.io/mixeme/selfpost:1.9.3 image: ghcr.io/mixeme/selfpost:1.9.4
restart: unless-stopped restart: unless-stopped
environment: environment:
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}" SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
+4 -2
View File
@@ -107,7 +107,7 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see 2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see
[§ Release image](#release-image)). [§ Release image](#release-image)).
**Planned version cuts** (from pin `1.9.3`; adjust if semver changes mid-track): **Planned version cuts** (from pin `1.9.4`; adjust if semver changes mid-track):
| Stage | ID | Cut | | Stage | ID | Cut |
|---|---|---| |---|---|---|
@@ -121,7 +121,9 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
| 8 | port-25 Postfix startup fix | `1.9.1` (PATCH) | | 8 | port-25 Postfix startup fix | `1.9.1` (PATCH) |
| 9 | 2026-08-19 code review pass | `1.9.2` (PATCH) | | 9 | 2026-08-19 code review pass | `1.9.2` (PATCH) |
| 10 | panel-redesign prep (docs only, owner-requested cut) | `1.9.3` (PATCH) | | 10 | panel-redesign prep (docs only, owner-requested cut) | `1.9.3` (PATCH) |
| 11 | inbound-antispam-panel | `1.10.0` (planned) | | 11 | panel-redesign plan: component kit, stages, reorder (docs only, owner-requested cut) | `1.9.4` (PATCH) |
| 12 | panel-redesign | `2.0.0` (planned; MAJOR, no compatibility) |
| 13 | inbound-antispam-panel | `2.1.0` (planned; was `1.10.0`, reordered 2026-10-09) |
Docs-only prep (checklists in plans, no product code) uses the same per-step Docs-only prep (checklists in plans, no product code) uses the same per-step
commit rule but **no** version cut until the next product stage ships. commit rule but **no** version cut until the next product stage ships.
+1 -1
View File
@@ -173,7 +173,7 @@ cat ./data/setup-token
#### Fixed image tag #### Fixed image tag
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`), `deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
deliberately never `:latest`. The current pin is `1.9.3`. Intermediate deliberately never `:latest`. The current pin is `1.9.4`. Intermediate
CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that
image was published. Pinning matters because of the backup version check (see image was published. Pinning matters because of the backup version check (see
[Full backup and restore](#full-backup-and-restore)): the panel binary's [Full backup and restore](#full-backup-and-restore)): the panel binary's
+5 -5
View File
@@ -2,7 +2,7 @@
**Status:** agreed **Status:** agreed
**Date:** 2026-08-19 **Date:** 2026-08-19
**Version:** `1.10.0` MINOR (opt-in; no change to outbound-only or inbound-without-filter paths). **Version:** `2.1.0` MINOR (opt-in; no change to outbound-only or inbound-without-filter paths). Was `1.10.0`; on 2026-10-09 the owner put the panel redesign first, so this ships on the 2.0 baseline and its screens are built from the redesign's component kit ([panel-redesign.md](panel-redesign.md) § Component kit).
--- ---
@@ -128,7 +128,7 @@ table has filters (decision, domain, date range). Mockups are drawn first, in
`docs/assets/panel-redesign/panel/src/` under that plan's design contract — `docs/assets/panel-redesign/panel/src/` under that plan's design contract —
not in the superseded `docs/assets/panel-ui/`. not in the superseded `docs/assets/panel-ui/`.
## Schema (migration `0011_inbound_spam_log.sql`) ## Schema (migration `0002_inbound_spam_log.sql`, the first after the 2.0 baseline)
**`inbound_spam_log`** **`inbound_spam_log`**
@@ -239,15 +239,15 @@ Fable runs after Opus sign-off.
## Implementation checklist ## Implementation checklist
Target version cut: **`1.10.0`** (MINOR). One commit per step; Target version cut: **`2.1.0`** (MINOR), after `2.0.0`. One commit per step;
[development.md](../development.md) § Plan checklists. [development.md](../development.md) § Plan checklists.
- [x] Agree journal fields, retention, and RBAC (this plan § Decisions) — **Composer** - [x] Agree journal fields, retention, and RBAC (this plan § Decisions) — **Composer**
- [ ] Migration `0011_inbound_spam_log.sql` — **Composer** - [ ] Migration `0002_inbound_spam_log.sql` on the 2.0 baseline — **Composer**
- [ ] Inbound journal-milter + Postfix wiring — **Composer** - [ ] Inbound journal-milter + Postfix wiring — **Composer**
- [ ] mail.log tailer: inbound reject rows — **Composer** - [ ] mail.log tailer: inbound reject rows — **Composer**
- [ ] List CRUD + validation + atomic rspamd map sync — **Composer** - [ ] List CRUD + validation + atomic rspamd map sync — **Composer**
- [ ] Panel: journal + lists UI (+ mockup) — **Composer** - [ ] Panel: Inbound › Log and Inbound › Filter lists from the kit partials, mockups `in-log` / `in-filter-lists` (add client IP and engine columns mockup-first), evidence pairs — **Composer**
- [ ] Backup includes `/data/antispam/` — **Composer** - [ ] Backup includes `/data/antispam/` — **Composer**
- [ ] Unit + handler tests — **Composer** - [ ] Unit + handler tests — **Composer**
- [ ] [guide.md](../guide.md), [architecture.md](../architecture.md) — **Composer** - [ ] [guide.md](../guide.md), [architecture.md](../architecture.md) — **Composer**
+128 -19
View File
@@ -211,7 +211,7 @@ target; none of this is smuggled in with the restyle):
- Queue as a table — parse `postqueue -j` instead of printing `postqueue -p`. - Queue as a table — parse `postqueue -j` instead of printing `postqueue -p`.
- Inbound queue — the same queue filtered by recipient domain. - Inbound queue — the same queue filtered by recipient domain.
- Inbound log — rides on `inbound-antispam-panel` (`1.10.0`). - Inbound log — rides on `inbound-antispam-panel` (`2.1.0`).
- Server › Health — a route for the tables removed from Overview, and the - Server › Health — a route for the tables removed from Overview, and the
home of *Reload configuration* (today a card on `/status`). home of *Reload configuration* (today a card on `/status`).
@@ -229,11 +229,12 @@ the first step of that feature's plan — not invented during implementation.
| Roadmap item | Where it lands | Built from | Drawn | | Roadmap item | Where it lands | Built from | Drawn |
|---|---|---|---| |---|---|---|---|
| inbound-antispam-panel (agreed, `1.10.0`) | journal = **Inbound › Log** (add client IP and engine to the mockup's columns; `quarantine` joins the decisions); lists = new **Inbound › Filter lists**, global-only because the lists are instance-wide | list layout: add form + two tables, `status_tag`, filter form in `box_head`; the log links *Allow / Deny sender* to the lists | `in-log`, `in-filter-lists` | | inbound-antispam-panel (agreed, `2.1.0`) | journal = **Inbound › Log** (add client IP and engine to the mockup's columns; `quarantine` joins the decisions); lists = new **Inbound › Filter lists**, global-only because the lists are instance-wide | list layout: add form + two tables, `status_tag`, filter form in `box_head`; the log links *Allow / Deny sender* to the lists | `in-log`, `in-filter-lists` |
| inbound-quarantine (candidate) | new **Inbound › Quarantine**, list + detail with *Release* / *Discard* | list layout; detail like `out-message` with `facts`, `log_pane` for headers only, Release beside a `sp-danger-zone` Discard | `in-quarantine`, `in-quarantine-message` — the open questions of its plan (where mail lives, what release means, retention, RBAC) stay open; the mockup assumes SelfPost-held mail, release to upstream, 14 days, delegated like the rest of Inbound | | inbound-quarantine (candidate) | new **Inbound › Quarantine**, list + detail with *Release* / *Discard* | list layout; detail like `out-message` with `facts`, `log_pane` for headers only, Release beside a `sp-danger-zone` Discard | `in-quarantine`, `in-quarantine-message` — the open questions of its plan (where mail lives, what release means, retention, RBAC) stay open; the mockup assumes SelfPost-held mail, release to upstream, 14 days, delegated like the rest of Inbound |
| preflight (candidate) | new **Server › Preflight**, next to Health: Health is what runs now, Preflight is the deeper on-demand installation check with a test e-mail form | `postmark` verdict, a table of checks with `status_tag` and a *What to do* column, one form box for the test e-mail | `preflight` | | preflight (candidate) | new **Server › Preflight**, next to Health: Health is what runs now, Preflight is the deeper on-demand installation check with a test e-mail form | `postmark` verdict, a table of checks with `status_tag` and a *What to do* column, one form box for the test e-mail | `preflight` |
| panel-notifications (candidate) | per-user event choice on **Account**; one switch under **Server › Settings** | checkboxes in a box, `help` | `account`, `settings` | | panel-notifications (candidate) | per-user event choice on **Account**; one switch under **Server › Settings** | checkboxes in a box, `help` | `account`, `settings` |
| password-reset (candidate) | a link on sign-in, two signed-out screens (request, set new password) | the signed-out layout | **no** | | password-reset (candidate) | a link on sign-in, two signed-out screens (request, set new password) | the signed-out layout | **no** |
| delivery-log-storage (candidate) | the *Delivery log* box of **Outbound › Log › message**, filled from a table instead of a grep of today's `mail.log` | `log_pane`, unchanged | `out-message` |
| csrf-tokens | no screen; every form gains a hidden field — forms are rendered through one helper so the token cannot be forgotten | — | n/a | | csrf-tokens | no screen; every form gains a hidden field — forms are rendered through one helper so the token cannot be forgotten | — | n/a |
| template-data-typing | no screen; the component partials are the natural first users of typed data | — | n/a | | template-data-typing | no screen; the component partials are the natural first users of typed data | — | n/a |
| structured-logging, contributing, review follow-ups | none | — | n/a | | structured-logging, contributing, review follow-ups | none | — | n/a |
@@ -242,8 +243,14 @@ The sibling strip under the navbar holds five or six entries per group without
wrapping; Inbound would reach five (Domains · Log · Queue · Quarantine · wrapping; Inbound would reach five (Domains · Log · Queue · Quarantine ·
Filter lists), Server six. Past that a group is split rather than squeezed. Filter lists), Server six. Past that a group is split rather than squeezed.
Ordering with 2.0: inbound-antispam-panel ships first in `1.10.0` and adds its Ordering with 2.0 (owner, 2026-10-09: feature order is not fixed, take
migration to the 1.x chain as planned; the 2.0 baseline then folds it in. whatever suits development): **the redesign ships first as `2.0.0`**, and
inbound-antispam-panel follows as `2.1.0`, built from the component kit on
the 2.0 baseline. The other way round would have styled two screens twice and
written a 1.x migration only to dissolve it into the baseline. The antispam
back end (journal milter, log tailer, list CRUD, rspamd map sync) touches
nothing under `internal/web/view` and may be built alongside stages 0–1 below
if time allows; its two screens wait for the kit.
## Three layers ## Three layers
@@ -276,6 +283,45 @@ stamp's paper margin with the perforation along its outer edge — round holes,
flat teeth, the mark's ink hairline, one tile `perf-edge.svg`; then the flat teeth, the mark's ink hairline, one tile `perf-edge.svg`; then the
sibling-page strip; and the footer. sibling-page strip; and the footer.
## Component kit
The three layers above are implemented as one unit **before any page is
restyled** — stage 0 of the checklist. The question the kit answers is the
owner's: a screen must not be able to invent a shared component on the spot.
The mockup fragments in `panel/src/` cannot answer it, because they copy the
component markup by hand into every screen; in the code that markup exists in
exactly one place.
The kit is, in `internal/web/view`:
| Part | What it is |
|---|---|
| `templates/components.html` | one `{{define}}` per partial of § Three layers; the only file that contains `sp-box-head`, `sp-postmark`, `sp-record`, `sp-facts`, … literally |
| `components.go` | the typed input of every partial — `Head{Kicker, Title, Lead, Postmark, Actions}`, `Box{No, Title, End, Help, Variant}`, `Record{Name, Status, Host, Type, Value, InDNS}`, `Fact{Label, Value, Big}`, `SideMenu{Groups}`, `Tag{Status, Label}`, … — so a page passes a struct, not a `dict` it composes itself. A partial's input is part of its definition: components.html documents the look, `components.go` the data |
| template functions | `status_tag` (status → `tag is-… is-light`), `copy_field`, `wbr_at` (`<wbr>` after `@` in table cells) |
| `static/panel.css` | `panel/theme.css` + `shared/brand.css`, no rule the mockups do not have; the old stylesheet is deleted at the end, not merged |
| `static/bulma.min.css`, icons | vendored, pinned by checksum; the icon font subset to the icons the mockups use (58 today), not the whole Tabler set |
| `layout.html` | the shell: navbar with the wordmark, perforated edge, sibling strip, user menu, footer; the signed-out variant |
| `GET /server/components` | the kit page, global role only: every partial in every state rendered from fixtures — the mockup `components.html` rebuilt by the real templates |
**Kit acceptance** (stage 0 is not done without it):
- `TestPanelClassVocabulary` and `TestPanelCSSContract` are green on the kit
alone (the old pages are still on the ratchet list at this point, see
§ Against resurrected pages);
- every partial named in § Three layers exists, renders on the kit page, and
has a typed input in `components.go` with a doc comment saying where it is
used;
- a screenshot of `/server/components` from the running container sits beside
the mockup under `docs/assets/panel-redesign/evidence/components.png`, and
the reviewer finds no difference they cannot name;
- `panel/audit.js` on the kit page reports an empty `problems` list.
After stage 0 a page template is thin: it calls partials with structs and
contains no `sp-` markup of its own. If a page needs a component the kit lacks,
that is contract rule 2 — stop and ask; the owner decides whether it is a
`design:` change to the components page first.
## Type and tables ## Type and tables
- **Three sizes.** Body 16px; `--sp-fs-s` 14px for supporting text (labels, - **Three sizes.** Body 16px; `--sp-fs-s` 14px for supporting text (labels,
@@ -359,6 +405,37 @@ CI adds one step, `design-first`: a commit that touches
`internal/web/view/**` must not touch `docs/assets/panel-redesign/panel/**` `internal/web/view/**` must not touch `docs/assets/panel-redesign/panel/**`
or the guard tests. or the guard tests.
### Against resurrected pages
It has happened once: an implementer took the old page templates out of git
history and reported them as the redesigned pages. Four guards make that
impossible to pass, and none of them depends on anyone looking:
1. **The ratchet.** The guard tests carry one list, `legacyPages`, of page
names that are still old and therefore exempt from
`TestPanelOutlinesMatchMockups`, `TestPanelClassVocabulary` and
`TestTemplatesUseComponents`. It starts as every page and **can only
shrink**: a CI step fails a commit whose `legacyPages` is not a subset of
its parent's. Restyling a page means removing it from the list in the same
commit; from then on that page is held to its outline forever. Putting the
old template back fails three tests at once, and putting the name back on
the list fails CI.
2. **The outline is the design.** An old page has a different component
skeleton from `outlines.json` — different boxes, no `page_head`, no
`side_menu` — so it cannot match, however its classes are renamed.
3. **Old paths are forbidden.** `TestRoutesFollowNavigation` fails on any
template that contains a pre-2.0 path; every old page does.
4. **No blob from the past.** The `design-first` CI step also compares every
file under `internal/web/view/templates/` with the same path's blobs in
history up to the last 1.x tag, whitespace-normalised, and fails on a
match. This catches a byte-for-byte revert that happens to be on the
ratchet list still; the three tests above catch everything else.
The evidence rules below close the remaining gap, a screenshot that is not
what it claims to be: screenshots come from the running container with the
e2e fixtures, so they show fixture data a mockup does not contain, and the
reviewer is a model that did not write the step.
### Evidence for "done" ### Evidence for "done"
Every checklist step that restyles pages ends with, in the commit: Every checklist step that restyles pages ends with, in the commit:
@@ -384,26 +461,58 @@ reviewed.
- Weakening CSP; a JS bundler; a front-end framework. - Weakening CSP; a JS bundler; a front-end framework.
## Implementation checklist ## Implementation checklist
No code until the roadmap status is **agreed**. No code until the roadmap status is **agreed**. Stages are ordered for
development, not by feature priority (owner, 2026-10-09). Stage 1 touches no
file under `internal/web/view` and may run alongside stage 0; stage 2 starts
only when both are done. Every stage-2 step ends with the evidence of
§ Evidence for "done".
**Decided**
- [x] Owner decision recorded here: keep today's paths or rename with redirects — **owner** (2026-09-21: rename, § Routes) - [x] Owner decision recorded here: keep today's paths or rename with redirects — **owner** (2026-09-21: rename, § Routes)
- [x] Owner decision: no compatibility, migrations to zero, `2.0.0` — **owner** (2026-09-21, § No compatibility) - [x] Owner decision: no compatibility, migrations to zero, `2.0.0` — **owner** (2026-09-21, § No compatibility)
- [x] Owner decision: inbound is delegated per user, separately from outbound — **owner** (2026-09-21, § Who sees what) - [x] Owner decision: inbound is delegated per user, separately from outbound — **owner** (2026-09-21, § Who sees what)
- [ ] Vendor Bulma 1.0.4 and the used Tabler icons (MIT) under `/static`, NOTICE and development.md rows, pinned checksum — **Haiku** - [x] Owner decision: the redesign ships before inbound-antispam-panel; feature order follows development convenience — **owner** (2026-10-09, § Room for roadmap items)
- [ ] Guard tests from the table above and `TestRoutesFollowNavigation`, red against today's panel where expected; `design-first` CI step — **Opus** - [x] Decision inside 2.0: **Server › Health** is built with the restyle (the tables that leave Overview need a home, § Routes); **queue as a table** and the **inbound queue** stay roadmap decisions — both queue pages ship old content in the new shell, the Inbound › Queue entry hidden — (2026-10-09)
- [ ] Schema from zero: single `0001_init.sql` baseline with `user_inbound_domains`, store tests, `schema-migrations.md` rewritten — **Opus**
- [ ] Inbound delegation: `requireInboundDomain`, filtered lists and log, two-list user form, per-route 404 tests for both roles — **Opus** **Stage 0 — foundation and component kit** (§ Component kit)
- [ ] Routes renamed per § Routes: new paths, one fragment name, old paths removed, subtree `requireGlobal()` for `/server/` — **Opus**
- [ ] `panel.css` from `panel/theme.css`; `components.html` partials; `status_tag`; a `/components` page behind the global role rendering every partial — **Sonnet** - [ ] Vendor Bulma 1.0.4 and the icon subset (MIT) under `/static`, NOTICE and development.md rows, pinned checksum — **Haiku**
- [ ] `layout.html`: navbar, perforated edge, sibling strip, user menu, footer; visibility flags — **Sonnet** - [ ] Guard tests from § Enforcement and `TestRoutesFollowNavigation`, red against today's panel where expected, with the `legacyPages` ratchet listing every page; `design-first` CI step including the ratchet and the history-blob check (§ Against resurrected pages) — **Opus**
- [ ] `panel.css` from `panel/theme.css` + `shared/brand.css`; `components.html` partials with typed inputs in `components.go`; `status_tag`, `copy_field`, `wbr_at` — **Sonnet**
- [ ] `layout.html`: navbar, perforated edge, sibling strip, user menu, footer, visibility flags; the signed-out shell — **Sonnet**
- [ ] `GET /server/components` behind the global role rendering every partial from fixtures; kit acceptance (§ Component kit) with evidence — **Sonnet**, reviewed by **Opus**
**Stage 1 — data and routes** (no template work; parallel with stage 0)
- [ ] Schema from zero: single `0001_init.sql` baseline with `users.email`, the DMARC default fields, `user_inbound_domains`, the two `all_*` flags; store tests; `schema-migrations.md` rewritten — **Opus**
- [ ] Inbound delegation: `requireInboundDomain`, lists and log filtered in the query, two-list user form data, per-route 404 tests for both roles including another tenant's id — **Opus**
- [ ] Routes renamed per § Routes: new paths, one fragment name, old paths removed, subtree `requireGlobal()` for `/server/`; links in the old templates retargeted so the panel works on every commit — **Opus**
- [ ] The application form as one POST; `out-app-created` as the response with `Cache-Control: no-store` — **Opus**
- [ ] Server › Health route and handler (tables and *Reload configuration* from `/status`); Overview handler reduced to verdicts — **Opus**
**Stage 2 — pages, in groups** (each step: partial calls only, page off the ratchet list, evidence pairs)
- [ ] Signed-out pages: login, setup — **Sonnet** - [ ] Signed-out pages: login, setup — **Sonnet**
- [ ] Overview (verdicts) and Account / Settings split — **Sonnet** - [ ] Overview, Health, and the Account / Settings split — **Sonnet**
- [ ] Outbound: domains, domain, domain settings, application form as one POST, shown-once password, delete — **Sonnet** - [ ] Outbound: domains, domain, domain settings, application form, shown-once password, delete — **Sonnet**
- [ ] Outbound: log, message, DMARC hub / domain / report — **Sonnet** - [ ] Outbound: log, message, DMARC hub / domain / report — **Sonnet**
- [ ] Inbound: domains, domain, delete — **Sonnet** - [ ] Inbound: domains, domain with the *Spam filter* box, delete — **Sonnet**
- [ ] Server: system log, backup, users, user form, delete; Help — **Sonnet** - [ ] Server: system log, backup, users, user form, delete; Help as `help_topic` partials, the drawer removed — **Sonnet**
- [ ] Remove the old `panel.css` rules and classes nothing references; vocabulary test green with no allow-list — **Sonnet** - [ ] Queues: old content inside the new shell (outbound), Inbound › Queue hidden — **Sonnet**
- [ ] Remove the old `panel.css`, the drawer code in `panel.js` and every class nothing references; `legacyPages` empty, vocabulary test green with no allow-list — **Sonnet**
**Stage 3 — reviews, docs, 2.0.0**
- [ ] Technical review of the whole against the mockups, evidence pairs complete — **Opus** - [ ] Technical review of the whole against the mockups, evidence pairs complete — **Opus**
- [ ] Security review of the route rename, subtree authorization and inbound delegation — **Fable** - [ ] Security review of the route rename, subtree authorization and inbound delegation — **Fable**
- [ ] guide.md screenshots and wording for the new navigation; architecture.md § Panel HTTP surface and § Persistence; security.md roles; *Removed* in the CHANGELOG with one line that 2.0 starts from an empty data directory — **Sonnet** - [ ] guide.md screenshots and wording for the new navigation; architecture.md § Panel HTTP surface and § Persistence; security.md roles; *Removed* in the CHANGELOG with one line that 2.0 starts from an empty data directory; strike the three UI rows of review-2026-08-followups that the redesign retires — **Sonnet**
- [ ] `go vet`, `go test ./...`, e2e suite — **Haiku** - [ ] `go vet`, `go test ./...`, e2e suite; version cut `2.0.0` — **Haiku**
**After 2.0** — features land in the kit, each from its own plan, mockup first
(§ Room for roadmap items): inbound-antispam-panel `2.1.0`; then, in whatever
order suits development, preflight, delivery-log-storage, panel-notifications,
password-reset (two screens to draw first), queue as a table with the inbound
queue, csrf-tokens
(one hidden field in the form partial once security.md is settled),
inbound-quarantine after its open questions.
+60 -4
View File
@@ -37,12 +37,15 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| structured-logging | `log/slog` with levels and fields | candidate | — | — | | structured-logging | `log/slog` with levels and fields | candidate | — | — |
| 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 | candidate | — | — |
| panel-notifications | E-mail notifications about important events | candidate | — | — | | panel-notifications | E-mail notifications about important events | candidate | — | — |
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | candidate | — | — |
| password-reset | Password reset by e-mail | candidate | — | — | | password-reset | Password reset by e-mail | candidate | — | — |
| panel-redesign | Panel redesign (accepted mockups, design contract) | candidate | 3/21 | [plans/panel-redesign.md](plans/panel-redesign.md) | | panel-redesign | Panel redesign (accepted mockups, design contract) | candidate | 5/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
| schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — | — | | schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — | — |
**Recommended order** (not binding): the next feature is **Recommended order** (not binding; owner, 2026-10-09: feature order follows
**inbound-antispam-panel** (`1.10.0`). panel-docs shipped in development convenience): the next stage is **panel-redesign** (`2.0.0`),
then **inbound-antispam-panel** (`2.1.0`) built in the new component kit —
styling its two screens once instead of twice. panel-docs shipped in
[CHANGELOG.md](../CHANGELOG.md) `[1.8.0]`; dmarc-reports in `[1.7.0]` [CHANGELOG.md](../CHANGELOG.md) `[1.8.0]`; dmarc-reports in `[1.7.0]`
(security review of the ingest path pending); domain-stats-auto-ratelimit in (security review of the ingest path pending); domain-stats-auto-ratelimit in
`[1.6.0]`; send-log-retention in `[1.5.0]`; inbound-relay in `[1.4.0]`; `[1.6.0]`; send-log-retention in `[1.5.0]`; inbound-relay in `[1.4.0]`;
@@ -130,7 +133,7 @@ hook is set.
**Dependencies / risks:** inbound relay `[1.4.0]`; rspamd map/header coupling; **Dependencies / risks:** inbound relay `[1.4.0]`; rspamd map/header coupling;
journal PII; global-admin-only RBAC. journal PII; global-admin-only RBAC.
**Version:** `1.10.0` MINOR; **agreed**. **Version:** `2.1.0` MINOR (was `1.10.0`; reordered after the redesign on 2026-10-09); **agreed**.
--- ---
@@ -297,6 +300,59 @@ and tagging it would only delete cross-platform test coverage.
--- ---
## delivery-log-storage
**Problem (owner, 2026-10-09):** the *Delivery log* box on a message's page
(`/deliveries/{id}`, mockup `out-message`) is usually empty. The lines are not
stored anywhere: `logtail.QueueLines`
([internal/logtail/logtail.go](../internal/logtail/logtail.go)) greps the
**current** `mail.log` on every page view, within its last 4 MB, and never
opens the rotated files. Rotation is daily
([build/logrotate-mail.conf](../build/logrotate-mail.conf)), so a message
older than the last rotation — in practice anything not sent today — shows
"Nothing for this queue id in the current mail log" while its send-log row
lives on for ninety days. The fourteen rotated files on disk hold the lines
and are never read.
**Goal:** a message's page shows what Postfix wrote about it for as long as
the message is in the send log.
**Options:**
1. **Store the lines** (recommended). The log tailer already reads every
`mail.log` line once to update delivery status; a line whose queue id
belongs to a send-log row is also appended to a new table
(`send_log_lines`: row id, written at, text), five to ten short lines per
message. Retention follows the row — the existing send-log sweep deletes
them together — and the page reads the table instead of the file. Lines
written while the panel was down are caught up the way status updates
are, from the persisted read offset. The grep stays only as a fallback
for rows older than the table.
2. **Grep the rotated files too.** `QueueLines` walks `mail.log.1`,
`mail.log.2.gz`, … until it finds the id. No schema, but each view
decompresses up to fourteen files, and past fourteen days the answer is
still empty against a ninety-day row.
**Boundary:** outbound send-log rows only; the inbound journal
([inbound-antispam-panel](#inbound-antispam-panel)) decides for itself
whether it wants the same. No change to what the box looks like — the
`out-message` mockup already draws it as a `log_pane`.
**Done when:** a message sent before the last rotation shows its lines on its
page; the lines disappear with the row; the page wording no longer blames
rotation for an ordinary case.
**Dependencies / risks:** a queue id is reused by Postfix after a message
leaves the queue, so matching must be bounded to the row's lifetime (accepted
time to final status plus the queue lifetime), not to the id alone; the table
grows with the send log (small — a few hundred bytes per message) and is
covered by the same retention.
**Version:** `2.x` MINOR; `candidate`. Fits after the redesign, built against
the kit's `log_pane`.
---
## panel-notifications ## panel-notifications
**Goal:** the panel tells its administrators when something needs them instead **Goal:** the panel tells its administrators when something needs them instead