Stage 2, step 6. Written by Sonnet, reviewed by Opus.
- Six pages named after their mockups: system-log, backup, users,
user, user-delete, help, with typed page data (view/server.go,
help.go), fixtures and tests. The old templates of the six are gone
(user_form.html, encrypt_fields.html deleted, the others rewritten)
and their names are off legacy_pages.txt, which now holds mail_queue
alone.
- Help is six help_topic blocks (checks, certificate, rdns, dns, apps,
limits); a test renders every fixture page and fails on a Help link
without a topic. The help drawer (help_drawer.html and its includes
in layout_legacy.html) is removed; its rules in legacy.css go with
the next step.
- The user form sets the user's e-mail (plan, Account e-mail): create
and update, validated, and a changed address resyncs DMARC for a
user whose default is the account e-mail - through the helper the
Account profile now shares.
- Handlers pass typed data; routes, POST targets, field names,
validation and authorization are unchanged. A test loads the user
form for four reach combinations and for the only global user, posts
it back untouched and finds the user unchanged.
Fixed on the way
- The user delete confirmation said the domains were not affected. A
domain that follows the user's default report address is left with
none; the page names those domains now.
- A refused backup lost the encryption choice and printed a literal
<code> element in its message.
Differences from the mockups, for the owner:
- Help has no DMARC and no Inbound topic: its outline fixes two boxes
of three topics, although other mockups link to #dmarc and #inbound.
Until the owner decides, Account's Help link goes to #dns and the
inbound MX box has none;
- system log: newest line first, as drawn; "refreshes on its own" (5 s
active, 30 s idle); only status=, warning: and error: lines are
coloured;
- backup: the file field is a plain file input; the encryption box is
ticked, as drawn and as on the domain export, so a plain download
means unticking it first;
- users: an E-mail column; the flash does not name the user;
- user form: no lead; the domain lists are no longer hidden by script
when Global is chosen (the server ignores them for that role).
Evidence
- docs/assets/panel-redesign/evidence/system-log.png, backup.png,
users.png, user.png, user-delete.png, help.png: 1440 px wide,
headless Edge, real templates served locally with their fixtures -
NOT from the container (no Docker on this machine).
- panel/audit.js on the six pages at 1440 and 1280: 720 texts checked,
problems: [].
- The system log's polling swap was not exercised in a browser; it
uses the mechanism of Overview and Health.
- go test -count=1 ./internal/web/... ./cmd/panel
ok internal/web
ok internal/web/auth
ok internal/web/handlers
ok internal/web/validate
ok internal/web/view
ok cmd/panel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SelfPost
Self-hosted SMTP relay with a web panel — one Docker image, no provider.
Postfix, OpenDKIM, and a small Go panel run together under supervisord; you
configure domains, DKIM keys, and SASL applications once, then point your apps
at the SMTP endpoint.
SelfPost sends mail straight to the internet from your own IP, with per-domain DKIM signing. No mailboxes or webmail. An optional inbound relay (backup-MX / forwarder on port 25) forwards to an upstream; it does not store mail.
For: operators who run their own VPS or home server and want a simple relay they control, without a third-party SMTP provider.
Key properties: one container, multi-domain DKIM, SASL per application, send log and DNS checks in the panel, encrypted backups.
Features
- Outbound SMTP (465/smtps; optional 587 submission) with per-domain DKIM signing
- Optional inbound relay on port 25 (backup-MX / forwarder; off by default)
- Optional DMARC aggregate report ingest on port 25 (off by default)
- In-panel Help drawer and
/helppage for Status and domain controls - Per-application client IP allow-list for SMTP AUTH (optional)
- Web panel — domains, applications, deliveries, mail queue, system log, backup
- Multi-domain relay — each SASL application is bound to one sending domain
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
- Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides
- Full-server backup and single-domain export/import (optional password encryption)
- Single Docker image; production data in a
./databind mount (the quick start below uses a named Docker volume instead)
Documentation
| Document | Contents |
|---|---|
| Operator guide | Reverse proxy, environment variables, DNS, IP warmup, panel operations, rate limiting, backup/restore, ports, image tag |
| Product boundaries | Purpose, deployment assumptions, out-of-scope items, multi-domain model |
| Architecture | As-built technical design |
| Security design | Mandatory requirements, accepted risks, the CSRF ADR |
| Development | Building, testing, docs rules, model routing, commits |
| Roadmap | Open work (1.x+) — direction, not commitments |
| CHANGELOG | Release history |
Found a vulnerability? Do not open an issue — SECURITY.md has the private reporting channel and the scope.
Repository: https://github.com/mixeme/selfpost — source, issues, releases, and
the ghcr.io/mixeme/selfpost image.
Requirements
Providing these is the operator's job — SelfPost cannot fix a blocked port or a missing PTR record for you. Details: Operator guide.
Platform
- Docker + Compose v2 on the host
- A reverse proxy in front of the panel (SelfPost never terminates HTTPS itself)
- Rough sizing: 1 vCPU, 512 MB–1 GB RAM, 8–10 GB disk (send log and
rotated
mail.logare the main growth drivers; both sit in the./datavolume, and both are capped — 90 days and 14 files by default)
Network and IP
- Static IP address
- Outbound TCP port 25 unblocked (many consumer/cloud hosts block it by default)
- PTR/rDNS for that IP pointing at your mail hostname (
SELFPOST_HOSTNAME) - Reasonable starting IP reputation — a fresh IP still needs warmup
Per sending domain
For every domain you add in the panel:
- SPF TXT record authorizing this server
- DKIM TXT record (value shown on the domain page)
- DMARC
_dmarcTXT record
See Domain-level DNS in the operator guide.
Per inbound domain (optional)
Only if you turn on inbound relay (INBOUND_RELAY_ENABLE=true):
- MX record pointing at
SELFPOST_HOSTNAME(keep any existing primary MX if this host is backup-MX)
See Inbound relay in the operator guide.
Quick start
First boot — create the admin account. On a fresh container SelfPost prints a one-time setup URL (valid ten minutes). Open it in a browser to choose the administrator username and password. Until you do, the panel has no login. Production deploy: Full deployment in the operator guide.
One container, panel at http://127.0.0.1:8080 — no reverse proxy, no TLS
files, no compose files. Good for clicking through the UI on your machine;
outbound mail will not reach the real internet without DNS, PTR, and port 25.
docker run --rm -d --name selfpost-try \
-p 127.0.0.1:8080:8080 \
-e SELFPOST_HOSTNAME=mail.local.test \
-e PANEL_COOKIE_SECURE=false \
-v selfpost-try-data:/data \
ghcr.io/mixeme/selfpost:1.9.5
Get the setup URL (pick one):
docker logs selfpost-try 2>&1 | grep -m1 'http'
docker exec selfpost-try cat /data/setup-token
The printed URL is https://mail.local.test/setup/<token>. For this local
trial rewrite it to http://127.0.0.1:8080/setup/<token> (same path token;
PANEL_COOKIE_SECURE=false so the cookie works over plain HTTP). Open it
before it expires.
When finished:
docker rm -f selfpost-try && docker volume rm selfpost-try-data
More detail (limitations, optional throwaway TLS for local SMTP): Local trial in the operator guide.
Reference deploy
Production layout: one docker-compose.yml, a .env, persistent ./data, and
TLS PEM files at ./certs (read by Postfix on 465/587). The panel is reached
only through a reverse proxy on 443 — port 8080 is bound to localhost in the
default compose file.
Full walkthrough — fetching the base files, setting up a reverse proxy and TLS (Apache/nginx/Caddy/Traefik), starting the container, and wiring up DNS — lives in the operator guide's Full deployment section, with proxy-specific commands under Reverse proxy.
The compose file always publishes 465, 587, and 25; Postfix listens
on 587 only when SUBMISSION_ENABLE=true, and on 25 when
INBOUND_RELAY_ENABLE=true and/or DMARC_REPORTS_ENABLE=true (see
Ports). Bump the
pinned image tag deliberately when upgrading, never :latest (why). Optional
variables (TRUSTED_PROXY_CIDR, rate limits, retention): see Environment
variables.
License
Copyright © 2026 Mikhail Yenuchenko.
AGPL-3.0. The AGPL closes the "SaaS loophole": if you run a modified version as a network-accessible service, you must make the modified source available to its users — not only when you distribute copies of the code. Third-party notices: NOTICE.