From 1e92c5b2a2cc2cff1b81a813441189c4c4123fe7 Mon Sep 17 00:00:00 2001 From: Mix Date: Fri, 9 Oct 2026 00:15:48 +0300 Subject: [PATCH] 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 --- CHANGELOG.md | 26 +++++ README.md | 2 +- deploy/docker-compose.yml | 2 +- docs/development.md | 6 +- docs/guide.md | 2 +- docs/plans/inbound-antispam-panel.md | 10 +- docs/plans/panel-redesign.md | 147 +++++++++++++++++++++++---- docs/roadmap.md | 64 +++++++++++- 8 files changed, 226 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 350726d..008e44e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [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 Panel-redesign prep, docs only: stage 1-2 mockups on all 33 screens, the diff --git a/README.md b/README.md index 24e61a1..c97218e 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ docker run --rm -d --name selfpost-try \ -e SELFPOST_HOSTNAME=mail.local.test \ -e PANEL_COOKIE_SECURE=false \ -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): diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 3b9db14..9893039 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -21,7 +21,7 @@ services: selfpost: - image: ghcr.io/mixeme/selfpost:1.9.3 + image: ghcr.io/mixeme/selfpost:1.9.4 restart: unless-stopped environment: SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}" diff --git a/docs/development.md b/docs/development.md index 21abaf0..40d6b18 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 [§ 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 | |---|---|---| @@ -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) | | 9 | 2026-08-19 code review pass | `1.9.2` (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 commit rule but **no** version cut until the next product stage ships. diff --git a/docs/guide.md b/docs/guide.md index 71e56ea..b2770ed 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -173,7 +173,7 @@ cat ./data/setup-token #### Fixed image tag `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 image was published. Pinning matters because of the backup version check (see [Full backup and restore](#full-backup-and-restore)): the panel binary's diff --git a/docs/plans/inbound-antispam-panel.md b/docs/plans/inbound-antispam-panel.md index 48d4b96..be23fba 100644 --- a/docs/plans/inbound-antispam-panel.md +++ b/docs/plans/inbound-antispam-panel.md @@ -2,7 +2,7 @@ **Status:** agreed **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 — 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`** @@ -239,15 +239,15 @@ Fable runs after Opus sign-off. ## 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. - [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** - [ ] mail.log tailer: inbound reject rows — **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** - [ ] Unit + handler tests — **Composer** - [ ] [guide.md](../guide.md), [architecture.md](../architecture.md) — **Composer** diff --git a/docs/plans/panel-redesign.md b/docs/plans/panel-redesign.md index 917e6d8..e59a49d 100644 --- a/docs/plans/panel-redesign.md +++ b/docs/plans/panel-redesign.md @@ -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`. - 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 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 | |---|---|---|---| -| 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 | | 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` | | 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 | | 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 | @@ -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 · 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 -migration to the 1.x chain as planned; the 2.0 baseline then folds it in. +Ordering with 2.0 (owner, 2026-10-09: feature order is not fixed, take +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 @@ -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 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` (`` 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 - **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/**` 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" 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. ## 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: 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) -- [ ] Vendor Bulma 1.0.4 and the used Tabler icons (MIT) under `/static`, NOTICE and development.md rows, pinned checksum — **Haiku** -- [ ] Guard tests from the table above and `TestRoutesFollowNavigation`, red against today's panel where expected; `design-first` CI step — **Opus** -- [ ] 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** -- [ ] 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** -- [ ] `layout.html`: navbar, perforated edge, sibling strip, user menu, footer; visibility flags — **Sonnet** +- [x] Owner decision: the redesign ships before inbound-antispam-panel; feature order follows development convenience — **owner** (2026-10-09, § Room for roadmap items) +- [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) + +**Stage 0 — foundation and component kit** (§ Component kit) + +- [ ] Vendor Bulma 1.0.4 and the icon subset (MIT) under `/static`, NOTICE and development.md rows, pinned checksum — **Haiku** +- [ ] 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** -- [ ] Overview (verdicts) and Account / Settings split — **Sonnet** -- [ ] Outbound: domains, domain, domain settings, application form as one POST, shown-once password, delete — **Sonnet** +- [ ] Overview, Health, and the Account / Settings split — **Sonnet** +- [ ] Outbound: domains, domain, domain settings, application form, shown-once password, delete — **Sonnet** - [ ] Outbound: log, message, DMARC hub / domain / report — **Sonnet** -- [ ] Inbound: domains, domain, delete — **Sonnet** -- [ ] Server: system log, backup, users, user form, delete; Help — **Sonnet** -- [ ] Remove the old `panel.css` rules and classes nothing references; vocabulary test green with no allow-list — **Sonnet** +- [ ] Inbound: domains, domain with the *Spam filter* box, delete — **Sonnet** +- [ ] Server: system log, backup, users, user form, delete; Help as `help_topic` partials, the drawer removed — **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** - [ ] 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** -- [ ] `go vet`, `go test ./...`, e2e suite — **Haiku** +- [ ] 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; 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. diff --git a/docs/roadmap.md b/docs/roadmap.md index 3f155dd..413e07b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -37,12 +37,15 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md). | structured-logging | `log/slog` with levels and fields | candidate | — | — | | review-2026-08-followups | Remaining findings of the 2026-08-19 code review | 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 | — | — | -| 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** | — | — | -**Recommended order** (not binding): the next feature is -**inbound-antispam-panel** (`1.10.0`). panel-docs shipped in +**Recommended order** (not binding; owner, 2026-10-09: feature order follows +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]` (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]`; @@ -130,7 +133,7 @@ hook is set. **Dependencies / risks:** inbound relay `[1.4.0]`; rspamd map/header coupling; 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 **Goal:** the panel tells its administrators when something needs them instead