mixandClaude Opus 5.5 e166a2ebd6 refused messages: the page shows what Postfix wrote (stage 2b, 0009)
The Delivery log box of every refused row said "This message never reached
the queue, so Postfix wrote no delivery lines for it" - untrue: Postfix
wrote a line for each. The owner, 2026-10-13: the box is required on every
message page. Pictured for him first (dev/shots, the refused variant of
out-message: wrong From with its queue id lines, rate-limited with the
NOQUEUE line); the variant has no mockup screen of its own - the guard ties
screens to pages, and its outline is out-message's.

- Wrong From: cleanup gives the message a queue id before the headers are
  judged; the sender check reads {i} at end-of-headers and writes it on the
  refused row. Its lines - stored under the id like any other's - are on the
  page, but only those from 10 minutes before to 1 minute after the refusal
  (store.SendLogLines for a rejected row): Postfix frees the id at once, and
  a later message with it lends nothing. No mail.log fallback for a refused
  row, for the same reason.
- Rate-limited and wrong client: refused at MAIL FROM, no queue id. Migration
  0009 adds send_log.refusal_line; logtail.refusalLines matches smtpd's
  "NOQUEUE: milter-reject: MAIL from ...; from=<...>" (not port 25's, not
  CONNECT/EHLO) and store.AttachRefusalLine puts it on the newest refused row
  of that sender (and login, when the line names it) within two minutes; a
  line whose row is not written yet waits up to 30 s (retried at each pass).
- The page: the queue id fact says "given, the message was not queued" or
  "none - refused at MAIL FROM, before one is given"; the box's caption is
  "The line from mail.log for this refusal" for the latter; a row from
  before says the line was not kept. LogLineLevel colours a refusal by its
  reply: 5xx red, 4xx amber (IPv6 clients included). The id in the fact is
  not muted as in the picture: the kit has no muted inline.

Tests: store (attach by sender, login, time; not a queued row, not one an
hour off; a refused row's lines are its own minutes); logtail (attach, wait
and retry, port 25 and other lines ignored); milter (the refused row carries
{i}); view (facts, caption, line levels); handlers (the line on the page,
the sentence without it). e2e refusals_show_postfixs_line: a client-address
refusal made there, and the pages of a wrong From, a rate-limited and a
wrong client refusal each show Postfix's line.

schema-migrations row 9, CHANGELOG [Unreleased], audit plan line ticked,
roadmap 25/62.
Evidence on the test server for this tree: make e2e exit 0 (the new case
in 5.4 s); go test ./... ok but for two schema tests, fixed after the run
(the schema at 0009) - store tests ok locally; the full run follows before
the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:56:29 +03:00

SelfPost

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: a page of topics, opened from the ? in the head of a box
  • Per-application client IP allow-list for SMTP AUTH (optional)
  • Web panel grouped by mail direction — Overview, Outbound (domains, log, queue, DMARC reports), Inbound, Server (health, system log, backup, users, settings)
  • 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 ./data bind 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.log are the main growth drivers; both sit in the ./data volume, 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 _dmarc TXT 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.

The 2.x line is not published on ghcr.io yet (the newest image there is 1.3.0). Until it is, build the image from this checkout under the name the commands below and deploy/docker-compose.yml use; Docker then takes the local image and pulls nothing:

docker build -f build/Dockerfile --build-arg VERSION=2.5.0 \
  -t ghcr.io/mixeme/selfpost:2.5.0 .
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:2.5.0

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.

S
Description
No description provided
Readme AGPL-3.0
12 MiB
Languages
Go 91.4%
HTML 3.5%
Shell 2.9%
CSS 1%
JavaScript 0.6%
Other 0.6%