20 Commits

Author SHA1 Message Date
mix 97a0c7e508 licence,ci: ship OFL with Plex fonts and guard release dispatch (P5)
test / test (push) Has been cancelled
IBM Plex WOFF2 files were shipped without the SIL OFL 1.1 text, NOTICE
told modifiers to edit layout.html for a Source URL that lives in
legal.go, and workflow_dispatch took GITHUB_REF_NAME as the version so a
run from main would publish ghcr.io/...:main.

Closes P5 of docs/plans/code-review.md. CHANGELOG updated.

Co-Authored-By: Cursor Grok 4.6 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 09:47:33 +03:00
mix b87baa6dd6 fix: pin logrotate config mode in image and fail loud on bad permissions
test / test (push) Has been cancelled
COPY --chmod makes /etc/logrotate.d/mail 0644 regardless of build context
file modes (Windows tar sync). logrotate-loop preflight exits non-zero when
logrotate would ignore the config. E2e covers mode, forced rotation, and a
group-writable context build.

Co-Authored-By: Composer 2.5 <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 00:51:37 +03:00
mix 6c8bf0d3b3 legal: close AGPL packaging gaps
test / test (push) Has been cancelled
Name the copyright holder, ship NOTICE with the image, serve the licence
from the panel footer on every page, and record the vendored htmx licence.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 21:56:51 +03:00
mixeme 178424eaf6 release: cut 1.0.0
Pin compose and local trial to ghcr.io/mixeme/selfpost:1.0.0, close the
CHANGELOG cut, and retire implementation-plan / v1.x-closure-plan.

Includes the post-cut startup fixes needed for a green release e2e gate:
root-owned TLS copies for postfix check, maillog_file_prefixes for /data,
hostname gate and traversable /data, panel /healthz before setup, and
setup-token / TempDir reclaim via docker exec.

Co-Authored-By: Composer <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 12:33:29 +03:00
mixeme 1f548dfc7a fix(logtail): keep mail.log in /data and reconcile stuck rows (v1.x closure phase 2)
Move the delivery log from the ephemeral /var/log to /data/log/mail.log so
the lines that resolve a queued send-log row survive a container recreate.
postlogd writes it as postfix, the panel reads it through the selfpost group
(dir 2750, file 0640, normalised every start); backups exclude log/.

Close the residual gap with a queue sweep: rows queued for over two minutes
whose id postqueue -p no longer lists are marked bounced. The sweep waits
until the tailer has read the log to its end and does nothing when the queue
cannot be listed, so a message in flight is never touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 11:35:27 +03:00
mixeme 5a5b642eac docs: split README into overview and operator guide for release
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 10:36:24 +03:00
mix c0d9aa7518 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>
2026-08-06 22:14:13 +03:00
mix 670982fb3e docs/chore: Phase 1 doc/code hygiene (code-review.md § Phase 1)
Removes ~30 stale "Phase N" / historical-staging comment references from
code and shell scripts now that v1.0 is done; fixes a stale dashboard
comment claiming applications/send-log were unimplemented; adds a CSRF ADR
to security.md documenting the Origin-check-over-tokens decision; resolves
docs/logo in roadmap.md (directory doesn't exist, criterion already met);
adds a gofmt -l check to CI so unformatted Go fails the build.

The known-limitations write-up for the log-tailer offset gap (the other
Phase 1 item) was already present in architecture.md § Log tailer, so no
change was needed there.

gofmt/go vet/go test clean on both Go modules (main + test/e2e), verified
on the dev server.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 16:13:36 +03:00
mix baaed5991b docs: D6-D9 — HEALTHCHECK, env regression test, new docs, archive spec
Add Docker HEALTHCHECK and mail-path /healthz liveness; env-doc regression
test; architecture.md and development.md; product.md and expanded security.md;
retire live specification.md to docs/archive/.

Co-Authored-By: Claude <claude-opus-5-thinking-high@noreply@anthropic.com>
2026-08-05 00:33:49 +03:00
mix 3eb3e94bce feat: implement B.3 — fatal SELFPOST_HOSTNAME check in entrypoint.sh
Unset or malformed hostname makes the panel and Postfix diverge on SASL
realm silently (auth breaks for every application) and breaks HELO/PTR
matching (spam), so entrypoint.sh now exits before postfix-config.sh /
supervisord with an explanatory error, plus a syntax check rejecting
missing dots, schemes, ports, and whitespace.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 23:53:14 +03:00
mix db1572d7ad feat: implement B.2 — rotate mail.log by rename + postfix reload
Replaces copytruncate with rename + `postfix reload` (the same mechanism
`postfix logrotate` itself uses), closing the up-to-one-second window where
copytruncate could drop in-flight delivery lines and leave a send-log row
stuck at "queued" forever.

logrotate-mail.conf keeps `create 0644 root root` rather than `nocreate` as
originally planned: verified on a live container that Postfix recreates the
file itself only lazily, on the next write after reload, and at mode 0600 —
unreadable by the unprivileged panel process. `create` hands the file back at
0644 immediately after rename, before Postfix ever touches it.

logtail.follow() re-drains the old file descriptor once more right before
switching to the rotated file, closing the residual gap between the last
poll's drain and the rotation check. readLogTail() treats a momentarily
missing mail.log as an empty screen rather than a logged error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 23:45:15 +03:00
mix 7b4549a35d panel: server status page, per-domain DNS checks, /domains move
Phase 13. Two new packages and one new screen.

internal/health owns the shared status vocabulary (ok/warn/error/unknown)
and the local checks: supervisord's process table, TLS certificate expiry
and the two milter sockets. Each check reports a problem as a status rather
than an error, so one broken component costs a line and not the page.

internal/dnscheck does the read-only lookups: forward-confirmed reverse DNS
for SELFPOST_HOSTNAME, and per-domain DKIM (compared against the key this
server actually signs with), SPF and DMARC. Every check is bounded by a
timeout and cached, and the resolver sits behind an interface so the tests
drive every branch without touching the network. The SPF check is
deliberately shallow: it looks for a mechanism literally covering the
server's address and does not follow include:/redirect=, so a record that
authorises us through an include is reported as "cannot tell" rather than
as a failure.

/status renders both, with the local checks in an HTMX-polled fragment and
the DNS lookups behind a Re-check button, and becomes the panel's landing
page: / now redirects there and the domain list lives at /domains. The
Reload button moves onto /status, where it reads as what it is — a
drift-recovery for the daemons — with text explaining what it regenerates.
A template test fails on any remaining href="/" so a stale link cannot
silently land on the wrong screen.

Also fixes a defect this made visible: the panel could never read the mail
queue in the documented deployment. postqueue relies on its setgid-postdrop
bit, which the compose file's no-new-privileges disables, so the Queue
screen always said "Could not read the mail queue" — including in the
released 1.0.0 image. The panel user is now a real member of postdrop,
which needs no setgid transition.

Verified in a container on the dev server against real DNS: PTR matching
(selfpost.example.com) and not matching (example.com), DKIM absent and
mismatched, SPF absent and via include:, DMARC p=quarantine/p=reject/absent,
and a resolver timeout degrading to "unknown" without hanging the page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-01 22:04:37 +03:00
mix 61f525e2d7 Phase 10: deployment (Apache compose + proxy fragments, CI release) + docs
- deploy/docker-compose.yml: pinned-tag ghcr image, hardened (cap_drop ALL +
  minimal cap_add, no-new-privileges, panel bound to 127.0.0.1 only). Apache
  itself runs on the host (spec 10.5), fragment at deploy/apache/.
- Alternative reverse-proxy fragments: nginx (+certbot sidecar), Caddy
  (automatic ACME), Traefik (+acme.json PEM extraction script).
- .github/workflows/release.yml: tag-triggered ghcr.io publish, version piped
  from the git tag into both the binary ldflags and the image tag (spec 10.1).
- Closed a gap from Phase 1: logrotate was installed but never invoked;
  wired up build/logrotate-mail.conf + logrotate-loop.sh + a supervisor
  program (copytruncate, since postlogd holds mail.log open with nothing to
  signal on rotation).
- README rewritten: site requirements checklist, reverse-proxy comparison,
  DNS setup (server- vs domain-level), IP warmup, backup/restore vs domain
  export/import, fixed-tag rationale, machine requirements.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 21:23:19 +03:00
mix f88d8dabcb Phase 9: full backup/restore + domain export/import (spec 7.5, 11.6)
Full server backup (spec 7.5.A): internal/backup produces a tar.gz of all of
/data — a consistent SQLite snapshot via VACUUM INTO, DKIM keys, sasldb2 and a
version manifest; TLS certs (tls/) and the Postfix queue are excluded. Two equal
paths: the panel button (POST /backup, no-store) and the selfpost-backup CLI via
docker exec (spec 11.6). CheckRestore runs before store.Open: a manifest version
mismatch refuses to boot with the image tag to use; a match consumes the
manifest so it only guards the first post-restore boot. Restore is not a
separate branch — Postfix/OpenDKIM regenerate from the restored SQLite as on any
start.

Domain export/import (spec 7.5.B): DomainExport carries the DKIM private key and
each application's working password. SASL secrets are read from sasldb2 via
db_dump (the userPassword property is plaintext) and, on import, re-keyed under
the local realm with saslpasswd2 — so credentials keep working on an instance
with a different hostname, with no DKIM DNS change. Import validates and rolls
back atomically on any failure. db-util (db_dump) is now an explicit image dep.

Verified on the server (selfpost:p9): gofmt/vet/test green; container e2e for
cross-realm domain export/import (SMTP AUTH 235 under the new realm), CLI and
panel backups, same-version restore, and version-mismatch refusal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:33:07 +03:00
mix 6ebb6f56d6 Phase 6: journal-milter + send-log status tailer + retention
Implement the structured send log (spec 7.3), the project's highest-risk
component since a milter bug can break the relay itself.

- internal/milter: go-milter v0.4.1 journal-milter. Per-connection session
  collects SASL login, From, recipients and Subject across callbacks and
  writes one send_log "queued" row per (queue-id, recipient) at EOM
  (spec 7.3.3). Monitoring only: callbacks return Continue/Accept, recorder
  errors are logged never propagated, so it can never block mail.
- internal/logtail: polling mail.log tailer with rotation handling (inode
  change / truncation), parses sent/deferred/bounced/expired by queue-id +
  recipient and advances rows; background retention sweep prunes rows past
  SEND_LOG_RETENTION_DAYS (default 90) at startup and every 6h.
- internal/store/sendlog.go: InsertQueued, UpdateStatus (case-insensitive
  recipient match), DeleteSendLogBefore + status constants.
- cmd/panel: open the store once and share it across http/milter/tailer;
  replace the journal/logtail stubs with the real roles.
- build/postfix-config.sh: bounded milter timeouts (15/15/30s) so a hung
  milter also fails open in seconds, not the 300s default.

Fix found in-container: SASL login (app_login) was empty because go-milter
keys macros exactly as Postfix sends them, and multi-character macro names
arrive brace-wrapped ({auth_authen}); the SASL-less Phase 0 spike could not
observe this. Added a brace-tolerant macro lookup.

Verified on selfpost.example.com: gofmt/vet/unit tests green; container e2e
records rows with correct fields and advances status via the tailer; fail-open
confirmed for both an unreachable and a hung milter; retention prunes at start.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:58:34 +03:00
mix 2c7f0da3d8 Phase 5: full outbound Postfix relay (465/587 SASL+TLS, sender binding, milters)
Generate the relay config from the environment at container start
(build/postfix-config.sh, run from entrypoint.sh):

- smtps 465 (implicit TLS) primary + optional submission 587 (STARTTLS),
  chroot=n so smtpd reaches the sasldb2/sender map under /data.
- Cyrus SASL against the panel-maintained sasldb2; realm left implicit so the
  authenticated name equals the bare login in smtpd_sender_login_maps.
- reject_sender_login_mismatch + relay/recipient restrictions with no
  permit_mynetworks: credentials-only, open relay impossible (spec 5, 5.1).
- TLS cert/key from TLS_CERT_FILE/TLS_KEY_FILE; daily postfix reload picks up
  renewed certs (postfix-cert-reload.sh under supervisord, spec 5.2).
- anvil level-1 rate limit from env (spec 5 p.5).
- Milter chain with per-milter action: OpenDKIM strict (tempfail), journal
  fail-open (accept) so monitoring never blocks the relay (spec 7.3).

Two integration fixes found on the server:
- postconf -F '*/*/chroot=n': Debian's chrooted delivery agent can't read
  /etc/resolv.conf, so MX lookups failed and mail never left.
- entrypoint sets /run/opendkim and /run/selfpost to group selfpost + setgid,
  and the journal stub chmods its socket 0660, so postfix can connect to both
  milter sockets (strict OpenDKIM was milter-rejecting all mail otherwise).

Verified on selfpost.example.com: gofmt/vet/test green, image builds; container
e2e — 465 auth+send DKIM-signed (d=domain,s=selfpost), 587 STARTTLS auth,
cross-domain sender 553, list-mode per-address binding, unauth relay 554,
real outbound delivery reaching the recipient MX over TLS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:37:16 +03:00
mix c6eeb30258 Phase 4: applications + SASL (sasldb2) + sender_login_maps
Adds application accounts bound to domains: a SASL login/password in
sasldb2, a per-application address mode (wildcard @domain or an explicit
list), and matching smtpd_sender_login_maps bindings — with create,
list, edit-mode, delete and password regeneration (spec 4.1, 5.1,
7.2.5-9). Generated passwords are shown exactly once and never stored in
plaintext (7.6.1).

- internal/store/applications.go: transactional CRUD; globally unique
  login; ListBindings (address->login) as the map source; logins-by-
  domain for pre-cascade SASL cleanup.
- internal/app: saslpasswd2 wrapper (password via stdin, login as a
  whitelisted argv element, no shell — 7.6.3); strong base64url password;
  address validation that enforces domain ownership before any config
  write (7.6.2); service orchestrating store + sasldb2 + map with full
  rollback on partial failure.
- internal/postfix: sender_login_maps regenerated as a pure function of
  the registry (many-to-one logins merged per address), atomic write,
  injection backstop (7.6.4).
- Postfix reload, corrected: `postfix start-fg` forks a separate master,
  so signalling the supervised process never reaches it. Reload now runs
  the canonical `postfix reload` via a one-shot supervisord program the
  unprivileged panel triggers over the group control socket. Verified in
  mail.log.
- domain.Service.Delete purges the domain's SASL accounts, then cascades,
  then rebuilds the sender map and reloads; manual reload now covers both
  OpenDKIM and Postfix.
- web: application management in the domain page, one-time credential
  shown inline; postfix joins the selfpost group and entrypoint normalises
  /data/sasl and /data/postfix (setgid, group-readable) with self-heal.

Verified on the dev server: gofmt/vet/test green, image builds, and a
container e2e covers the full application lifecycle, domain-delete
cascade, restart persistence, and a real postfix reload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 21:18:10 +03:00
mix a7a5ad3f91 Phase 3: sending domains + per-domain OpenDKIM signing
Add/list/delete of sending domains with per-domain DKIM keys and the
OpenDKIM tables that drive signing (spec 6, 7.2.2-4, 7.2.10).

internal/domain:
- Pure-Go RSA-2048 keygen; PKCS#1 PEM written atomically at 0640; the
  published DNS TXT record is derived from the key on disk (single source
  of truth) rather than persisted. No os/exec for key generation.
- KeyTable/SigningTable fully regenerated from the registry on every
  add/delete (idempotent), written atomically; SigningTable via refile:
  with *@domain, KeyTable with absolute key paths. Table writer refuses
  any unsafe character as a backstop (spec 7.6.4).
- Reload without root: the unprivileged panel signals OpenDKIM through
  supervisord (`supervisorctl signal USR1 opendkim`, fixed args, no
  shell, no user input — spec 7.6.3). An existing key is reused, never
  overwritten, so re-adding a domain keeps its published DNS valid.
- Service orchestrates registry -> key -> table rebuild -> reload, with
  rollback of the row if a downstream step fails; delete cascades apps
  via the DB FK and removes the key + table entries.

Infra:
- Shared `selfpost` group bridges panel (writes keys) and opendkim
  (reads them); /data/opendkim is setgid so panel-created files inherit
  the group, keys are 0640, RequireSafeKeys is disabled by design.
- opendkim.conf moves from verify-only (Mode v) to signing (Mode s).
- entrypoint.sh normalises the DKIM tree on every start (ownership,
  setgid, perms, empty tables before opendkim starts) — self-healing
  after a restore.
- supervisord control socket opened to the `selfpost` group so the panel
  can request the reload.

web/store:
- Strict domain-name validation (whitelist [a-z0-9.-], DNS shape, >=2
  labels), lower-case normalisation (spec 7.6.2).
- Domain queries with application counts; delete relies on ON DELETE
  CASCADE. Dashboard lists domains + add form; domain page shows the
  DKIM record; a dedicated confirm page warns about the app cascade
  before deletion (spec 7.2.4); manual reload button (spec 7.2.12,
  OpenDKIM side; Postfix reload lands in Phase 5).
- Authenticated routes moved to a sub-mux using Go 1.22 method/wildcard
  patterns.

Tests: validateDomain, DKIM keygen/record roundtrip, table rendering +
injection-safety, key reuse, store cascade. Verified on the dev server:
gofmt/vet/test green, image builds, container e2e (add/delete a domain,
DKIM record shown, OpenDKIM reads panel keys and reloads, keys and
tables persist across a restart).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:20:35 +03:00
mix d72a383904 Phase 2: SQLite persistence, admin setup-link, login/sessions
Implements the secure single-admin panel entry (spec 7.6).

- internal/store: modernc.org/sqlite (pure Go, static build), WAL +
  foreign keys, embedded PRAGMA user_version migrations; schema 0001
  covers admin/settings/domains/applications/send_log/rate_limits (spec 9).
- Setup secret-link (spec 7.6.1): 128-bit crypto/rand token, printed to
  log + /data/setup-token (0600), 10-min TTL with regeneration, per-IP
  rate limit, subtle.ConstantTimeCompare, failures don't invalidate,
  one-time admin form, permanent invalidation once admin exists (/setup 404).
- bcrypt admin password; server-side username/password validation.
- Login + in-memory sessions, crypto-random token, cookie
  HttpOnly/Secure/SameSite (Secure toggleable for dev HTTP), login
  rate limit, auth middleware.
- html/template base layout + setup/login/dashboard, vendored htmx 2.0.4.
- build/entrypoint.sh: fix bind-mounted /data ownership as root before
  supervisord drops to the unprivileged panel user (found via container test).

Verified on selfpost.example.com: go vet/build/test/gofmt clean; e2e curl
of setup+login flows; docker build + run with -v ./data:/data creates the
DB and 0600 token owned by panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 21:24:09 +03:00
mix e6aceeb811 Phase 1: Docker image, supervisord, three-process cold start
Single bookworm-slim image running opendkim + panel + postfix under
supervisord with enforced start ordering (spec 4):

- build/Dockerfile: multi-stage static Go build; runtime installs postfix,
  opendkim, cyrus-sasl, supervisor, logrotate; unprivileged panel user (7.6.8).
- build/supervisord.conf: priority ordering opendkim -> panel -> postfix;
  crashexit event listener terminates the container on any FATAL process.
- build/postfix-wrapper.sh: waits for both milter sockets (test -S, 30s
  timeout) before `postfix start-fg`, exits non-zero on timeout.
- panel: HTTP :8080 stub + /healthz, journal-milter socket stub (so the
  wrapper's readiness probe passes), log-tailer stub; SIGTERM graceful stop.

Verified on the dev server: image builds, three processes live, panel serves
the stub, wrapper waits for sockets, and an unrecoverable panel failure brings
the container down cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 15:14:08 +03:00