release: 1.2.0: Panel 1.2.0-1.2.5: layout, RBAC, tests, GUI and CI fixes

[1.2.0]
The second MINOR after 1.0.0: domain administrators with per-domain scope, a panel visual refresh on the SelfPost palette, and refinements to navigation and the send log. Upgrading runs one SQLite migration (`0005_panel_users`); the single administrator becomes a global user.
Added:
  - panel: **domain-admin role** — global administrators manage panel users and
Fixed:
  - Docker build: `LICENSE` is no longer excluded by `.dockerignore`, so the
  - panel: on signed-in pages with a narrow card (**Settings**, the user form)
Changed:
  - panel: navigation session icons are distinct — **Settings** uses a gear,
  - panel: `/` redirects domain administrators to `/domains`; global users still
  - panel: **Sign out** in the navigation column uses the same type size and
  - panel: **visual style** brought in line with the SelfPost mark — brick accent
  - panel: the send log's **status is a badge**, in the same ok/warn/error/unknown
  … (1 more in CHANGELOG)
[1.2.1]
Panel refinements after 1.2.0: navigation icons, status-badge centreing, drill-down back-link placement, and safeguards for the sole global administrator. Upgrading is a tag bump; no migration.
Fixed:
  - panel: status badge text sat low in the box (and below the heading or label
  - panel: the **Users** navigation icon was two full silhouettes with staggered
  - panel: the **Settings** navigation icon was a sun-with-rays (circle plus
  - panel: the user create/edit form placed «Back to users» at the bottom of the
Changed:
  - panel: the user edit form disables role change and delete for the only global
  - panel: the user create/edit form hides **Assigned domains** when the role is
  - panel: **Settings** shows panel credentials and DMARC aggregate reports side
[1.2.2]
Status page layout after 1.2.1: paired cards in a wide column, denser machine and check copy, and a user-form checkbox fix. Upgrading is a tag bump; no migration.
Changed:
  - panel: **Status** is wide again so paired cards fill the column. Layout:
Fixed:
  - panel: cards inside `.split` used `margin: 0 auto`, which in a CSS grid
  - panel: on the user create/edit form, **Assigned domains** checkboxes stacked
[1.2.3]
Domain detail layout and panel polish after 1.2.2. Upgrading is a tag bump; no migration.
Changed:
  - panel: the domain detail page is wide with paired cards (DKIM+SPF ‖ DMARC;
  - panel: Domains list — **Add domain** sits beside the domain field; the
  - panel: page URLs, browser titles, and headings are aligned — **Settings** is
Fixed:
  - image: `mail.log` rotation no longer silently stops when the build context
[1.2.4]
Level-2 rate-limit semantics inverted after 1.2.3, plus a small DNS field height fix. Upgrading is a tag bump; no migration.
Changed:
  - rate limiting (level 2): domain ceilings apply to every client IP (no IP
Fixed:
  - panel: on the domain DNS status grid, the Type (TXT) field height matches
[1.2.5]
Rate-limit form polish after 1.2.4. Upgrading is a tag bump; no migration.
Changed:
  - panel: rate-limit forms refined — level-1 backstop as muted copy on the

Squashed CHANGELOG sections: [1.2.0], [1.2.1], [1.2.2], [1.2.3], [1.2.4], [1.2.5].

Full detail: CHANGELOG.md (1.2.0, 1.2.1, 1.2.2, 1.2.3, 1.2.4, 1.2.5).
This commit is contained in:
mix
2026-09-22 15:24:52 +03:00
parent f62fe23826
commit 60e08e802e
73 changed files with 3614 additions and 1517 deletions
+9 -6
View File
@@ -81,11 +81,13 @@ One process, three roles:
proxy only.
2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open**
(`default_action=accept`) so milter failure does not stop mail. The level-2
count is the stored send-log rows plus the messages this process has admitted
but not yet written (`internal/milter/inflight.go`), so concurrent sessions
cannot each spend the same last slot; a reservation is released at
end-of-message, on ABORT, or after a 10-minute TTL.
(`default_action=accept`) so milter failure does not stop mail. Domain
ceilings apply to every client IP; an application ceiling with trusted IPs
raises the limit for those IPs only and skips the domain check (guide § Rate
limiting). The level-2 count is the stored send-log rows plus the messages
this process has admitted but not yet written (`internal/milter/inflight.go`),
so concurrent sessions cannot each spend the same last slot; a reservation
is released at end-of-message, on ABORT, or after a 10-minute TTL.
3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by
queue-id. Send-log `queued → sent` transitions depend on this goroutine alone
(`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)).
@@ -170,7 +172,8 @@ unless noted. The table below is a summary — HTMX fragment endpoints
| `/system-log` | `mail.log` tail |
| `/reload` | Reload OpenDKIM + Postfix maps |
| `/backup` | Full backup download (page also hosts the import form) |
| `/account` | Admin username/password |
| `/settings` | Admin username/password and DMARC report address |
| `/users`, `/users/*` | Panel user CRUD (global admin only) |
HTMX polling refreshes monitoring fragments (5 s while the operator is active on
the page, 30 s when the tab is visible but idle, none when hidden — scheduled in
+5 -3
View File
@@ -141,7 +141,7 @@ Requires Go 1.26+ and `CGO_ENABLED=0`.
```sh
make build # bin/panel, bin/selfpost-backup (VERSION=dev by default)
make build VERSION=1.1.0
make build VERSION=1.2.3
```
Or directly:
@@ -162,8 +162,10 @@ docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .
```
The Dockerfile has a build stage (`go vet`, `go build` with `VERSION`) and a
runtime stage (Debian + mail stack). See [architecture.md](architecture.md) §
Image and processes.
runtime stage (Debian + mail stack). Runtime config and scripts use `COPY
--chmod` so file modes in the image do not depend on how the build context was
synced (e.g. a Windows checkout widening permissions on `logrotate-mail.conf`).
See [architecture.md](architecture.md) § Image and processes.
---
+22 -13
View File
@@ -189,7 +189,7 @@ service healthy and will mail be accepted?"
- **Status** (`/status`) — supervised processes (Postfix, OpenDKIM, panel),
TLS certificate validity and expiry, milter socket presence, and a short
Postfix queue summary. The **Machine** card adds the resource usage of the
host underneath — processor (with the load average), memory and swap, and
host underneath — processor (core and thread counts), memory and swap, and
per-interface network throughput and totals — read from the kernel's
counters; CPU and throughput are measured between refreshes, so they appear
one refresh after the page opens. A fully busy processor or a machine out of
@@ -202,7 +202,8 @@ service healthy and will mail be accepted?"
shows after manual edits under `/data`.
- **Domains** (`/domains`) — add sending domains, inspect each domain's DKIM
TXT value, SPF/DMARC checks, and SASL applications. Per-domain rate limits
(level 2) are configured here. *Export domain* writes a single-domain archive;
(level 2) and trusted-IP application overrides are configured here.
*Export domain* writes a single-domain archive;
*Import a domain* on the Backup page reads one back in.
- **Deliveries** (`/deliveries`) — searchable send log with server-side filters
by domain and application. A row identifies its message and nothing more —
@@ -229,7 +230,7 @@ service healthy and will mail be accepted?"
- **Backup** (`/backup`) — download a full-server backup; the same page hosts
the domain-import form (`POST /domains/import`). See
[Backup, restore, and moving a single domain](#backup-restore-and-moving-a-single-domain).
- **Settings** (`/account`) — change the administrator username and/or password.
- **Settings** (`/settings`) — change the administrator username and/or password.
Application SASL logins are separate and are not changed here.
**Sessions.** A login survives a container restart: sessions live in SQLite, not
@@ -269,8 +270,10 @@ docker compose exec selfpost cat /data/setup-token
## Rate limiting
SelfPost applies two independent limits; both can refuse a submission, but only
level 2 writes a `rejected` row in the send log.
SelfPost applies two independent layers; both can refuse a submission, but only
level 2 writes a `rejected` row in the send log. Level-2 ceilings set in the
panel cannot exceed level 1 (the panel shows the level-1 values and rejects
higher numbers).
**Level 1 (IP backstop)** — always on, configured via `.env`:
@@ -278,14 +281,20 @@ level 2 writes a `rejected` row in the send log.
- `RATE_LIMIT_WINDOW_SECONDS` → Postfix `anvil_rate_time_unit`
This is an anvil limit per connecting client IP. It keeps working even if the
journal-milter (level 2) is down.
journal-milter (level 2) is down. There is no per-IP bypass.
**Level 2 (per domain / per application)** — optional, configured in the panel
on each domain's page or on an individual application. You set a message
ceiling, a time window, and optionally restrict the limit to specific client
IPs; an empty IP list means the differentiated limit does not apply. When
exceeded, Postfix returns a 4xx and the refusal is recorded in Deliveries as
`rejected`.
**Level 2 — domain** — optional, on each domain's page. A message ceiling and
window for **every** client IP sending as that domain. When unset, only
level 1 applies for non-privileged senders.
**Level 2 — application (trusted IPs)** — optional override on an application:
list one or more client IPs and a ceiling **strictly above** the domain limit
(still ≤ level 1). Connections from those IPs use the application ceiling and
skip the domain check. Other IPs stay under the domain limit (or level 1 alone).
An application override without trusted IPs is inactive.
When a level-2 ceiling is exceeded, Postfix returns a 4xx and the refusal is
recorded in Deliveries as `rejected`.
## Backup, restore, and moving a single domain
@@ -381,7 +390,7 @@ but it can look like an open port in external scans.
## 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.1.0`. Intermediate
deliberately never `:latest`. The current pin is `1.2.5`. 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 above:
the panel binary's embedded version and the image tag that produced it are the
-72
View File
@@ -1,72 +0,0 @@
# Plan: domain-admin (domain administrator role)
**Status:** agreed
**Version:** target bump **1.x** MINOR, given a compatible migration of the
current administrator into a global one.
**Order:** recommended after [web-split](web-split.md), before
[inbound-relay](inbound-relay.md).
---
## What this is
Today the panel has exactly one subject: `requireAuth` is a boolean gate, not a
role ([web.go](../../internal/web/web.go) — the
`mux.Handle("/", s.requireAuth(authed))` wrapper), and the session carries
nothing beyond the fact of being signed in.
The role grants access to **explicitly assigned domains** (one or several); the
list of domains is set by the **global administrator**. For each domain on that
list:
- that domain's applications (creation, sender mode, password regeneration,
deletion, its own L2 limit);
- the domain's DKIM/DNS status;
- the send log filtered to the domain — the filter already exists in the log
([sendLogData](../../internal/web/handlers/handlers_monitor.go)).
What stays outside the role is what is global by nature:
- adding and removing domains;
- creating domain-admin users and assigning domains to them;
- `/reload`;
- the full backup (that is all of `/data` including `sasldb2`, i.e. every
domain at once);
- the queue and the `mail.log` tail — those are server-wide and not tied to a
domain.
## Why this extends v1.0
[product.md](../product.md) puts "multiple panel users, roles" out of scope
(one administrator). A second subject is a deliberate widening of the project's
boundary, as inbound-relay is.
The cost is phase-sized, not patch-sized:
- a users table and their binding to domains;
- the role in the session;
- authorisation in every handler (not only on the route — today `{id}`/`{aid}`
are checked for nothing beyond existence);
- reworking first-run setup and password change for several users;
- accounting for the new subject in backup and domain export.
*(The earlier wording of this item — "2FA and multiple administrators" — has
been replaced: 2FA is off the table, and "multiple administrators" is narrowed
to one specific role, because what is needed is not a second all-powerful admin
but limited access for the owner of one or several domains, with the list set
by the global administrator.)*
## Done when
- A global administrator and a domain-admin with different rights both work
through the panel; the domain-admin cannot reach past the **assigned**
domains;
- the current single admin migrates into a global one without losing access;
- backup/restore accounts for users and their bindings;
- `build`/`vet`/`test`/image green.
## Risks
- An incomplete `{id}`/`{aid}` check in a handler — access leaking to someone
else's domain;
- breaking setup or backup — that would be a semver major, not 1.x.
+3 -2
View File
@@ -3,8 +3,9 @@
**Status:** agreed
**Version:** target bump **1.x** MINOR; **`2.x` possible** — to be settled once
the implementation lands (do not fix a major in advance).
**Order:** recommended after [web-split](web-split.md) and
[domain-admin](domain-admin.md).
**Order:** recommended next among agreed 1.x+ items (after `internal/web` split
and the domain-admin role, both shipped — see [CHANGELOG](../CHANGELOG.md)
`[1.2.0]`).
---
+28
View File
@@ -0,0 +1,28 @@
# Plan: logrotate-mode (mail.log stops rotating in some images)
**Status:** done
**Version:** patch; no schema, no configuration surface.
---
## Summary
`mail.log` stopped rotating when `/etc/logrotate.d/mail` landed in the image
with group/other write permission (e.g. build context from a Windows tar sync).
logrotate ignores such configs but exits 0, so the loop looked healthy while
the log grew without bound.
## What shipped
- [`build/Dockerfile`](../../build/Dockerfile): `COPY --chmod` pins config
(`0644`) and script (`0755`) modes so the image no longer depends on checkout
file modes.
- [`build/logrotate-loop.sh`](../../build/logrotate-loop.sh): preflight and
per-iteration checks refuse group/other-writable configs; logrotate stderr
mentioning `Ignoring` is fatal.
- [`test/e2e/logrotate_check.go`](../../test/e2e/logrotate_check.go): asserts
mode `644`, forced `logrotate -f`, and that a group-writable context file
still produces `644` in the image.
`create 0640 postfix selfpost` in [`build/logrotate-mail.conf`](../../build/logrotate-mail.conf)
is unchanged — required for panel readability after rotation.
-70
View File
@@ -1,70 +0,0 @@
# Plan: web-split (splitting `internal/web`)
**Status:** done (see [CHANGELOG](../CHANGELOG.md) `[1.1.0]`)
**Version:** `1.x`; an internal refactor, it does not force a break on its own.
---
## What this is
`internal/web` is the project's largest package: ~50 files (templates and
static assets included), ~25 `.go` files and ~4000 lines of Go, with the
handlers for every panel section, sessions, security headers, origin checking,
form validation and template rendering all sitting in one flat namespace.
The candidates to split out are `web/handlers` and `web/auth`, or a cut along
the panel's own domains.
## Why now
At its current size the flat package reads fine: the file names
(`handlers_domains.go`, `handlers_apps.go`, `handlers_monitor.go`) do the work
directories would, and splitting would force exporting what is package-private
today — widening the internal API for cosmetics.
It starts to pay off once the package grows: **domain-admin** and
**inbound-relay** both add code to it — the role brings authorisation into
every handler, the inbound relay brings its own pages and handlers for inbound
domains. The refactor is cheaper before that growth than after it.
## Recommended order
**web-split → domain-admin → inbound-relay** (see the
[roadmap](../roadmap.md)).
1. **web-split** — lay down the package structure (including a place for
`web/auth`) while there are no cross-cutting edits from the role and no new
inbound handlers.
2. **domain-admin** — authorisation in every handler builds on a package layout
already chosen.
3. **inbound-relay** — a new vertical slice; easier to add to an already split
package than to refactor alongside the two features before it.
The order is a recommendation, not a blocker.
## Chosen scheme
**Horizontal split into four packages** (decided at implementation):
```
internal/web/ # Config, Server, New, Handler — composition root; security.go
internal/web/view/ # embed templates/static, render/renderFragment, staticHandler
internal/web/auth/ # session, login/logout/setup, requireAuth, currentUser
internal/web/validate/ # shared form validation (avoids auth ↔ handlers import cycle)
internal/web/handlers/ # all authenticated page handlers (handlers_*.go)
```
`cmd/panel` keeps importing only `internal/web`. Subpackages are not exported
beyond what the composition root needs.
## Done when
The package is split along the scheme above. After the split: `build`/`vet`/`test`
green, the panel's behaviour unchanged.
## Risks
- Splitting too early — a superfluous internal API and churn with nothing to
show for it;
- leaving it until after the growth — a harder refactor, tangled up with the
features.
+12 -9
View File
@@ -47,17 +47,19 @@ Explicitly excluded to prevent scope creep:
- Inbound mail (IMAP/POP3, mailboxes, delivery to user inboxes)
- Webmail
- Multi-user panel / organisations / roles — one administrator; managing
**multiple sending domains** is in scope (see below)
- Organisations / tenancy beyond global + domain-admin roles; managing
**multiple sending domains** under one global administrator is in scope (see
below)
- Inbound antispam/antivirus (rspamd, ClamAV, etc.)
- A custom MTA — Postfix is used as-is
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only
Agreed **1.x+** extensions (optional inbound relay, domain-admin role) are
tracked in [roadmap.md](roadmap.md) and [plans/](plans/). Inbound relay targets
a 1.x MINOR bump by default; a 2.x major remains possible pending
implementation. Items still marked *candidate* in the roadmap require explicit
approval before coding.
The **domain-admin** role ships in the current line (global administrator plus
domain administrators with assigned domains). The optional **inbound relay** is
the main agreed **1.x+** extension still on the
[roadmap](roadmap.md) — it targets a 1.x MINOR bump by default; a 2.x major
remains possible pending implementation. Items marked *candidate* in the
roadmap require explicit approval before coding.
---
@@ -102,5 +104,6 @@ Adding a domain does **not** create an application automatically.
- **Delete domain** — removes DKIM key and **all** its applications.
- **Delete application** — removes only that app's SASL and map entries.
This is not multi-tenancy (one admin); it is one owner operating several
sending domains with independent application credentials.
This is not multi-tenancy; it is one owner (or a small team with global and
domain-scoped roles) operating several sending domains with independent
application credentials.
+28 -49
View File
@@ -26,15 +26,14 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| ID | Topic | Status | Plan |
|---|---|---|---|
| domain-admin | Domain administrator role | **agreed** | [plans/domain-admin.md](plans/domain-admin.md) |
| inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | [plans/inbound-relay.md](plans/inbound-relay.md) |
| contributing | `CONTRIBUTING.md` | candidate | — |
| visual-style | Обновление визуального стиля | candidate | — |
| dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | [plans/dmarc-reports.md](plans/dmarc-reports.md) |
| panel-docs | In-panel operator documentation | candidate | — |
**Recommended order** (not binding): **domain-admin →
inbound-relay** — role-wide authorisation first, then the inbound relay vertical
slice. Deviating is allowed; there are no hard phases here.
**Recommended order** (not binding): **inbound-relay** first among agreed
items — it is the largest remaining 1.x+ extension. Candidates need explicit
agreement before they join the queue.
After a context reset, pick an item marked `agreed` or `in progress`, then work
the checklist in its linked plan.
@@ -57,33 +56,11 @@ engine stays outside the image, only the attachment point is provided.
**Dependencies / risks:** a finished outbound path; open relay and backscatter;
a wider attack surface (port 25 accepting mail).
**Order:** recommended after [domain-admin](plans/domain-admin.md).
**Version:** target bump `1.x`; `2.x` possible — to be settled once the
implementation lands.
---
## domain-admin
**Goal:** a role with access to one or several assigned domains (the list is
set by the global administrator) — applications, DKIM/DNS, and the send log for
each of them; without global operations (adding domains, full backup, the
queue, `mail.log`).
**Boundary:** an extension of v1.0 — [product.md](product.md) fixes a single
administrator. Not a second all-powerful admin, but limited access to the
assigned domains (one or several).
**Done when:** see [plans/domain-admin.md](plans/domain-admin.md).
**Dependencies / risks:** a users table, the role in the session, authorisation
in every handler, setup and backup. **Order:** recommended **before**
[inbound-relay](plans/inbound-relay.md).
**Version:** `1.x` MINOR, given a compatible migration of the current
administrator into a global one.
---
## contributing
**Goal:** `CONTRIBUTING.md` in the root — the dev loop, the checks to run
@@ -102,28 +79,6 @@ priority.
---
## visual-style
**Goal:** refresh the control panel's visual design — typography, colour tokens,
spacing, and component styling — without changing operator workflows or panel
behaviour.
**Boundary:** presentation only (`panel.css`, templates, static assets); no new
features. Styling must stay compatible with the panel CSP — rules live in
`panel.css`, not inline (see [security.md](security.md) and the stylesheet
header).
**Done when:** the panel reflects an agreed visual direction (starting
reference: [assets/selfpost-proof.html](assets/selfpost-proof.html)); light and
dark schemes remain supported; readability and contrast are preserved.
**Dependencies / risks:** CSP constraints on how styles are applied; visual
regression across pages; low priority relative to functional work — take up
after explicit agreement, independently of the feature roadmap order.
**Version:** no bearing on semver.
---
## dmarc-reports
**Goal:** SelfPost receives DMARC aggregate reports (RFC 7489) on SMTP,
@@ -148,3 +103,27 @@ inbound-relay depending on how port 25 acceptance is structured.
**Version:** `1.x` MINOR.
---
## panel-docs
**Goal:** built-in operator documentation in the panel — short pages (or a
help drawer) that explain what each Status check and other controls mean,
without sending the operator out to `docs/guide.md`.
**Boundary:** in-panel help only; not a second copy of the full operator guide.
Seed content includes the Status blurbs removed from the cards in favour of a
denser layout — Machine (kernel counters / rate window), TLS certificate
(port 465, reverse-proxy mount), Hostname / reverse DNS (forward-confirmed
reverse DNS, PTR at the hosting provider), and similar notes for other panel
surfaces as they lose inline commentary.
**Done when:** an operator can open help from the panel for those topics; the
removed Status blurbs are preserved there (or equivalent); no requirement to
read the git tree for day-to-day meaning of a card.
**Dependencies / risks:** copy ownership and translation; keeping help in sync
when checks change; not bloating every page with a second column of prose.
**Version:** `1.x` MINOR; `candidate` until explicitly agreed.