Stage 2, step 3. Written by Sonnet, reviewed by Opus.
- Six pages named after their mockups: out-domains, out-domain,
out-domain-settings, out-app, out-app-created, out-domain-delete,
with typed page data (view/outbound.go, outbound_app.go), fixtures
and tests. The engine pages dashboard, domain_detail and
domain_delete are gone with their templates and are off
legacy_pages.txt. GET .../settings has its own handler.
- Handlers pass typed data; routes, POST targets, field names,
validation, authorization (both roles, another tenant's id is a 404)
and Cache-Control: no-store on the password page are unchanged.
- Kit, attribute only: Action.Confirm writes data-confirm on a POST
action's form (panel.js already reads it).
- test/e2e/panel_client.go scrapes the new markup (copy fields, the
application row's Edit link). Not run: no Docker here.
Fixes found on the way
- Report address of a domain that follows another user's default: the
select had one "inherit" option, selected, so saving the form
untouched moved the domain to whoever saved it. It now offers that
user's default as "keep" (selected, saves nothing) and "My default
instead" as the change, as the mockup draws it; "keep" on a domain
that does not follow someone else is refused.
- panel.js: syncShowWhen stopped at the first matching rule, so the
auto-limit fields were never toggled with the limit mode. Every
matching rule applies now.
Differences from the mockups, for the owner:
- application rows carry Edit only. New password and Delete are POSTs,
and the kit has no link-styled POST control for a table row; both are
in the application page's head with a confirmation. Drawing them in
the row needs a design: change (as Sign out was);
- the application login is read-only on edit (the handler refuses a
rename); the submit says Create application on the empty form;
- multiplier bounds are the store's 1.5-5, default 2.5;
- "Recalculate" buttons exist where the stored limit is automatic;
- a domain administrator has no add box and no Delete; Delete domain is
global-only on the domain page and on settings;
- a fourth record, Report authorization, appears when the report
address is on another domain; record states missing / weak / not
checked are tags with the explanation under the value;
- refusals are the kit flash above the first box, and those of the
settings forms now show on the settings page; successful saves still
redirect to the domain page;
- the export checkbox is ticked by default, as drawn.
Evidence
- docs/assets/panel-redesign/evidence/out-domains.png, out-domain.png,
out-domain-settings.png, out-app.png, out-app-created.png,
out-domain-delete.png: 1440 px wide, headless Edge, real templates
served locally with their fixtures - NOT from the container (no
Docker on this machine). Taken before the report-address fix, which
does not change what the fixture renders.
- panel/audit.js on the six pages at 1440 and 1280 (text inside
[hidden] blocks skipped): 866 texts checked, problems: [].
- go test -count=1 ./internal/web/...
ok internal/web
ok internal/web/auth
ok internal/web/handlers
ok internal/web/validate
ok internal/web/view
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.