chore/docs: move to GitHub as the single home; drop archived-spec references

Codeberg is being retired as the project's public site, so every reference now
points at GitHub. That includes the Go module path (codeberg.org/mix/selfpost →
github.com/mixeme/selfpost): leaving an import path on a host that is going
away would break `go get` and `go install`, so this is not only a docs change.
Touches go.mod, test/e2e/go.mod, all imports, Makefile MODULE, the -ldflags
version stamp in build/Dockerfile and docs/development.md, the licence headers
in the SVG/HTML assets, and README (no more primary/mirror pair).

Comments no longer cite the archived specification. "spec 7.6.1", "spec 5.1"
and friends pointed into docs/archive/specification-v1.0.md, which is marked as
not a source of truth; each is now a reference to the live document that owns
the subject — architecture.md (with section), product.md, security.md or the
README. The review only asked for the 7.x refs (code-review.md § 4), but 4/5/6/
8/9 had the same defect, so they went too. Comments only, no behaviour change.

Also closes the remaining review items: architecture.md gained a Code layers
section with the layer diagram (A2), and TestParseDelivery gained the exotic
mail.log cases (§ 3).

Fixes a bug that last test found: the delivery-line pattern matched status=
greedily, taking the *last* occurrence on the line. Postfix appends the remote
server's reply verbatim, so a rejection whose reply quoted "status=sent" was
filed as a delivered message in the send log. It now takes the first status=
after the recipient, which is the real field.

R7 (CONTRIBUTING.md) moved to roadmap 2.x — one developer, no external PR flow,
so the file would have no audience yet. R1 (compose image tag) and the git tag
stay in roadmap § v1.x as the release-commit steps.

gofmt/go vet clean on both modules; go test ./... green except the three known
Windows-only failures (file perms, backslash paths, renaming an open file).
Not exercised on the dev server — no Docker locally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 22:14:13 +03:00
parent ed0a786739
commit d49351c022
87 changed files with 896 additions and 623 deletions
+28 -24
View File
@@ -1,7 +1,8 @@
// Package postfix owns the Postfix configuration files the panel edits at
// runtime and the privileged reload that applies them (spec 5.1, 7.6.3-4): the
// smtpd_sender_login_maps table binding each application's SASL login to the
// sender addresses it may use, plus the relay configuration in main.cf.
// runtime and the privileged reload that applies them (architecture.md § Mail
// path, security.md): the smtpd_sender_login_maps table binding each
// application's SASL login to the sender addresses it may use, plus the relay
// configuration in main.cf.
package postfix
import (
@@ -38,22 +39,24 @@ func (p *Postfix) SenderLoginMapsPath() string {
return p.senderLoginMapsPath
}
// Binding is one sender-address → login pair (spec 5.1). Address is either a
// domain wildcard "@example.com" or a specific address "alerts@example.com".
// Binding is one sender-address → login pair (architecture.md § Mail path).
// Address is either a domain wildcard "@example.com" or a specific address
// "alerts@example.com".
type Binding struct {
Address string
Login string
}
// RebuildSenderLoginMaps regenerates the sender_login_maps file from the full
// set of bindings and reloads Postfix (spec 5.1). Full regeneration (rather than
// incremental edits) keeps the file a pure function of the registry, so add,
// edit and delete share one idempotent path. The file is written atomically
// before the reload.
// set of bindings and reloads Postfix (architecture.md § Mail path). Full
// regeneration (rather than incremental edits) keeps the file a pure function
// of the registry, so add, edit and delete share one idempotent path. The file
// is written atomically before the reload.
//
// Several applications may be authorised for the same address (many-to-one,
// spec 5.1 §4) — their logins are merged onto a single line as a comma-separated
// list, which is how Postfix expects multiple owners of one sender.
// architecture.md § Mail path) — their logins are merged onto a single line as
// a comma-separated list, which is how Postfix expects multiple owners of one
// sender.
func (p *Postfix) RebuildSenderLoginMaps(bindings []Binding) error {
content, err := renderSenderLoginMaps(bindings)
if err != nil {
@@ -66,7 +69,8 @@ func (p *Postfix) RebuildSenderLoginMaps(bindings []Binding) error {
}
// Reload asks Postfix to re-read its configuration without regenerating any
// file. It backs the panel's manual reload button (spec 7.2.12).
// file. It backs the panel's manual reload button (architecture.md § Panel
// HTTP surface).
func (p *Postfix) Reload() error {
return p.reload()
}
@@ -74,7 +78,7 @@ func (p *Postfix) Reload() error {
// renderSenderLoginMaps builds the sender_login_maps file contents. Keys are
// sorted for deterministic output and the logins under each key are sorted and
// de-duplicated. Every address and login is re-checked for injection safety
// before being written (spec 7.6.4) — upstream validation already guarantees
// before being written (security.md) — upstream validation already guarantees
// this, but the writer refuses to emit anything unsafe as a hard backstop.
func renderSenderLoginMaps(bindings []Binding) ([]byte, error) {
byAddr := make(map[string][]string)
@@ -94,8 +98,8 @@ func renderSenderLoginMaps(bindings []Binding) ([]byte, error) {
for _, addr := range order {
logins := byAddr[addr]
sort.Strings(logins)
// texthash format: <key><whitespace><value>. A comma-separated value
// lists every login permitted to use this sender (spec 5.1 §4).
// texthash format: <key><whitespace><value>. A comma-separated value lists
// every login permitted to use this sender (architecture.md § Mail path).
fmt.Fprintf(&sb, "%s %s\n", addr, strings.Join(logins, ","))
}
return []byte(sb.String()), nil
@@ -113,9 +117,9 @@ func appendUnique(list []string, v string) []string {
// assertMapSafe rejects any address/login value that could break out of a single
// map line or inject a directive. Addresses are validated to a strict whitelist
// (letters, digits, '@', '.', '-', '_', '+') and logins to an even stricter one
// upstream (spec 7.6.2); this is defence in depth against a validation gap ever
// upstream (security.md); this is defence in depth against a validation gap ever
// letting whitespace, a newline or a comma (the value separator) through into
// the file (spec 7.6.4).
// the file (security.md).
func assertMapSafe(address, login string) error {
if address == "" || login == "" {
return fmt.Errorf("postfix: empty address or login")
@@ -130,17 +134,17 @@ func assertMapSafe(address, login string) error {
}
// reloadViaSupervisor asks supervisord (PID 1, running as root) to run the
// one-shot `postfix-reload` program, which executes the canonical
// `postfix reload` and re-reads main.cf/master.cf and the lookup tables they
// reference. The panel runs unprivileged: it cannot run `postfix reload` itself,
// and it cannot signal the Postfix master directly because `postfix start-fg`
// forks a separate master whose PID supervisord does not track (a SIGHUP to the
// one-shot `postfix-reload` program, which executes the canonical `postfix
// reload` and re-reads main.cf/master.cf and the lookup tables they reference.
// The panel runs unprivileged: it cannot run `postfix reload` itself, and it
// cannot signal the Postfix master directly because `postfix start-fg` forks a
// separate master whose PID supervisord does not track (a SIGHUP to the
// supervised process would never reach it). Going through supervisord's
// group-accessible control socket runs the reload as root without any panel
// privilege (spec 5.2, 7.2.12, 7.6.3, 7.6.8).
// privilege (architecture.md § Mail path, security.md).
//
// Arguments are fixed literals — no user input is interpolated into the command,
// and it never goes through a shell (spec 7.6.3).
// and it never goes through a shell (security.md).
func reloadViaSupervisor() error {
cmd := exec.Command("supervisorctl",
"-c", "/etc/supervisor/supervisord.conf",
+1 -1
View File
@@ -7,7 +7,7 @@ import (
func TestRenderSenderLoginMaps(t *testing.T) {
// Deliberately unsorted, with two logins sharing one wildcard key
// (many-to-one, spec 5.1 §4) to exercise merge + sort.
// (many-to-one, architecture.md § Mail path) to exercise merge + sort.
bindings := []Binding{
{"@zeta.example", "z1"},
{"alerts@alpha.example", "a-listed"},
+6 -6
View File
@@ -6,12 +6,12 @@ import (
"strings"
)
// Queue returns Postfix's own human-readable mail-queue listing (spec 7.2.11):
// active, deferred and held messages, exactly as an administrator would see
// via the CLI. The command takes a single fixed flag and no user input, so it
// never goes through a shell (spec 7.6.3). The panel is responsible for
// escaping the output before display (spec 7.6.7); this function returns it
// as-is.
// Queue returns Postfix's own human-readable mail-queue listing
// (architecture.md § Panel HTTP surface): active, deferred and held messages,
// exactly as an administrator would see via the CLI. The command takes a
// single fixed flag and no user input, so it never goes through a shell
// (security.md). The panel is responsible for escaping the output before
// display (security.md); this function returns it as-is.
func Queue() (string, error) {
cmd := exec.Command("postqueue", "-p")
out, err := cmd.CombinedOutput()
+1 -1
View File
@@ -8,7 +8,7 @@ import (
// writeFileAtomic writes data to path via a temp file in the same directory
// followed by a rename, so a concurrent Postfix reload only ever sees the
// complete old or new map, never a partial write (spec 7.6.4). It mirrors the
// complete old or new map, never a partial write (security.md). It mirrors the
// same primitive used for the OpenDKIM tables.
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)