Document stopped-container tar backup with WAL warning and manifest
consumption; refresh status banner and port-587 note; align
implementation-plan B.1 with actual session behaviour on password change.
Co-authored-by: Cursor <cursoragent@cursor.com>
Keeps implementation-plan.md focused on unresolved v1.0/v1.x questions;
inbound relay (Phase O1) and the domain-admin role now live in
docs/roadmap.md, cross-linked from progress.md.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The documentation is part of the deliverable (spec 11.5/11.7/11.9), so it has
to describe what the code does, not what was intended. Adds
docs/documentation-plan.md: the package inventory against the spec, the
per-claim sources of truth in the tree, the results of a first cross-check
pass (11 findings, most notably the missing "operations" section required by
spec 11.7, the absent env-var reference, .env.example's dangling link to a
README "Rate limiting" section, and the unwritten "tar while stopped" backup
path from spec 9), and tasks D1-D7 gating the next release tag.
progress.md points at it so it survives a context reset.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Separate test/e2e Go module drives the shipped deploy/docker-compose.yml
(plus a test-only override: self-signed cert, low ports, isolated compose
project) against a fake DNS zone (CoreDNS) and an smtp-sink MX, exactly as
an administrator and their applications would over HTTP/SMTP — covering the
class of failure unit tests can't see (container wiring). Positive path:
setup -> login -> domain -> DKIM record published into the fake zone ->
application -> SMTP AUTH send -> DKIM verified against the DNS-published
key -> send-log queued->sent. Negative: no-AUTH/unauthenticated relay,
sender/login mismatch, L1 (anvil) and L2 (panel) rate limits, journal-milter
fail-open, SELFPOST_HOSTNAME gate, session survives docker restart.
release.yml moves off qemu to a native per-arch build (amd64/arm64), each
gated by this suite before its tag is pushed and merged into the version
manifest.
Verified green on selfpost.mixfed.ru via `make e2e`; go vet/gofmt clean in
both modules.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
Sessions move from an in-memory map (absolute 12h TTL) to a `sessions`
table (migration 0002), storing only the SHA-256 of the token. Expiry is
now a sliding idle window (PANEL_SESSION_IDLE_DAYS, default 7, no
absolute cap), extended at most once an hour and never by the
monitoring screens' background polling (GET + HX-Request), so a
forgotten open tab doesn't keep a session alive indefinitely. A login
now survives a container restart or redeploy.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The plan holds undone work; an accepted risk is a decision, not a task
— it has no place in a queue, only a condition for revisiting it. Both
risks (POST with neither Sec-Fetch-Site nor Origin, no session-bound
CSRF tokens) move verbatim into a new docs/security.md, which also
states where D.5 findings land. Section letters and item numbering in
the plan stay as they were, since progress.md and the commit history
reference them; a note in their place points at the new file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A single security pass once B.1-B.3 and C.4 are in, not four per-item
passes: those four rewrite authentication, log-file handling, the
entrypoint gate and the release workflow, so what matters is the final
state. Scope is the whole diff since v1.0.0 (phases 12-14 included)
plus a fresh walk over spec 7.6, run by Fable rather than Opus so the
reviewer is not the author. Findings are either fixed before the tag
or recorded in section A as accepted risks; together with the C.4 e2e
this gates tagging. The old section D (2.x pointer) becomes E, its
items renumbered 6 and 7.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2FA is dropped from the 2.x candidate list. "Multiple administrators"
is narrowed to one concrete role: an admin scoped to a single domain
(its applications, DKIM/DNS status, filtered send log), with the
globally scoped actions — domain add/delete, reload, full backup,
queue and mail.log tail — left out of it. Still 2.x: a second panel
subject contradicts the out-of-scope list in spec section 3, so it
needs agreement and a spec change before any code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The unit tests all fake the process boundary, so the class of failure that
actually broke this project — container wiring: chroot vs DNS, milter socket
permissions, the SASL realm, the Postfix reload path, cap_add — is invisible
to them. Record the decision to close it with a hermetic containerised e2e:
a separate Go module under test/e2e/, driven against the shipped compose file
plus an override, with a fake DNS zone and an smtp-sink standing in for the
outside world.
It runs before tagging (make e2e on the dev server, plus workflow_dispatch)
and gates image publication on the tag itself, which pulls release.yml off
qemu onto a native amd64/arm64 matrix: build, test, push per-arch tags, merge
the manifest — so the bytes published are the bytes that were tested.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
copytruncate loses log records twice per rotation: everything written
since the tailer's last poll (kept in mail.log.1, but skipped because the
descriptor points at the truncated inode) and whatever lands between the
copy and the truncate (gone for good). Those records carry the final
delivery statuses the send log is reconciled from, so a dropped line
means a row stuck in "queued" — not just a gap in the monitoring view,
as the item previously assumed.
Decision recorded, implementation deferred to its own step.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sessions move from the in-memory map to a `sessions` table (migration
0002), so a restart, a redeploy or a restore from a full backup no longer
signs the administrator out. The row holds a SHA-256 of the token rather
than the token itself: a stolen database file or backup archive cannot be
replayed into a login, while the browser that still holds the cookie keeps
working across a restore.
The 12-hour absolute TTL becomes a sliding 7-day idle window, configurable
through PANEL_SESSION_IDLE_DAYS (whole days, mirroring
SEND_LOG_RETENTION_DAYS). No absolute cap: for an administrator who visits
regularly the session lasts indefinitely, which is the accepted trade-off.
The four `every 5s` monitoring fragments deliberately do not renew it —
otherwise a forgotten open tab would hold the session open forever and the
window would mean "seven days without an open tab" rather than "seven days
without the administrator".
Decision only; no code yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
14.C needed no code: the setup link is already mirrored to /data/setup-token
at 0600 and removed once setup completes. What was missing is the reason to
prefer it — a deployment whose container logs ship to a central aggregator
otherwise leaves a live bearer token in that pipeline for ten minutes, and in
whatever retains it afterwards.
The reverse-proxy section gains the one requirement 14.A introduces: pass the
original Host header through. Everything else about security stays the
proxy's non-problem, which is the point of emitting the headers from the
panel.
Phase 14 leaves the plan (the file describes only unfinished work), but its
section A keeps what was deliberately left open: the accepted risk for clients
sending neither Sec-Fetch-Site nor Origin, the decision not to add
session-bound CSRF tokens and what would justify revisiting it, and the fact
that XSS inside the panel's own origin is answered by html/template and the
CSP rather than by either of those.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records both decisions and, more usefully, what the item was actually about.
The prefix was filed as a free nicety ("мелочь, но бесплатная"), which is why
it sat undecided: nothing said what it prevents. It prevents the same-site
neighbour from the CSRF item using its other lever — setting a Domain-scoped
cookie of the same name. The browser then sends two, r.Cookie returns the
older one, and the admin logs in successfully into an endless login loop. That
is denial of service rather than compromise (no valid token can be forged with
a single account), but it is close to undiagnosable from the panel's side, and
the origin check decided in A.2 does nothing about it — the request comes from
the admin's own origin.
Phase 14 gains section B: the cookie name becomes conditional on CookieSecure,
because a __Host- cookie over plain HTTP is rejected outright and would break
the dev mode silently; logout clears both names; and requireAuth switches to
r.Cookies() so a duplicate is refused and logged instead of silently picked.
The setup-token documentation moves to 14.C.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records variant (b): the panel will check Origin / Sec-Fetch-Site in the same
middleware as the security headers, and will not carry CSRF tokens. The
coverage table above the decision already says what that buys; what the item
was missing is what it does not buy, so both are now written down — the
accepted risk (a client sending neither header still gets through, which is
exactly the old-browser row) and the two escalation paths with their price,
tightening the policy to reject those requests, or session-bound tokens.
Phase 14.A grows the implementation rules: which requests are checked, the
three-way decision, and the fact that only the host is compared because the
panel sits behind a proxy and never sees its own external scheme. The rule
depends on r.Host being the external name — all four shipped proxy fragments
preserve it (checked), but a proxy that rewrites Host would turn every POST
into a 403, so the rejection has to log both sides of the comparison and the
container test has to run through a real proxy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The plan is meant to hold only what is still open, but three of its numbered
items had already been implemented and were still being read as pending work:
the TRUSTED_PROXY_CIDR-gated X-Forwarded-For handling (A.1), the account
settings page (A.6) and the go vet/go test CI workflow (C.10). Remove them
and renumber; the residual scope note from A.6 (2FA, multiple admins) moves
to section D, which is where deliberately deferred scope belongs.
Same for the "done" notices at the top of the plan and the phase-by-phase
retellings in progress.md: phases 12 and 13 are described in full in the
CHANGELOG and git history, so the tracker now states what is closed and what
is next, and nothing else.
Three code comments cited plan item numbers that this renumbering would have
silently pointed at a different item, and one cited a phase 13 section that
no longer exists; they now state the fact instead of the reference. The CI
test workflow was never recorded in the CHANGELOG, so its entry is added
there before the plan item describing it goes away.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.mixfed.ru) and not matching (mixfed.ru), 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>
Phase 12 (UI/UX). The navigation bar now renders once from layout.html
instead of being copied into each content template, so it is present on
every authenticated page — including the domain page and its delete
confirmation, which had no links at all — and the current page is
highlighted via .Active rather than quietly dropping out of the list.
New /account page changes the administrator's username and/or password:
the current password is required and the attempt is throttled on the same
limiter as the login form, so this route cannot be used to brute-force
past that limit. A password change invalidates every other session while
keeping the one performing it; a rename carries that session over.
Backup and domain import move from a card in the middle of the domain
list to their own /backup page, one card each; the handlers themselves
are unchanged, only the page the import form renders its errors on.
The domain page gains a "Sending server settings" card (server, port,
encryption) so a client can be configured without reading the docs; 587
is listed only when SUBMISSION_ENABLE is true for this deployment, which
is a deploy-time flag the panel cannot verify at runtime.
Client-side (static/panel.js, no libraries): Copy buttons on the values
that get carried elsewhere (DKIM record, new application credentials,
server name), and the Addresses field is hidden while the address mode is
wildcard, where the server ignores it.
Verified in a container on the dev server: setup, login, every page's
nav and active item, domain and application creation, all account-form
paths including cross-session invalidation, import errors, full backup
download. gofmt/vet/test/docker build green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a new agreed-upon phase covering /status (supervisord processes,
Postfix queue, TLS cert expiry, milter sockets, PTR/FCrDNS check) and
per-domain DNS correctness status (DKIM/SPF-heuristic/DMARC) on the
domain page. Not part of v1.0 spec scope; scoped and agreed with the
user before implementation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CHANGELOG.md now tracks version history (0.1.0 baseline); progress.md and
implementation-plan.md keep only live process and unfinished work (open
questions, optional 2.x.x phase O1) since phases 0-11 are fully closed and
already covered by git history and the changelog.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Bringing up the production Apache stack for real surfaced a latent bug
in the Phase 10 hardening: cap_drop: ALL with only NET_BIND_SERVICE/
CHOWN/SETUID/SETGID/DAC_OVERRIDE left the root startup phase unable to
chmod the /data dirs it had just chowned to the panel user (needs
CAP_FOWNER) or set their setgid bit (needs CAP_FSETID). The container
crash-looped on "chmod: Operation not permitted". Phase 10 never caught
this because its compose up hit a port conflict before full boot.
Add FOWNER and FSETID to cap_add and document what each capability is
for. Verified: container now starts clean under the hardened compose.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Line-by-line audit of all 8 points of spec 7.6 against the code: full
compliance, no code changes required. Acceptance verified on the dev
server (image selfpost:p11): gofmt/vet/build/test green, docker build
ok, clean container start (all processes RUNNING, panel as non-root
uid 999, setup link + 0600 token, bogus token 404, unauth 303, healthz
200). Baseline v1.0 plan (phases 0->11) complete.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- 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>
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>
The journal-milter, until now a pure monitor, now refuses a message with a
4xx tempfail (RespTempFail/451) at MAIL FROM when a per-domain or per-
application limit is exceeded. Key is the client IP; the count is
COUNT(DISTINCT queue_id) over a sliding window reusing the send log; the
limit applies only when a non-empty IP binding matches the client (empty
binding => level-1 only, per spec 7.4). Enforcement is fail-open on the
milter's own errors — a limiter malfunction never blocks mail, and Postfix's
level-1 anvil limit stays the independent backstop. Refused messages are
recorded in send_log with status "rejected" for UI visibility.
- store/ratelimits.go: RateLimit type (+Active/AllowsIP), id-keyed get/set/
delete for the panel, name/login-keyed lookup + windowed distinct-message
count for the milter, DeleteRateLimitsForDomain. No migration — the
rate_limits table has existed since Phase 2.
- milter: enforce at MailFrom, fail-open helper overLimit, InsertRejected.
- web: server-side validated IP/ceiling/window forms on the domain page and
per application; routes POST /domains/{id}/ratelimit and
/applications/{aid}/ratelimit. Milter reads rows live, so no reload.
- domain/app services clear limits on deletion (rate_limits has no FK cascade).
Unit tests + container e2e (p8) green: refusal on both scopes, unregistered
IP ignored, fail-open with the panel stopped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three HTMX-polled monitoring screens (spec 7.2.11-13): send log with
server-side domain/application filters and pagination, Postfix queue
(postqueue -p), and a mail.log tail. Fragment endpoints return HTML
snippets, not JSON (spec 7.1); all output is auto-escaped via
html/template (spec 7.6.7).
Adds store.QuerySendLog/CountSendLog/ListApplicationLogins,
postfix.Queue(), and logtail.TailLines (a point-in-time reverse read,
independent of the background follow loop). Verified on the dev server:
gofmt/vet/test green, docker build green, container e2e (filters,
60-row pagination, <script> escaping, real postqueue/mail.log output,
existing Reload button unaffected).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Live delivery verified end-to-end: dtester@mixdelta.ru -> selfpost@mixeme.ru
accepted by mc.mixfed.ru with Authentication-Results dkim=pass
(d=mixdelta.ru s=selfpost) and spf=pass, read back over IMAP. All Phase 5
"done when" criteria met. Records the own-domain-policy pitfall (can't test
delivery from a domain the receiver itself hosts) and the mixdelta.ru sender
workaround for future delivery tests. Next: Phase 6 (journal-milter) on Opus.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 5 code is complete, committed (b2692e4) and verified on the server.
Everything at SelfPost's boundary is proven (auth, sender binding, no open
relay, valid DKIM signing, delivery to the recipient MX over TLS). The one
open item — the receiver accepting the message into its inbox — is gated by
mc.mixfed.ru's DNS cache / own-domain policy, not a relay defect; a background
loop retries until it lands.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
De-risk spike confirmed emersion/go-milter v0.4.1 (BSD-2) interoperates with
Postfix 3.7.11 (bookworm) over protocol v6: reads From/To(per-rcpt)/Subject/
queue-id, gets client IP from Connect(), and fails open when the milter dies.
Progress tracker updated; Phase 1 (Docker + supervisord) is next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Go module (codeberg.org/mix/selfpost), two command skeletons (panel,
selfpost-backup) sharing internal/buildinfo for the -ldflags version stamp,
Makefile (static CGO_ENABLED=0 build), AGPL-3.0 LICENSE, README skeleton and
.gitattributes forcing LF (container scripts must not get CRLF).
Verified on the dev server: go vet clean, make build produces statically
linked binaries, version stamping works.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
12-phase plan derived from the spec, plus a durable progress tracker
(model-per-phase, resume-after-reset protocol, commit conventions).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>