[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).
15 KiB
SelfPost — development
What this file is. How to build, test, document, and ship changes. Open work after 1.0 (1.x+) lives in roadmap.md and linked plans/. Product boundaries: product.md. As-built layout: architecture.md.
Resuming work
After /clear or a fresh chat:
- Read this file (process, docs rules, model routing).
- Open roadmap.md for the index of open work; follow the linked plan file for the active item. Accepted risks — security.md; as-built — architecture.md.
- Skim product.md if scope is in doubt.
- Continue from the next unchecked step in the active plan file (not the roadmap index).
History of closed phases is in git log and CHANGELOG.md,
not duplicated here.
Authorship and disclosure
Decided, not open for re-litigation. SelfPost is written by AI agents under a maintainer's direction, and the project says so rather than hiding it.
Concretely, this is what "says so" means, and none of it is an oversight to be tidied away later:
Co-Authored-By: Claude <model>trailers stay in commit messages, including the ~140 commits that predate v1.0.- The model routing table below is public, in a file the README links to.
- .cursor/rules/agent-rules.mdc ships in the repository.
- Process notes written for an agent — "after a context reset, pick an item
marked
agreed" in roadmap.md — stay as they are.
Why not quietly drop it. Once the trailers are in the history, removing the routing table or the rules file would not conceal authorship, it would only make the project look like it was trying to. Partial concealment reads worse than the plain statement, and the plain statement costs nothing: the code is reviewed, tested, and shipped under the same rules either way, and the security design records what was audited and what was accepted.
Revisit if: the disclosure ever conflicts with the licence or a downstream obligation — not because the convention around AI authorship shifts.
Model routing
| Kind of work | Model | Examples |
|---|---|---|
Security, infra, file permissions, Postfix/postqueue, open-relay risk |
Opus | mail.log under /data, entrypoint permissions, queue reconcile |
| UI / JS / CSS, templates, documentation (English), README | Sonnet | adaptive polling, this file's Documentation section |
| Trivial mechanics: retarget links, grep, compose bump, CHANGELOG cut | Haiku | Makefile / release.yml comment fixes, deleting closed plan files |
| Security review (not authorship) | Fable | pre-release checklist (CHANGELOG [0.5.0] Security / security.md — done) |
Default rule: risk-critical → Opus; UI / docs / boilerplate → Sonnet; trivial mechanics → Haiku. Reviewers must not be the author of the code under review.
Technology stack and tools
| Component | Version / notes |
|---|---|
| Go | 1.26+ (go.mod); CGO_ENABLED=0 — pure Go, static linking |
| SQLite | modernc.org/sqlite (pure Go, no cgo) |
| Build | Makefile: primary targets vet, test, build, e2e (also all, clean) |
| Container | Docker + Compose v2 on the dev host and in CI |
| Image (build stage) | golang:1.26-bookworm — build/Dockerfile |
| Image (runtime) | debian:bookworm-slim + Postfix, OpenDKIM, supervisord, SASL, logrotate |
| CI | GitHub Actions — .github/workflows/ |
| Image registry | ghcr.io/mixeme/selfpost |
Repository layout (brief; process details in architecture.md):
cmd/panel— HTTP panel + journal-milter + log-tailercmd/selfpost-backup— backup CLI (docker exec … selfpost-backup)internal/— domain logic, store, web, healthbuild/— Dockerfile, supervisord, Postfix/OpenDKIM, entrypointdeploy/—docker-compose.yml, proxy examples,.env.exampletest/e2e/— separate Go module; container integration tests
External libraries
The project is AGPL-3.0 (LICENSE). Copyright holder and third-party notices: NOTICE. New Go dependencies must be permissive or GPL-family (see .cursor/rules/agent-rules.mdc).
Main module (go.mod)
| Package | Version | Repository | License |
|---|---|---|---|
github.com/emersion/go-milter |
v0.4.1 | https://github.com/emersion/go-milter | BSD-2-Clause |
golang.org/x/crypto |
v0.54.0 | https://github.com/golang/crypto | BSD-3-Clause |
modernc.org/sqlite |
v1.53.0 | https://gitlab.com/cznic/sqlite (mirror: https://github.com/modernc-org/sqlite) | BSD-3-Clause |
Transitive dependencies — go mod graph / go.sum; all indirect packages in
the tree are AGPL-3.0-compatible.
Vendored front-end
| Asset | Version | Repository | License |
|---|---|---|---|
internal/web/view/static/htmx.min.js |
2.0.4 | https://github.com/bigskysoftware/htmx | 0BSD |
E2e module (test/e2e/go.mod)
| Package | Version | Repository | License |
|---|---|---|---|
github.com/emersion/go-msgauth |
v0.6.8 | https://github.com/emersion/go-msgauth | BSD-2-Clause |
The test module is not part of the main go build graph and is not shipped in
the image.
Debian packages in the runtime image
Postfix, OpenDKIM, supervisord, sasl2-bin, logrotate, and others come
from Debian bookworm repositories; licenses are in each package's copyright
file on https://packages.debian.org/bookworm/.
The image also ships LICENSE and NOTICE under
/usr/share/doc/selfpost/. The panel serves the AGPL text at /license.
Building binaries and the image
Local binaries
Requires Go 1.26+ and CGO_ENABLED=0.
make build # bin/panel, bin/selfpost-backup (VERSION=dev by default)
make build VERSION=1.2.3
Or directly:
go build -trimpath -ldflags "-X github.com/mixeme/selfpost/internal/buildinfo.Version=dev" -o bin/panel ./cmd/panel
The version is stamped into both binaries via -ldflags and must match the
Docker image tag — restore checks backup version compatibility.
Docker image
From the repository root:
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). 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 § Image and processes.
Commits and release build
Commit on every meaningful step (a working sub-feature, a green build, end of a
phase) — not every file save, and not only at phase end. Minimum: one commit per
closed phase, plus intermediate commits for coherent sub-steps. Branch main
unless a separate branch is requested. Push / PR only on explicit request.
Commit messages end with
Co-Authored-By: Claude <model> <noreply@anthropic.com> for the model that did
the step (e.g. Claude Sonnet 4.6).
Every such step also updates CHANGELOG.md under
[Unreleased] (Keep a Changelog). On an explicit version cut, rename
[Unreleased] to [X.Y.Z] - date and open a fresh empty [Unreleased]. Image
tag / push only on explicit request (see release.yml).
Release image
The release image is published only on tag vX.Y.Z (not on every push to
main). The tag is the single source of version: it drives the image tag and
-ldflags in the binaries so they cannot drift apart.
Steps (on explicit request):
- Close
[Unreleased]in CHANGELOG.md and bump the pinned tag in deploy/docker-compose.yml (and any local-trial image references) in the same release commit. - Create and push git tag
vX.Y.Zon that commit. - Workflow release.yml builds, e2e-gates,
and publishes
ghcr.io/mixeme/selfpost:X.Y.Z.
Ordinary commits do not publish an image. The compose pin and the git tag
must match (1.0.0 / v1.0.0 for the first published release). Intermediate
CHANGELOG sections (0.2.0…0.6.0) record development history before that cut.
Phase closure
Before /clear at the end of a finished step:
- Update roadmap.md (and the active plan checklist, if any): what changed, what is next.
- Check the applicable «Done when…» criteria.
- Append CHANGELOG.md under
[Unreleased]. - Make the final commit for the step (when the user asks for a commit).
Testing
Static analysis and unit tests
Main module (go test ./...); e2e is a separate module — see below.
make vet # go vet ./...
make test # go test ./...
Or directly:
gofmt -l . # in CI — fails on drift
go vet ./...
go test ./...
Env documentation regression
go test ./cmd/panel -run 'TestLoadConfigKeysDocumented|TestBuildScriptKeysDocumented|TestDocumentedKeysAreRead'
— every new loadConfig key must appear in the env lists in guide.md
(cmd/panel/envdoc_test.go).
End-to-end (container suite)
Separate Go module test/e2e/; not included in the main module's
go test ./....
make e2e
# same as: cd test/e2e && go test -v -timeout 20m ./...
Stack: deploy/docker-compose.yml +
test/e2e/compose.override.yml — same
cap_drop/cap_add/no-new-privileges as production. Override: high ports
(20465/20587/20080), test hostname, self-signed TLS,
PANEL_COOKIE_SECURE=false, isolated compose project. Mail is hermetic:
CoreDNS (fake zone) + Postfix smtp-sink as sink-MX; DKIM TXT is scraped from
the panel and published into the zone — the test verifies the records an
operator would actually use.
Coverage (summary): bootstrap → SMTP AUTH → delivery → DKIM verify →
send-log queued → sent; negatives (no AUTH, relay, sender/login mismatch,
L1/L2 limits, milter fail-open, bad SELFPOST_HOSTNAME, session survives
docker restart). Polling with timeouts only — no fixed sleep.
Requires Docker + Compose v2 on the machine running the suite.
CI
Workflows in .github/workflows/. What each job runs — § Testing above.
test.yml — every push and PR to main
gofmt -l → go vet ./... → go test ./... (main module, no e2e).
release.yml — push of tag vX.Y.Z or workflow_dispatch
prepare (version from tag)
→ build [matrix: ubuntu-latest / ubuntu-24.04-arm]
→ docker build --load (VERSION from tag)
→ e2e (test/e2e)
→ push ghcr.io/...:X.Y.Z-amd64 | X.Y.Z-arm64
→ merge
→ docker buildx imagetools create → unified manifest X.Y.Z
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 the bytes that passed the gate.
A failed e2e blocks image publication.
Documentation
History of deleted plans lives in git and CHANGELOG.md.
There is no docs/archive/ directory.
Documentation map
| Home | File |
|---|---|
| Operator install / quick start | README.md |
| Operator guide | guide.md |
| Product boundaries | product.md |
| As-built design | architecture.md |
| Development process (this file) | development.md |
| Security requirements and accepted risks | security.md |
| Roadmap (1.x+) | roadmap.md |
| Active design plans | plans/ |
| Release history | CHANGELOG.md |
User-facing deliverables
| Artefact | Role |
|---|---|
| README.md | Overview, requirements, quick start, docs index, reference deploy, licence |
| guide.md | Proxy, env, DNS, IP warmup, operations, rate limiting, backup, ports, image tag |
| SECURITY.md | Private reporting channel, supported versions, scope |
| LICENSE | AGPL-3.0 full text |
| NOTICE | Copyright holder and third-party attributions |
| deploy/docker-compose.yml + proxies | Apache + nginx/Caddy/Traefik under deploy/ |
| deploy/.env.example | Public env template; full reference in guide.md |
| CHANGELOG.md | Keep a Changelog |
Out of scope for v1.x: CONTRIBUTING.md, man pages, a separate docs site
(candidates in roadmap.md).
Maintaining documentation
- Step rule: a new or renamed env key, panel route, or observable mail-path
behaviour ships together with guide.md /
.env.exampleand a CHANGELOG entry (see § Commits and release build). - Env regression: cmd/panel/envdoc_test.go
fails on an undocumented
loadConfigor build-script key. - New gaps go into roadmap.md (or the active plan file), not silent drive-by edits.
Verifying docs against code
Every claim in the docs has a source of truth in the tree; verify from code to prose.
| Claim class | Source of truth |
|---|---|
| Env keys and defaults | loadConfig — cmd/panel/main.go; ${VAR:-…} in build/ |
| Mail path | build/postfix-config.sh |
| Panel routes | internal/web/web.go |
| Backup / restore, domain export | internal/backup/, cmd/selfpost-backup/ |
| Sessions | internal/store/sessions.go, internal/web/auth/session.go |
| Log rotation, reload | build/logrotate-mail.conf, build/logrotate-loop.sh, build/postfix-cert-reload.sh |
| Deploy | deploy/docker-compose.yml, build/Dockerfile |
| Operator checklist | § User-facing deliverables; detail — guide.md |
| Product / out of scope | product.md |
| As-built | architecture.md |
| Mandatory security | security.md |
Order: list what the code actually does → find it in guide.md /
architecture.md. Before every tag, a short pass over this table — not a full
prose rewrite.