Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,259 +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) (done), 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.auth.RequireAuth(authed))` wrapper), and the session carries
|
||||
nothing beyond the fact of being signed in.
|
||||
|
||||
Two panel roles:
|
||||
|
||||
| Role | Scope |
|
||||
|------|-------|
|
||||
| **global** | Full panel except nothing new — same powers as today's single admin |
|
||||
| **domain_admin** | Only **assigned** domains (one or several; list set by global admin) |
|
||||
|
||||
For each assigned domain, a domain-admin can:
|
||||
|
||||
- applications (create, sender mode, password regeneration, delete, L2 limit);
|
||||
- DKIM/DNS status and recheck;
|
||||
- per-domain DMARC `rua=` (inherit / none / custom) — full control on the
|
||||
domain page;
|
||||
- send log filtered to assigned domains;
|
||||
- domain export (encrypted `.spde` optional, same as today);
|
||||
- domain-level L2 rate limit.
|
||||
|
||||
What stays **global-only** (domain-admin gets 404 or redirect):
|
||||
|
||||
- adding and removing domains;
|
||||
- domain import;
|
||||
- creating/editing/deleting panel users and assigning domains;
|
||||
- `/reload`;
|
||||
- full backup (`/backup` — all of `/data` including every domain's `sasldb2`);
|
||||
- mail queue (`/mail-queue*`);
|
||||
- system log tail (`/system-log*`);
|
||||
- status page (`/status*`) — server-wide health, queue summary, reload, DNS
|
||||
recheck of the **hostname**; same treatment as queue and system log.
|
||||
|
||||
Domain-admin **self-service** on `/account`: username and password only (not
|
||||
global DMARC report email).
|
||||
|
||||
## 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.)*
|
||||
|
||||
---
|
||||
|
||||
## Schema and migration
|
||||
|
||||
**New migration** `0005_panel_users.sql`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('global', 'domain_admin')),
|
||||
dmarc_report_email TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE user_domains (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, domain_id)
|
||||
);
|
||||
|
||||
-- Migrate existing administrator → global user (idempotent guard via admin count).
|
||||
INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at)
|
||||
SELECT username, password_hash, 'global', dmarc_report_email, created_at
|
||||
FROM admin WHERE id = 1;
|
||||
|
||||
DROP TABLE admin;
|
||||
```
|
||||
|
||||
**Sessions:** keep `sessions.username` (no schema change). On login and
|
||||
`RequireAuth`, resolve username → `User` row (role + domain IDs). Stale session
|
||||
after username change behaves as today (`Lookup` fails → redirect login).
|
||||
|
||||
**Backup/restore:** full backup already snapshots `selfpost.db` via
|
||||
`VACUUM INTO`; users and bindings restore with the DB. No manifest format change
|
||||
required (same `selfpost-full-backup`).
|
||||
|
||||
**DMARC two levels:**
|
||||
|
||||
- **Global** `users.dmarc_report_email` — only on global user's `/account`;
|
||||
default `rua=` when a domain uses *inherit*.
|
||||
- **Per-domain** `domains.dmarc_rua` — domain-admin edits on the domain page
|
||||
(existing handler); domain-admin never sees the global default field.
|
||||
|
||||
---
|
||||
|
||||
## Principal model
|
||||
|
||||
Request context carries a `Principal` (in `internal/web/auth`):
|
||||
|
||||
```go
|
||||
type Role string // "global" | "domain_admin"
|
||||
|
||||
type Principal struct {
|
||||
ID int64
|
||||
Username string
|
||||
Role Role
|
||||
Domains []int64 // assigned domain IDs; empty for global (meaning "all")
|
||||
}
|
||||
```
|
||||
|
||||
Helpers:
|
||||
|
||||
- `CurrentPrincipal(r)` — from context;
|
||||
- `IsGlobal(p)` — `p.Role == "global"`;
|
||||
- `CanAccessDomain(p, domainID)` — global or `domainID` in `p.Domains`;
|
||||
- `CanAccessApp(p, app)` — `CanAccessDomain(p, app.DomainID)`.
|
||||
|
||||
`lookupDomain` / `lookupApplication` in handlers call `CanAccess*` after
|
||||
existence check; return 404 (not 403) to avoid leaking IDs.
|
||||
|
||||
---
|
||||
|
||||
## Route matrix
|
||||
|
||||
| Method | Path | global | domain_admin |
|
||||
|--------|------|--------|--------------|
|
||||
| GET | `/` | → `/status` | → `/domains` |
|
||||
| GET | `/status`, `/status/fragment` | yes | **no** (404) |
|
||||
| POST | `/status/recheck` | yes | **no** |
|
||||
| GET | `/domains` | all domains | assigned only |
|
||||
| POST | `/domains` | yes | **no** |
|
||||
| POST | `/domains/import` | yes | **no** |
|
||||
| GET | `/domains/{id}` | yes | assigned |
|
||||
| POST | `/domains/{id}/dns-recheck` | yes | assigned |
|
||||
| GET/POST | `/domains/{id}/delete` | yes | **no** |
|
||||
| POST | `/domains/{id}/applications` | yes | assigned |
|
||||
| POST | `/domains/{id}/ratelimit` | yes | assigned |
|
||||
| POST | `/domains/{id}/dmarc` | yes | assigned |
|
||||
| POST | `/domains/{id}/export` | yes | assigned |
|
||||
| POST | `/applications/{aid}/*` | yes | if app in assigned domain |
|
||||
| POST | `/reload` | yes | **no** |
|
||||
| GET/POST | `/account` | username, password, global DMARC email | username, password only |
|
||||
| GET/POST | `/backup` | yes | **no** |
|
||||
| GET | `/deliveries*` | all (optional filter) | clamped to assigned domains |
|
||||
| GET | `/mail-queue*` | yes | **no** |
|
||||
| GET | `/system-log*` | yes | **no** |
|
||||
| GET | `/users` | list users | **no** |
|
||||
| GET/POST | `/users/new` | create user | **no** |
|
||||
| GET/POST | `/users/{uid}` | edit/delete user | **no** |
|
||||
| POST | `/logout` | yes | yes |
|
||||
|
||||
**Deliveries:** for domain-admin, `sendLogData` forces filter to assigned
|
||||
domain set; dropdowns list only assigned domains/apps; reject `domain` query
|
||||
param outside assignment; `HandleDelivery` checks log row's `domain` field.
|
||||
|
||||
---
|
||||
|
||||
## User management UI (global only)
|
||||
|
||||
New routes under `/users`:
|
||||
|
||||
- **List** — username, role, assigned domain names (or "all" for global).
|
||||
- **Create** — username, password, role (`domain_admin` default), multi-select
|
||||
domains (required when role is `domain_admin`).
|
||||
- **Edit** — change password (optional), reassign domains, delete user.
|
||||
- **Guards:** cannot delete the last `global` user; cannot demote self to
|
||||
`domain_admin` without another global user; domain-admin role cannot access
|
||||
these routes.
|
||||
|
||||
Templates: `users.html`, `user_form.html`; nav link visible only for global
|
||||
users.
|
||||
|
||||
---
|
||||
|
||||
## Auth / setup / sessions
|
||||
|
||||
- **Setup** (`/setup/{token}`): unchanged semantics — creates first **global**
|
||||
user via `CreateGlobalUser`; `AdminExists` → `UserExists`.
|
||||
- **Login:** authenticate against `users` by username + bcrypt.
|
||||
- **Password change:** per-user `UpdateUser`; domain-admin cannot change
|
||||
another user's password.
|
||||
- **Session rename / destroy others:** unchanged behaviour keyed by username.
|
||||
|
||||
---
|
||||
|
||||
## Navigation
|
||||
|
||||
[layout.html](../../internal/web/view/templates/layout.html) `nav` template:
|
||||
|
||||
- **global:** all items today (status, domains, deliveries, mail queue, system
|
||||
log, backup, settings) + **Users**.
|
||||
- **domain_admin:** domains, deliveries, settings only.
|
||||
|
||||
Pass `IsGlobal` (or `Principal`) into every rendered page.
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
- **CSRF:** keep origin-check-only for now ([security.md](../security.md) ADR);
|
||||
note in CHANGELOG that multi-user panel reopens the ADR — no CSRF tokens in
|
||||
this phase.
|
||||
- **Export encryption:** optional password on domain export remains; full backup
|
||||
encryption trigger ("second administrator") is satisfied by domain-admin
|
||||
existing — no change required.
|
||||
- **Authorization tests:** table-driven tests for global vs domain-admin on
|
||||
representative handlers; explicit `{aid}` cross-domain mutation blocked.
|
||||
|
||||
---
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. Migration `0005_panel_users.sql` + `store/users.go` (CRUD, domain bindings).
|
||||
2. Auth: login against `users`, `Principal` in context, setup creates global user.
|
||||
3. `CanAccessDomain` / `CanAccessApp`; harden `lookupDomain` / `lookupApplication`.
|
||||
4. Route guards: global-only middleware or per-handler checks.
|
||||
5. Filter lists: dashboard, deliveries, domain detail DMARC inherit source.
|
||||
6. User management handlers + templates.
|
||||
7. Nav visibility + default redirect (`/`).
|
||||
8. Tests + `go build` / `go vet` / `go test`; CHANGELOG `[Unreleased]`.
|
||||
|
||||
---
|
||||
|
||||
## 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,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.1.0]` and `[Unreleased]`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
# Plan: visual-style
|
||||
|
||||
**Status:** agreed
|
||||
**Version:** no bearing on semver — presentation only, no schema and no route
|
||||
changes.
|
||||
**Order:** independent of the feature roadmap; may be taken up between feature
|
||||
items.
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Bring the control panel's surface in line with the mark that was approved in
|
||||
[selfpost-proof.html](../assets/selfpost-proof.html): its palette, its
|
||||
typography, and the plainness of its components. Today the panel is a default
|
||||
blue-on-cool-grey admin theme standing next to a warm brick stamp, so the mark
|
||||
reads as pasted onto someone else's page.
|
||||
|
||||
## Scope
|
||||
|
||||
**In:**
|
||||
- `internal/web/view/static/panel.css` — colour tokens, typography, spacing,
|
||||
every component rule.
|
||||
- `internal/web/view/static/` — three self-hosted font files.
|
||||
- Templates, only where a class has to be added or a wrapper introduced for a
|
||||
rule to have something to attach to.
|
||||
- `NOTICE` — the OFL attribution the font files oblige.
|
||||
|
||||
**Out:**
|
||||
- Any change to what a page does, which pages exist, or what an operator has to
|
||||
click. No new features, no copy rewriting.
|
||||
- The navigation's position and the two-column shell. The proof's panel mock
|
||||
shows a horizontal bar on a dark header; the panel's left column also carries
|
||||
the per-page section index (`.sections` plus the scroll-spy in `panel.js`),
|
||||
which that layout has nowhere to put. Keeping the column is a deliberate
|
||||
divergence from the mock, not an oversight.
|
||||
- The mark files themselves (`logo.svg`, `logo-compact.svg`, `favicon.*`) —
|
||||
already drawn, already converted to outlines.
|
||||
|
||||
## Constraint that shapes everything
|
||||
|
||||
The panel's Content-Security-Policy is a plain `default-src 'self'` with no
|
||||
inline-style exemption ([security.md](../security.md)). Every rule lives in
|
||||
`panel.css`; a `style="..."` attribute in a template is blocked and silently
|
||||
does nothing. Self-hosted fonts are served from the panel's own origin and are
|
||||
therefore already covered — no CSP change is needed, and none may be made.
|
||||
|
||||
## Typography
|
||||
|
||||
IBM Plex, self-hosted. The mark is Plex converted to outlines, so the panel
|
||||
setting its own name in Segoe UI or Cantarell is the seam this whole item
|
||||
exists to close.
|
||||
|
||||
| File | Covers | Size |
|
||||
|---|---|---|
|
||||
| `static/ibm-plex-sans.woff2` | variable, weights 100–700, latin | 45.7 KB |
|
||||
| `static/ibm-plex-mono-400.woff2` | mono regular, latin | 14.8 KB |
|
||||
| `static/ibm-plex-mono-600.woff2` | mono semibold, latin | 15.7 KB |
|
||||
|
||||
76 KB in total, in a 20 MB binary. The variable file replaces what would
|
||||
otherwise be five static weights and lets the scale below use 300 and 500
|
||||
without paying per weight.
|
||||
|
||||
Monospace is the one the operator actually reads: DKIM records, `mail.log`
|
||||
lines, application logins, socket paths, generated passwords. `ui-monospace`
|
||||
resolves to Consolas, SF Mono or DejaVu Sans Mono depending on the operator's
|
||||
machine, and those differ in advance width — the six-column send log wraps
|
||||
differently for each. A shipped mono makes those tables one layout.
|
||||
|
||||
| Role | Family | Size | Weight |
|
||||
|---|---|---|---|
|
||||
| Body | sans | 15px / 1.5 | 400 |
|
||||
| `h1` | sans | 1.55rem, tracking −0.01em | 300 |
|
||||
| `h2` | sans | 1.05rem | 600 |
|
||||
| `label` | sans | 0.9rem | 600 |
|
||||
| Nav entry / active | sans | 0.95rem | 400 / 600 |
|
||||
| `th` | **mono**, uppercase, tracking 0.08em | 0.75rem | 500 |
|
||||
| `.st` status badge | **mono** | 0.78rem | 500 |
|
||||
| `.code`, `.mono`, `.metric` | **mono** | 0.85rem | 400 |
|
||||
|
||||
`font-display: swap`, so a cold load shows the system stack for a frame rather
|
||||
than blank text.
|
||||
|
||||
## Colour tokens
|
||||
|
||||
Names stay as they are wherever they already exist: the dark scheme reassigns
|
||||
the same custom properties, which is why no rule in the file needs
|
||||
`!important`. Warm neutrals replace the cool greys; brick becomes the accent.
|
||||
|
||||
| Token | Light | Dark |
|
||||
|---|---|---|
|
||||
| `--bg` | `#F4F2ED` | `#16181B` |
|
||||
| `--fg` | `#12161C` | `#E9E6E0` |
|
||||
| `--muted` | `#6B7684` | `#9AA1A9` |
|
||||
| `--card-bg` | `#FFFFFF` | `#1D2024` |
|
||||
| `--border` | `#DEDCD7` | `#2C2F34` |
|
||||
| `--control-border` | `#CBC8C1` | `#3A3E44` |
|
||||
| `--input-bg` | `#FFFFFF` | `#14161A` |
|
||||
| `--code-bg` | `#EFEDE9` | `#14161A` |
|
||||
| `--surface-bg` | `#EAE7E0` | `#23262B` |
|
||||
| `--accent-fill` / `--on-accent` | `#7A3B2E` / `#FFFFFF` | `#8E4535` / `#FFFFFF` |
|
||||
| `--accent-text` | `#7A3B2E` | `#CE7B66` |
|
||||
| `--nav-active-bg` | `#EDE4DE` | `#2A1F1B` |
|
||||
|
||||
Brick splits into a fill and a text value because `#7A3B2E` on `#16181B` is
|
||||
about 2:1 — unreadable as a dark-scheme link. The fill lightens just enough to
|
||||
keep white on it above 4.5:1; the text value lightens further.
|
||||
|
||||
Status families (`--st-ok-*`, `--st-warn-*`, `--st-error-*`, `--st-unknown-*`),
|
||||
the flash, the credential card and `--danger-*` keep their hues and are only
|
||||
warmed to sit on paper. The one thing to watch is brick against `st-error` red:
|
||||
the proof rejected several candidate colours precisely so that the mark would
|
||||
not read as a status, and the same test now applies to every brick button
|
||||
standing in a row of `error` badges.
|
||||
|
||||
## Components
|
||||
|
||||
Everything already in `panel.css`, in the order it appears there: card, form
|
||||
controls, buttons (filled, outlined, danger), flash, table, status badge,
|
||||
`.code`, nav (brand, links, sections, session), application list and its
|
||||
disclosure panels, credential card, status page meters and facts, delivery
|
||||
timeline, log tables, split layout, encrypt fields, footer.
|
||||
|
||||
Two component-level changes rather than pure repaints, both forced by the
|
||||
accent:
|
||||
|
||||
- Row actions (`td.actions a.danger`, `Delete`) become outlined instead of
|
||||
filled. A filled red button in a table row next to a filled brick button
|
||||
reads as one block of colour.
|
||||
- Nav entries carry the active state as brick text on a warm tint rather than
|
||||
the current blue tint.
|
||||
|
||||
## Order of work
|
||||
|
||||
1. Fonts into `static/`, `@font-face` and the type scale in `panel.css`,
|
||||
`NOTICE` attribution. Nothing else changes shape.
|
||||
2. Token block: light and dark, both schemes in one pass.
|
||||
3. Chrome: `layout.html`'s nav, footer, shell.
|
||||
4. Signed-out pages: `login`, `setup` — the mark and one card, where the seam
|
||||
is worst.
|
||||
5. `dashboard`, `domain_detail`, `domain_delete`.
|
||||
6. `deliveries`, `deliveries_rows`, `delivery`.
|
||||
7. `status` + `status_body`, `mail_queue*`, `system_log*`.
|
||||
8. `backup`, `account`, `users`, `user_form`, `encrypt_fields`.
|
||||
9. `CHANGELOG.md` under `[Unreleased]`.
|
||||
|
||||
## Verification
|
||||
|
||||
- Every page rendered locally and screenshotted in both schemes before and
|
||||
after (`panel.exe` on Windows, headless Edge), including a 375px-wide pass —
|
||||
the nav column and the wide tables are where a repaint breaks layout.
|
||||
- Contrast: body text and every status badge at 4.5:1 or better against its own
|
||||
background, UI borders at 3:1. Brick on white is 7.3:1 by the proof's own
|
||||
measurement; the dark-scheme values above are the ones to re-check.
|
||||
- `go build ./... && go vet ./... && go test ./...`; the template guards in
|
||||
`internal/web/view/templates_test.go` must stay green, and the static-asset
|
||||
ETag test grows to cover the three font files.
|
||||
- No `style=` attribute anywhere in `templates/` — the CSP would drop it.
|
||||
|
||||
## What is done
|
||||
|
||||
The restyle itself landed in `652f1fe`, with the table-wrapping fixes it
|
||||
surfaced in `f44f533`. Every page in the order above was rendered in both
|
||||
schemes from a panel running locally and checked: the signed-out pair, the
|
||||
domain list empty and with three domains, the whole domain page (credential
|
||||
card, DKIM/SPF/DMARC, DNS status, applications, rate limit, export, danger
|
||||
zone), the delete confirmation, the send log with rows, a single delivery,
|
||||
status, mail queue, system log, backup, settings, users and the user form.
|
||||
|
||||
Two of those need standing in for what the container provides: `supervisorctl`,
|
||||
`saslpasswd2` and `postmap` stubs on `PATH`, `postfix/`, `opendkim/keys/` and
|
||||
`sasl/` created inside the data dir by hand, and rows seeded into `send_log` —
|
||||
without them the domain, application and send-log pages do not exist locally.
|
||||
|
||||
## Outstanding
|
||||
|
||||
Nothing here blocks the item; each is written down so it is not rediscovered.
|
||||
|
||||
1. **Send-log status is bare text**, while every other status in the panel is a
|
||||
`.st` badge. Making it one is not a repaint: it needs a mapping from
|
||||
`sent`/`queued`/`deferred`/`bounced` onto the four badge colours, which is a
|
||||
judgement about severity (is `deferred` a warning?) rather than a style.
|
||||
**Needs a decision before it is written.**
|
||||
2. **The panel overflows horizontally at 375px** — `main` and its cards render
|
||||
wider than the window and the page scrolls sideways. Reproduced with the
|
||||
stylesheet at `f59befd` too, so it predates this work; tracked separately.
|
||||
Removing the navigation does not fix it, so it is in `main`/`.card`, not in
|
||||
the bar the `@media (max-width: 66rem)` block lies down.
|
||||
3. **Three views were only ever seen empty**: the mail queue with entries, the
|
||||
system log with lines, and a delivery's own `mail.log` lines (`table.log`).
|
||||
All three need a running Postfix, so they are a test-server check, not a
|
||||
local one. `table.log` is the only restyled component with no screenshot
|
||||
behind it.
|
||||
4. ~~**CSP and the font ETags**~~ — **done** on the test server at
|
||||
`1.1.0-post.669f928`. The policy is unchanged
|
||||
(`default-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self';
|
||||
frame-ancestors 'none'`) and admits all three fonts, which come back as
|
||||
`font/woff2` with `Cache-Control: no-cache` and a content ETag: a matching
|
||||
`If-None-Match` gets 304, a stale one gets the bytes. The signed-out page
|
||||
renders in Plex over the network.
|
||||
5. **`font-display: swap` has never been observed** — every render had the fonts
|
||||
already on disk. Worth one cold load over the network to see how long the
|
||||
system stack is on screen.
|
||||
|
||||
## Done when
|
||||
|
||||
- The panel and the mark read as one design in both schemes, at the reading
|
||||
measure and on the wide data pages.
|
||||
- Nothing an operator does changed: same pages, same controls, same copy.
|
||||
- Fonts are served from the panel's own origin under the unchanged CSP, and the
|
||||
image works with no network access.
|
||||
- `NOTICE` credits IBM Plex (OFL-1.1); build, vet and tests are green.
|
||||
- The three views in **Outstanding** 3 are seen with real data on the test
|
||||
server, and 4 is confirmed there.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Visual regression across 21 templates.** The panel has pages that are only
|
||||
reachable mid-workflow (the credential card, the delete confirmation, the
|
||||
encrypt fields). Mitigation: the page order above is a checklist, and each
|
||||
step is screenshotted rather than assumed.
|
||||
- **Brick against the error red.** If the two fight in a real row of the send
|
||||
log, the accent gets pulled back to the mark and the buttons stay neutral —
|
||||
the mark's colour is fixed, the panel's accent is the negotiable one.
|
||||
- **Thin weights on dark.** `h1` at 300 is the one place a variable font makes
|
||||
it easy to go too light; check it on the dark scheme before keeping it.
|
||||
@@ -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
@@ -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.
|
||||
|
||||
+3
-52
@@ -26,16 +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 | Panel visual style | **agreed** | [plans/visual-style.md](plans/visual-style.md) |
|
||||
| dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | [plans/dmarc-reports.md](plans/dmarc-reports.md) |
|
||||
| logrotate-mode | `mail.log` stops rotating in some builds | candidate | [plans/logrotate-mode.md](plans/logrotate-mode.md) |
|
||||
|
||||
**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.
|
||||
@@ -58,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
|
||||
@@ -103,30 +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:** see [plans/visual-style.md](plans/visual-style.md). The agreed
|
||||
direction is the mark's own — brick, warm paper, IBM Plex — taken from
|
||||
[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 (rules in
|
||||
`panel.css`, never inline); visual regression across pages; the accent colour
|
||||
must not read as a status badge.
|
||||
**Order:** independent of the feature items; may be taken up between them.
|
||||
**Version:** no bearing on semver.
|
||||
|
||||
---
|
||||
|
||||
## dmarc-reports
|
||||
|
||||
**Goal:** SelfPost receives DMARC aggregate reports (RFC 7489) on SMTP,
|
||||
@@ -150,4 +102,3 @@ source of truth for `rua=` in DNS guidance.
|
||||
inbound-relay depending on how port 25 acceptance is structured.
|
||||
|
||||
**Version:** `1.x` MINOR.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user