Cut without asking, as the owner said on 2026-10-15. The image runs debian:trixie-slim with Postfix 3.10.13, libmilter 8.18.1, OpenSSL 3.5; every citation re-read against the trixie sources. smtpd_hide_client_session=yes on 465/587 (owner's decision). The selfpost group is 10998 and the panel 10999:10999, outside the system range; the entrypoint re-owns /data by name. The panel's database is 0600; the entrypoint removes symbolic links the panel could leave under /data. Commits68ec8b2,1c6e04b,a3050af. Rolled out to the test server as 2.6.1-post.a3050af (the same code), 2026-10-15 17:07 UTC: healthy, every program RUNNING, no restarts, three messages sent, the queue empty, no warning but outside password guessing. The canary: one message from an application through 465 to Gmail, Yandex and the test mailbox - Received: in the short form everywhere; Gmail dkim=pass spf=pass dmarc=pass; Yandex spf=pass dkim=pass, not spam. No migration, schema 11. CHANGELOG cut; pin 2.7.0 in deploy/docker-compose.yml, README, guide (and its sample restore message); development.md "from pin" and the rows of base-trixie and panel-dkim-signing; roadmap row and recommended order. No tag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
618 lines
29 KiB
Markdown
618 lines
29 KiB
Markdown
# 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](roadmap.md) and linked
|
||
[plans/](plans/). Product boundaries: [product.md](product.md). As-built layout:
|
||
[architecture.md](architecture.md).
|
||
|
||
---
|
||
|
||
## Resuming work
|
||
|
||
After `/clear` or a fresh chat:
|
||
|
||
1. Read this file (process, docs rules, model routing).
|
||
2. Open [roadmap.md](roadmap.md) for the index of open work; follow the linked
|
||
plan file for the active item. Accepted risks — [security.md](security.md);
|
||
as-built — [architecture.md](architecture.md).
|
||
3. Skim [product.md](product.md) if scope is in doubt.
|
||
4. 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](../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](../.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](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](security.md) 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](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.
|
||
|
||
**Composer → Opus → Fable** (when a plan specifies it): **Composer** writes all
|
||
implementation for that item (code, tests, docs); **Opus** does the technical
|
||
review of the result; **Fable** does the security review. A plan's checklist
|
||
overrides the default routing table for that item only.
|
||
|
||
---
|
||
|
||
## Plan checklists
|
||
|
||
Open [roadmap.md](roadmap.md) items with a linked plan file carry an
|
||
**Implementation checklist** in [plans/](plans/). After a context reset, work
|
||
the **active** plan — not the roadmap prose — from the first unchecked line.
|
||
|
||
**Checklist format**
|
||
|
||
- `- [ ] Step description — **Model**` (model from the routing table above).
|
||
- Mark `[x]` only in the commit that actually finishes the step.
|
||
- `candidate` items: checklist may exist, but no code until the roadmap status
|
||
is agreed.
|
||
|
||
**Progress column** in the roadmap index (`0/N`) counts every checklist line in
|
||
that plan (including docs and `go vet` / `go test` steps). Update `N` when the
|
||
checklist changes; update the numerator when steps are checked off.
|
||
|
||
### Roadmap track: commit and version
|
||
|
||
For the current 1.x+ roadmap queue ([roadmap.md](roadmap.md) index), stricter
|
||
than the default «meaningful step» rule in [§ Commits and release build](#commits-and-release-build):
|
||
|
||
**After each checklist step**
|
||
|
||
1. Check off the step in the plan file; bump `Progress` in the roadmap index.
|
||
2. Append [CHANGELOG.md](../CHANGELOG.md) under `[Unreleased]` for that step.
|
||
3. Commit on `main` (push / tag / Release only on explicit request).
|
||
|
||
**After each roadmap stage** (all checklist steps done; «Done when» satisfied)
|
||
|
||
1. Version cut in one commit: rename `[Unreleased]` to `[X.Y.Z] - date`, open a
|
||
fresh `[Unreleased]`, bump the image pin in
|
||
[deploy/docker-compose.yml](../deploy/docker-compose.yml).
|
||
2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see
|
||
[§ Release image](#release-image)).
|
||
|
||
**Planned version cuts** (from pin `2.7.0`; adjust if semver changes mid-track):
|
||
|
||
| Stage | ID | Cut |
|
||
|---|---|---|
|
||
| 1 | queue-retries | `1.3.1` (PATCH) |
|
||
| 2 | inbound-relay | `1.4.0` |
|
||
| 3 | send-log-retention | `1.5.0` |
|
||
| 4 | domain-stats-auto-ratelimit | `1.6.0` |
|
||
| 5 | dmarc-reports | `1.7.0` |
|
||
| 6 | panel-docs | `1.8.0` |
|
||
| 7 | application auth IPs | `1.9.0` |
|
||
| 8 | port-25 Postfix startup fix | `1.9.1` (PATCH) |
|
||
| 9 | 2026-08-19 code review pass | `1.9.2` (PATCH) |
|
||
| 10 | panel-redesign prep (docs only, owner-requested cut) | `1.9.3` (PATCH) |
|
||
| 11 | 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` (MAJOR, no compatibility) |
|
||
| 14 | DMARC ingest drops and bounds, full backup from the panel, no queue in a backup | `2.0.1` (PATCH) |
|
||
| 15 | inbound-antispam-panel | `2.1.0` (was `1.10.0`, reordered 2026-10-09) |
|
||
| 16 | delivery-log-storage | `2.2.0` |
|
||
| 17 | panel-notifications | `2.3.0` |
|
||
| 18 | from-header-binding | `2.4.0` |
|
||
| 19 | code audit 2026-10-12 and its plan (docs only, owner-requested cut) | `2.4.1` (PATCH) |
|
||
| 20 | audit-2026-10-12 stage 0: release hygiene | `2.4.2` (PATCH) |
|
||
| 21 | audit-2026-10-12 stages 1 and 2 | `2.5.0` |
|
||
| 22 | audit-2026-10-12 stage 2b: the log tailer | `2.5.1` |
|
||
| 23 | audit-2026-10-12 stage 2c: the sender check reads From: as OpenDKIM does | `2.5.2` |
|
||
| 24 | audit-2026-10-12 stage 3: persistence and restore | `2.5.3` |
|
||
| 25 | audit-2026-10-12 stage 4: panel behaviour | `2.5.4` |
|
||
| 26 | audit-2026-10-12 stage 5: image and Postfix hardening | `2.6.0` |
|
||
| 27 | port25-bare-newline | `2.6.1` (PATCH) |
|
||
| 28 | base-trixie | `2.7.0` |
|
||
| 29 | panel-dkim-signing | `2.8.0` |
|
||
|
||
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.
|
||
|
||
---
|
||
|
||
## 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](../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-trixie` (bookworm until `2.7.0`) — [build/Dockerfile](../build/Dockerfile). The tag floats to the newest 1.26 patch release, and most standard-library security fixes (`net/http`, `html/template`, …) arrive only that way: rebuild the image after each Go patch release, no edit needed. Dependencies are raised by hand (`go get …@latest`, `go mod tidy`), at least at each audit |
|
||
| **Image (runtime)** | `debian:trixie-slim` (bookworm until `2.7.0`) + Postfix, OpenDKIM, supervisord, SASL, logrotate |
|
||
| **CI** | GitHub Actions — [.github/workflows/](../.github/workflows/) |
|
||
| **Image registry** | `ghcr.io/mixeme/selfpost` |
|
||
|
||
**Repository layout** (brief; process details in [architecture.md](architecture.md)):
|
||
|
||
- `cmd/panel` — HTTP panel + journal-milter + log-tailer
|
||
- `cmd/selfpost-backup` — backup CLI (`docker exec … selfpost-backup`)
|
||
- `internal/` — domain logic, store, web, health
|
||
- `build/` — Dockerfile, supervisord, Postfix/OpenDKIM, entrypoint
|
||
- `deploy/` — `docker-compose.yml`, proxy examples, `.env.example`
|
||
- `test/e2e/` — **separate Go module**; container integration tests
|
||
|
||
---
|
||
|
||
## External libraries
|
||
|
||
The project is **AGPL-3.0** ([LICENSE](../LICENSE)). Copyright holder and
|
||
third-party notices: [NOTICE](../NOTICE). The tree does not use per-file
|
||
`SPDX-License-Identifier` headers; AGPL-3.0 does not require them. New Go
|
||
dependencies must be permissive or GPL-family (see
|
||
[.cursor/rules/agent-rules.mdc](../.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 |
|
||
| `internal/web/view/static/ibm-plex-*.woff2` | latin subset | <https://github.com/IBM/plex> | SIL OFL 1.1 (`OFL.txt` beside the files) |
|
||
| `internal/web/view/static/bulma.min.css` | 1.0.4 | <https://github.com/jgthms/bulma> | MIT |
|
||
| `internal/web/view/static/tabler-icons.css` | 3.49.0 (57-icon subset) | <https://github.com/tabler/tabler-icons> | MIT |
|
||
| `internal/web/view/static/tabler-icons.woff2` | 3.49.0 (57-icon subset) | <https://github.com/tabler/tabler-icons> | MIT |
|
||
|
||
### 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 trixie repositories; licenses are in each package's `copyright`
|
||
file on <https://packages.debian.org/trixie/>.
|
||
The image also ships [LICENSE](../LICENSE), [NOTICE](../NOTICE), and the IBM
|
||
Plex [OFL.txt](../internal/web/view/static/OFL.txt) under
|
||
`/usr/share/doc/selfpost/`. The panel serves the AGPL text at `/license` and
|
||
the OFL text at `/static/OFL.txt`.
|
||
|
||
### Code that relies on another component's behaviour
|
||
|
||
The owner's rule (2026-10-14): what another program does is read in its
|
||
documentation or its source - never guessed. SelfPost reads Postfix's log
|
||
lines and reply codes, feeds OpenDKIM's tables, parses `postqueue`,
|
||
`postconf`, `supervisorctl` and `db_dump` output, talks the milter protocol
|
||
through go-milter, relies on SQLite's locking, on logrotate's and Docker's
|
||
behaviour. Every block of code that does so carries a comment that says
|
||
what it relies on and where that is written, for the version the image runs
|
||
(the Debian trixie package, not upstream's head):
|
||
|
||
```go
|
||
// Postfix 3.10.13, src/smtpd/smtpd_check.c,
|
||
// reject_auth_sender_login_mismatch(): the SASL login is compared with
|
||
// each owner in sender_login_maps by strcasecmp_utf8.
|
||
```
|
||
|
||
The Debian sources of the image's versions are fetched with `apt-get source`
|
||
inside a `debian:trixie` container (`postfix`, `opendkim`, `cyrus-sasl2`,
|
||
`supervisor`, `logrotate`, and `sendmail` for libmilter); `dev/upstream/`
|
||
(not in git) is where they are kept for reading, and `dev/upstream-bookworm/`
|
||
holds the bookworm ones the citations named before `2.7.0`. Go modules are
|
||
read in the module cache at the version `go.mod` pins.
|
||
|
||
The image takes `debian:trixie-slim` and installs without pinning (pinned
|
||
Debian versions leave the mirrors), so a rebuild after a point or security
|
||
release runs packages the citations were not read against. The versions
|
||
read are these; the e2e stand compares them with `dpkg-query` in the image
|
||
it built (`checkCitedPackageVersions`), and a difference fails the run -
|
||
re-read what changed, update the citations, then this table:
|
||
|
||
<!-- cited-package-versions -->
|
||
| Package | Version read |
|
||
|---|---|
|
||
| `postfix` | `3.10.13-0+deb13u1` |
|
||
| `opendkim` | `2.11.0~beta2-9.1+b1` |
|
||
| `libopendkim11` | `2.11.0~beta2-9.1+b1` |
|
||
| `libmilter1.0.1` | `8.18.1-6+b1` |
|
||
| `libsasl2-2` | `2.1.28+dfsg1-9` |
|
||
| `sasl2-bin` | `2.1.28+dfsg1-9` |
|
||
| `supervisor` | `4.2.5-3` |
|
||
| `logrotate` | `3.22.0-1` |
|
||
<!-- /cited-package-versions -->
|
||
|
||
A document instead of source: its URL and section. Where neither answers the
|
||
question, the comment says so and what was measured instead - "measured on
|
||
the e2e stand, 2026-10-14: ..." - and a measurement is never written as if
|
||
it were read. A test fixture copied from a real log says where and when it
|
||
was seen; one composed by hand says that it was composed. The heuristics
|
||
review of 2026-10-14 (audit-2026-10-12, stage 2c) brought the code that
|
||
existed then up to this rule.
|
||
|
||
---
|
||
|
||
## Building binaries and the image
|
||
|
||
### Local binaries
|
||
|
||
Requires Go 1.26+ and `CGO_ENABLED=0`.
|
||
|
||
```sh
|
||
make build # bin/panel, bin/selfpost-backup (VERSION=dev by default)
|
||
make build VERSION=1.2.3
|
||
```
|
||
|
||
Or directly:
|
||
|
||
```sh
|
||
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:
|
||
|
||
```sh
|
||
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](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](../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** for a SemVer version `X.Y.Z`: a
|
||
**published** GitHub Release whose tag is `vX.Y.Z`, or a `workflow_dispatch`
|
||
that supplies that version. Pushing a git tag alone does not publish. Ordinary
|
||
commits, and a dispatch from `main` without a version input, do not publish.
|
||
The version is the single source that drives the image tag and `-ldflags` in
|
||
the binaries so they cannot drift apart. The e2e gate rebuilds the image it
|
||
then pushes, so `release.yml` hands it the version as `SELFPOST_E2E_VERSION`;
|
||
the suite's `image_stamped_with_version` and a check right before the push
|
||
both compare `panel -version` and `selfpost-backup -version` with it. (Until
|
||
`2.4.2` the rebuild stamped every published image `e2e`; those images stay as
|
||
they are.)
|
||
|
||
**Steps (on explicit request):**
|
||
|
||
1. Close `[Unreleased]` in [CHANGELOG.md](../CHANGELOG.md) and bump the pinned
|
||
tag in [deploy/docker-compose.yml](../deploy/docker-compose.yml) (and any
|
||
local-trial image references) in the **same** release commit. While no
|
||
image of the current line is published, README, guide § Fixed image tag
|
||
and the compose file's header say so and show the local build under the
|
||
pinned name; bump the version there too. The first publication of the
|
||
line removes those notes.
|
||
2. Create and push git tag `vX.Y.Z` on that commit.
|
||
3. Publish the GitHub Release for `vX.Y.Z` (not a draft).
|
||
4. Workflow [release.yml](../.github/workflows/release.yml) builds, e2e-gates,
|
||
and publishes `ghcr.io/mixeme/selfpost:X.Y.Z` (checks out tag `vX.Y.Z`).
|
||
|
||
**GitHub Release vs GHCR.** The public [Releases](https://github.com/mixeme/selfpost/releases)
|
||
page lists only **published** releases. A draft is visible to maintainers only —
|
||
it looks like “no releases” to everyone else. CI does not create or publish the
|
||
GitHub Release; you do that in the UI. Deleting a release’s git tag on GitHub
|
||
(or re-pushing tags while cleaning the registry) converts a published release
|
||
back into a **draft** — that matches “I published three times and it keeps
|
||
disappearing”. After publish, leave the tag on GitHub; clean up only unwanted
|
||
GHCR package versions, not the git tag.
|
||
|
||
Push workflow and source changes to **github.com/mixeme/selfpost** before
|
||
publishing — Actions reads that repo, not Gitea.
|
||
|
||
**Gitea → GitHub tag mirror.** If every tag push from Gitea is mirrored to
|
||
GitHub, two things follow:
|
||
|
||
1. **GitHub Release tags must not be deleted on GitHub.** Many mirror setups
|
||
prune remote tags that are absent on Gitea (or re-push with `--force` /
|
||
`--prune`). Deleting `v1.0.0` / `v1.3.0` on GitHub converts a published
|
||
Release back to draft. Mirror **branches and new tags forward**; do not
|
||
delete release tags on the GitHub side. GHCR cleanup is package versions in
|
||
the UI — not `git push github --delete` and not tag prune on the mirror.
|
||
|
||
2. **Tag push runs the workflow file at that tag's commit**, not `main`. `v1.0.0`
|
||
still points at a commit whose `release.yml` has `on: push: tags` and no
|
||
per-arch GHCR cleanup — every mirror (re)push of that tag can republish
|
||
`1.0.0-amd64` / `1.0.0-arm64`. Tags from `v1.3.0` onward only run
|
||
`release.yml` on **Publish release** (`release: published`), so mirroring
|
||
those tags alone does not start the image build.
|
||
|
||
Safe mirror: push tags to GitHub without deleting existing ones; keep release
|
||
tags on Gitea; publish the GitHub Release on github.com after the mirror has
|
||
the tag.
|
||
|
||
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:
|
||
|
||
1. Update [roadmap.md](roadmap.md) (and the active plan checklist, if any): what
|
||
changed, what is next.
|
||
2. Check the applicable «Done when…» criteria.
|
||
3. Append [CHANGELOG.md](../CHANGELOG.md) under `[Unreleased]`.
|
||
4. 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.
|
||
|
||
```sh
|
||
make vet # go vet ./...
|
||
make test # go test ./...
|
||
```
|
||
|
||
Or directly:
|
||
|
||
```sh
|
||
gofmt -l . # in CI — fails on drift
|
||
go vet ./...
|
||
go test ./...
|
||
```
|
||
|
||
### Env documentation regression
|
||
|
||
`go test ./cmd/panel -run 'TestEnvironment'` compares what the code reads with
|
||
what the documents name ([cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go)).
|
||
Read: the `envDefault` / `envInt` / `envBool` / `envSeconds` / `envDuration` /
|
||
`os.Getenv` / `os.LookupEnv` calls in `cmd/panel` and `internal` (tests aside),
|
||
and `${VAR:-…}` in `build/*.sh`. Named: [guide.md](guide.md) § Environment
|
||
variables (the table and the paragraph under it) and the internal list in
|
||
[architecture.md](architecture.md) § Configuration. A variable read and named
|
||
nowhere fails the first test; one named and read nowhere fails the second.
|
||
|
||
### Panel pages: the component kit and its guards
|
||
|
||
Every panel page is built from the component kit in
|
||
[internal/web/view](../internal/web/view): partials in
|
||
`templates/components.html`, their typed input in `components.go`, one stylesheet
|
||
`static/panel.css`, one layout `layout.html`. A page template calls partials with
|
||
a struct from a `view.New…` constructor and writes no component markup of its
|
||
own ([architecture.md § Panel HTTP surface](architecture.md#panel-http-surface)).
|
||
The guard tests (`internal/web/guard_*_test.go`, `internal/web/view/guard_*_test.go`)
|
||
run in `go test ./...` and read their rules from the accepted mockups under
|
||
[docs/assets/panel-redesign/panel/](assets/panel-redesign/index.html): which
|
||
classes and icons exist, what `panel.css` may contain, what component skeleton
|
||
each page has, which routes exist. A failing guard means the page is wrong, not
|
||
the guard.
|
||
|
||
Changing the design is mockup first: edit the fragment under `panel/src/`, run
|
||
`build.py` and `check.py --write`, commit as `design: …`, get it accepted, and
|
||
only then touch `internal/web`. CI enforces the order with `design-first`
|
||
([.github/scripts/design-first.sh](../.github/scripts/design-first.sh)): a commit
|
||
that changes the mockups, a guard or the script touches nothing else under
|
||
`internal/web/`, and a template that returns from git history is refused.
|
||
[plans/panel-redesign.md](plans/panel-redesign.md) § The contract and
|
||
§ Enforcement have the full rules.
|
||
|
||
### End-to-end (container suite)
|
||
|
||
Separate Go module `test/e2e/`; **not** included in the main module's
|
||
`go test ./...`.
|
||
|
||
```sh
|
||
make e2e
|
||
# same as: cd test/e2e && go test -v -timeout 20m ./...
|
||
```
|
||
|
||
**Stack:** [deploy/docker-compose.yml](../deploy/docker-compose.yml) +
|
||
[test/e2e/compose.override.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, and the image's
|
||
`VERSION` from `SELFPOST_E2E_VERSION` (unset locally: `e2e`). 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, the panel down or hung mid-message (Postfix defers the mail
|
||
rather than letting it through: `panel_down_defers_mail`,
|
||
`panel_hung_mid_message_is_counted`), bad `SELFPOST_HOSTNAME`, session
|
||
survives `docker restart`); startup checks that supervisord actually brought up
|
||
OpenDKIM, the panel, and Postfix (`checkSupervisorProcesses`), plus logrotate
|
||
config-mode and forced-rotation checks (`checkLogrotateConfigMode`,
|
||
`checkLogrotateRotation` — [test/e2e/logrotate_check.go](../test/e2e/logrotate_check.go)).
|
||
Waits poll with a timeout (`waitFor`); a fixed sleep is the exception and says
|
||
why where it stands (`sender_check_test.go`: a downtime worth a second or two,
|
||
and 35 s to show a row is not written again).
|
||
|
||
Requires **Docker + Compose v2** on the machine running the suite.
|
||
|
||
---
|
||
|
||
## CI
|
||
|
||
Workflows in [.github/workflows/](../.github/workflows/). What each job runs —
|
||
[§ Testing](#testing) above.
|
||
|
||
### `test.yml` — every push and PR to `main`
|
||
|
||
`design-first` (needs the full history) → `gofmt -l` → `go vet ./...` →
|
||
`go test ./...` (main module, no e2e; includes the panel guard tests, see
|
||
[§ Panel pages](#panel-pages-the-component-kit-and-its-guards)) → the e2e
|
||
module compiled, not run (`go vet ./...` and `go test -c -o /dev/null .` in
|
||
`test/e2e`).
|
||
|
||
### `release.yml` — published GitHub Release, or `workflow_dispatch` with SemVer
|
||
|
||
Publishing a GitHub Release runs `release.yml` directly (`release: published`,
|
||
same pattern as gosentry / imap-scrub). You can also run it manually via
|
||
`workflow_dispatch` with an explicit `X.Y.Z` input. A bare git tag push does not
|
||
run the workflow. The build always checks out `vX.Y.Z`, not `main` HEAD.
|
||
|
||
`prepare` takes the version from `github.event.release.tag_name` on a release
|
||
event, or from the `workflow_dispatch` `version` input. A dispatch whose input
|
||
is missing or not `X.Y.Z` fails in `prepare`.
|
||
|
||
```
|
||
release: published
|
||
prepare (version from release tag or workflow_dispatch input; checkout vX.Y.Z)
|
||
→ build [matrix: ubuntu-latest / ubuntu-24.04-arm]
|
||
→ docker build --load (VERSION from prepare)
|
||
→ e2e (test/e2e; rebuilds selfpost:e2e with SELFPOST_E2E_VERSION=X.Y.Z)
|
||
→ check panel / selfpost-backup -version = X.Y.Z
|
||
→ push ghcr.io/...:X.Y.Z-amd64 | X.Y.Z-arm64
|
||
→ merge
|
||
→ docker buildx imagetools create → unified manifest X.Y.Z
|
||
→ GitHub Packages API → drop untagged versions no tagged manifest references
|
||
```
|
||
|
||
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. Each release leaves three tags in GHCR:
|
||
`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.
|
||
|
||
---
|
||
|
||
## Documentation
|
||
|
||
History of deleted plans lives in git and [CHANGELOG.md](../CHANGELOG.md).
|
||
There is no `docs/archive/` directory.
|
||
|
||
### Documentation map
|
||
|
||
| Home | File |
|
||
|---|---|
|
||
| Operator install / quick start | [README.md](../README.md) |
|
||
| Operator guide | [guide.md](guide.md) |
|
||
| Product boundaries | [product.md](product.md) |
|
||
| As-built design | [architecture.md](architecture.md) |
|
||
| Development process (this file) | [development.md](development.md) |
|
||
| Security requirements and accepted risks | [security.md](security.md) |
|
||
| SQLite schema and migrations | [schema-migrations.md](schema-migrations.md) |
|
||
| Roadmap (1.x+) | [roadmap.md](roadmap.md) |
|
||
| Active design plans | [plans/](plans/) |
|
||
| Release history | [CHANGELOG.md](../CHANGELOG.md) |
|
||
|
||
### User-facing deliverables
|
||
|
||
| Artefact | Role |
|
||
|---|---|
|
||
| [README.md](../README.md) | Overview, requirements, quick start, docs index, reference deploy, licence |
|
||
| [guide.md](guide.md) | Proxy, env, DNS, IP warmup, operations, rate limiting, backup, ports, image tag |
|
||
| [SECURITY.md](../SECURITY.md) | Private reporting channel, supported versions, scope |
|
||
| [LICENSE](../LICENSE) | AGPL-3.0 full text |
|
||
| [NOTICE](../NOTICE) | Copyright holder and third-party attributions |
|
||
| [deploy/docker-compose.yml](../deploy/docker-compose.yml) + proxies | Apache + nginx/Caddy/Traefik under [deploy/](../deploy/) |
|
||
| [deploy/.env.example](../deploy/.env.example) | Public env template; full reference in [guide.md](guide.md) |
|
||
| [CHANGELOG.md](../CHANGELOG.md) | Keep a Changelog |
|
||
|
||
Out of scope for v1.x: `CONTRIBUTING.md`, man pages, a separate docs site
|
||
(candidates in [roadmap.md](roadmap.md)).
|
||
|
||
### Maintaining documentation
|
||
|
||
1. **Step rule:** a new or renamed env key, panel route, or observable mail-path
|
||
behaviour ships together with [guide.md](guide.md) / `.env.example` and a
|
||
CHANGELOG entry (see [§ Commits and release build](#commits-and-release-build)).
|
||
2. **Env regression:** [cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go)
|
||
fails on a variable the code or a build script reads that no document names,
|
||
and on one a document names that nothing reads.
|
||
3. **New gaps** go into [roadmap.md](roadmap.md) (or the active plan file), not
|
||
silent drive-by edits.
|
||
4. **Schema changes:** update [schema-migrations.md](schema-migrations.md) in the
|
||
same change as a new `internal/store/migrations/*.sql` file.
|
||
|
||
### 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](../cmd/panel/main.go); `${VAR:-…}` in [build/](../build/) |
|
||
| Mail path | [build/postfix-config.sh](../build/postfix-config.sh) |
|
||
| Panel routes | [internal/web/web.go](../internal/web/web.go) |
|
||
| Backup / restore, domain export | [internal/backup/](../internal/backup/), [cmd/selfpost-backup/](../cmd/selfpost-backup/) |
|
||
| Sessions | [internal/store/sessions.go](../internal/store/sessions.go), [internal/web/auth/session.go](../internal/web/auth/session.go) |
|
||
| Log rotation, reload | [build/logrotate-mail.conf](../build/logrotate-mail.conf), [build/logrotate-loop.sh](../build/logrotate-loop.sh), [build/postfix-cert-reload.sh](../build/postfix-cert-reload.sh) |
|
||
| Deploy | [deploy/docker-compose.yml](../deploy/docker-compose.yml), [build/Dockerfile](../build/Dockerfile) |
|
||
| Operator checklist | [§ User-facing deliverables](#user-facing-deliverables); detail — [guide.md](guide.md) |
|
||
| Product / out of scope | [product.md](product.md) |
|
||
| As-built | [architecture.md](architecture.md) |
|
||
| Mandatory security | [security.md](security.md) |
|
||
|
||
Order: list what the code actually does → find it in [guide.md](guide.md) /
|
||
`architecture.md`. Before every tag, a short pass over this table — not a full
|
||
prose rewrite.
|