Compare commits
2
Commits
6bfaa518d7
...
57c6fddfc3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57c6fddfc3 | ||
|
|
1e92c5b2a2 |
@@ -11,9 +11,9 @@ name: release
|
|||||||
# own native runner; a merge job then combines them into the one manifest tag
|
# own native runner; a merge job then combines them into the one manifest tag
|
||||||
# documented in deploy/docker-compose.yml. "test, then push" (not push-by-digest
|
# documented in deploy/docker-compose.yml. "test, then push" (not push-by-digest
|
||||||
# then test) is deliberate: it means the bytes that get tagged are exactly the
|
# then test) is deliberate: it means the bytes that get tagged are exactly the
|
||||||
# bytes that passed e2e. Per-arch tags are pushed only so imagetools can
|
# bytes that passed e2e. The per-arch tags stay in GHCR next to the version
|
||||||
# assemble the multi-arch manifest; merge removes them from GHCR so operators
|
# tag: they are the images the multi-arch manifest points at, and GHCR cannot
|
||||||
# see a single version tag.
|
# drop a tag without deleting the image behind it.
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [published]
|
||||||
@@ -177,28 +177,35 @@ jobs:
|
|||||||
backoff=$((backoff * 2))
|
backoff=$((backoff * 2))
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Remove per-arch tags from GHCR
|
- name: Remove orphaned untagged versions from GHCR
|
||||||
# Side-effect tags for imagetools assembly only — not part of the public
|
# Re-publishing a version moves its tags to new manifests and leaves the
|
||||||
# version surface (deploy/docker-compose.yml pins X.Y.Z, not X.Y.Z-amd64).
|
# old ones behind as untagged "sha256:..." package versions. Delete only
|
||||||
# imagetools has no "rm" subcommand; delete via the GitHub Packages API.
|
# those that no tagged manifest references: the images behind a
|
||||||
|
# multi-arch tag are package versions too, and deleting one breaks pulls
|
||||||
|
# of that tag. Any failed lookup aborts the step before anything is
|
||||||
|
# deleted.
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
version="${{ needs.prepare.outputs.version }}"
|
image="ghcr.io/${{ github.repository }}"
|
||||||
owner="${{ github.repository_owner }}"
|
owner="${{ github.repository_owner }}"
|
||||||
pkg="${{ github.event.repository.name }}"
|
pkg="${{ github.event.repository.name }}"
|
||||||
api="/users/${owner}/packages/container/${pkg}/versions"
|
api="/users/${owner}/packages/container/${pkg}/versions"
|
||||||
for suffix in amd64 arm64; do
|
versions=$(gh api "${api}?per_page=100" --paginate \
|
||||||
tag="${version}-${suffix}"
|
--jq '.[] | [.id, .name, (.metadata.container.tags | length)] | @tsv')
|
||||||
mapfile -t ids < <(gh api "$api" --paginate \
|
keep=$(mktemp)
|
||||||
--jq ".[] | select([.metadata.container.tags[]] | index(\"${tag}\")) | .id")
|
while IFS=$'\t' read -r id digest ntags; do
|
||||||
if [ "${#ids[@]}" -eq 0 ]; then
|
[ "$ntags" -gt 0 ] || continue
|
||||||
echo "no GHCR package version for tag ${tag}"
|
echo "$digest" >> "$keep"
|
||||||
|
raw=$(docker buildx imagetools inspect --raw "${image}@${digest}")
|
||||||
|
jq -r '.manifests[]?.digest' <<<"$raw" >> "$keep"
|
||||||
|
done <<<"$versions"
|
||||||
|
while IFS=$'\t' read -r id digest ntags; do
|
||||||
|
[ "$ntags" -eq 0 ] || continue
|
||||||
|
if grep -qxF "$digest" "$keep"; then
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
for id in "${ids[@]}"; do
|
echo "deleting orphaned GHCR package version ${id} (${digest})"
|
||||||
echo "deleting GHCR package version ${id} (tag ${tag})"
|
gh api -X DELETE "${api}/${id}"
|
||||||
gh api -X DELETE "${api}/${id}"
|
done <<<"$versions"
|
||||||
done
|
|
||||||
done
|
|
||||||
|
|||||||
@@ -5,6 +5,45 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.9.5] - 2026-10-09
|
||||||
|
|
||||||
|
CI-only cut: the release workflow no longer breaks the multi-arch image it
|
||||||
|
just published. No product code changes.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- ci (GHCR): the release no longer deletes the `X.Y.Z-amd64` / `X.Y.Z-arm64`
|
||||||
|
package versions after the manifest merge. Those versions are the images the
|
||||||
|
`X.Y.Z` manifest points at, so `docker pull ghcr.io/mixeme/selfpost:1.3.0`
|
||||||
|
failed with a missing manifest. The per-arch tags now stay; the merge job
|
||||||
|
instead removes untagged versions that no tagged manifest references.
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|||||||
@@ -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.5
|
||||||
```
|
```
|
||||||
|
|
||||||
**Get the setup URL** (pick one):
|
**Get the setup URL** (pick one):
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
selfpost:
|
selfpost:
|
||||||
image: ghcr.io/mixeme/selfpost:1.9.3
|
image: ghcr.io/mixeme/selfpost:1.9.5
|
||||||
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}"
|
||||||
|
|||||||
+12
-5
@@ -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.5`; adjust if semver changes mid-track):
|
||||||
|
|
||||||
| Stage | ID | Cut |
|
| Stage | ID | Cut |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -121,7 +121,10 @@ 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 | GHCR release fix: keep per-arch images, sweep orphaned versions (CI only) | `1.9.5` (PATCH) |
|
||||||
|
| 13 | panel-redesign | `2.0.0` (planned; MAJOR, no compatibility) |
|
||||||
|
| 14 | 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.
|
||||||
@@ -407,13 +410,17 @@ prepare (version from release tag or workflow_dispatch input; checkout vX.Y.Z)
|
|||||||
→ push ghcr.io/...:X.Y.Z-amd64 | X.Y.Z-arm64
|
→ push ghcr.io/...:X.Y.Z-amd64 | X.Y.Z-arm64
|
||||||
→ merge
|
→ merge
|
||||||
→ docker buildx imagetools create → unified manifest X.Y.Z
|
→ docker buildx imagetools create → unified manifest X.Y.Z
|
||||||
→ GitHub Packages API → drop X.Y.Z-amd64 and X.Y.Z-arm64 from GHCR
|
→ GitHub Packages API → drop untagged versions no tagged manifest references
|
||||||
```
|
```
|
||||||
|
|
||||||
Native per-arch matrix (no QEMU): running the full Postfix/OpenDKIM stack under
|
Native per-arch matrix (no QEMU): running the full Postfix/OpenDKIM stack under
|
||||||
emulation for e2e is impractical. E2e first, then push — the registry receives
|
emulation for e2e is impractical. E2e first, then push — the registry receives
|
||||||
the bytes that passed the gate. Only `ghcr.io/mixeme/selfpost:X.Y.Z` remains
|
the bytes that passed the gate. Each release leaves three tags in GHCR:
|
||||||
tagged in GHCR; per-arch names exist briefly during the merge job.
|
`X.Y.Z` (the multi-arch manifest operators pin) and `X.Y.Z-amd64` /
|
||||||
|
`X.Y.Z-arm64` (the images it points at). The per-arch tags must stay — GHCR
|
||||||
|
cannot drop a tag without deleting the image, and deleting it breaks pulls of
|
||||||
|
`X.Y.Z`. The last merge step removes only orphans: untagged versions left
|
||||||
|
behind when a version is published again.
|
||||||
|
|
||||||
A failed e2e **blocks** image publication.
|
A failed e2e **blocks** image publication.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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.5`. 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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user