test / test (push) Canceled after 0s
Both reviews were of c171fb5, read-only, given the code and questions.
Found by both, and the one that would have refused ordinary mail from the
first day: a folded From: line - what a mail library makes of a long display
name - did not parse and was refused with 550. The line is unfolded before it
is read. e2e sends one through Postfix now.
Technical (Opus)
- A display name in a charset Go does not convert (windows-1251, koi8-r) was
refused: such a name is passed through unread, only the address is judged.
- The statistics ran between binding the socket and serving it, with a
supervisorctl that has no timeout: they are kept beside the check now and
nothing of them is waited for. A supervisorctl that fails is logged.
- The count of deferred mail followed smtpd's reject lines and so missed a
check that stops answering in the middle of a message, where cleanup
writes them: it is taken at Postfix's warning, the line that names the
socket. The test line for that case had been invented with the wrong
process; it is marked as composed from Postfix's source.
- A card whose figures could not be read showed zeros and "ok": dashes and
"unknown".
- entrypoint removes the sockets a killed container left, which satisfied
the wrapper's wait before the check was listening.
- Outside quoted strings the line may hold one "@" only - a rule that needs
no knowledge of OpenDKIM's parser.
- Comments that still spoke of two sockets, or of a session replaced after a
refusal; the limits of the cards layout on a phone, said where it is defined.
Security (Fable; no Critical or High)
- Another sender's name in an encoded display name passed the second look:
the decoded name is looked at as well.
- A client could arm the deferred counter by sending the socket's name where
Postfix quotes it in a warning: the two warnings are matched from their
first word.
- A refused message wrote a row per recipient: one row, naming the first
recipient and how many more.
- Docs: the check's lookup is stricter than Postfix's for address extensions
and the case of logins, rather than "cannot disagree"; what a display name
looks like to a person is not promised; the timeouts and the 454 at AUTH
stated as they are; "messages" are connections turned away.
Left as found, in the plan: downtime that crosses midnight is booked on the
day of the restart; a message with the null envelope sender leaves a row
without a domain; a log read again after an unclean stop counts again.
Test server, this tree: go vet ok, go test 23 packages ok, make e2e ok (330 s).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1281 lines
69 KiB
Markdown
1281 lines
69 KiB
Markdown
# SelfPost operator guide
|
||
|
||
Detailed install, configuration, and day-to-day operations. For a short
|
||
overview and quick start, see [README.md](../README.md).
|
||
|
||
This guide has three parts: **[Installation](#installation)** (getting a
|
||
container running with a working reverse proxy and TLS), **[Instance
|
||
administration](#instance-administration)** (running and maintaining the
|
||
SelfPost server itself — the panel's layout, health, backups, users,
|
||
upgrades), and **[Domain administration](#domain-administration)** (day-to-day
|
||
work on the sending domains hosted on that instance — DNS, the outbound log,
|
||
rate limits, applications).
|
||
|
||
SelfPost 2.0 starts from an empty data directory: a `/data` written by 1.x is
|
||
refused at start, not upgraded. A domain moves over with its
|
||
[export file](#exporting-and-importing-a-single-domain), and every panel path
|
||
changed (see the CHANGELOG, *Removed*).
|
||
|
||
## Table of contents
|
||
|
||
- [Installation](#installation)
|
||
- [Ports](#ports)
|
||
- [Local trial](#local-trial)
|
||
- [Initial setup](#initial-setup)
|
||
- [Full deployment](#full-deployment)
|
||
- [Fixed image tag](#fixed-image-tag)
|
||
- [Environment variables](#environment-variables)
|
||
- [Reverse proxy (mandatory)](#reverse-proxy-mandatory)
|
||
- [Instance administration](#instance-administration)
|
||
- [The panel](#the-panel)
|
||
- [Help](#help)
|
||
- [Overview and Health](#overview-and-health)
|
||
- [Queue and System log](#queue-and-system-log)
|
||
- [Account](#account)
|
||
- [Settings](#settings)
|
||
- [Notifications](#notifications)
|
||
- [Users](#users)
|
||
- [Sessions](#sessions)
|
||
- [Upgrading](#upgrading)
|
||
- [Container health](#container-health)
|
||
- [Server-level DNS (PTR/rDNS)](#server-level-dns-ptrrdns)
|
||
- [Rate limiting — level 1 (IP backstop)](#rate-limiting--level-1-ip-backstop)
|
||
- [Full backup and restore](#full-backup-and-restore)
|
||
- [Taking a full backup](#taking-a-full-backup)
|
||
- [Restore](#restore)
|
||
- [Encrypting a backup or export](#encrypting-a-backup-or-export)
|
||
- [Inbound relay](#inbound-relay)
|
||
- [DMARC reports](#dmarc-reports)
|
||
- [Domain administration](#domain-administration)
|
||
- [Outbound domains](#outbound-domains)
|
||
- [Domain-level DNS (SPF, DKIM, DMARC)](#domain-level-dns-spf-dkim-dmarc)
|
||
- [IP warmup](#ip-warmup)
|
||
- [Rate limiting — level 2 (domain and application)](#rate-limiting--level-2-domain-and-application)
|
||
- [Outbound log](#outbound-log)
|
||
- [Exporting and importing a single domain](#exporting-and-importing-a-single-domain)
|
||
|
||
## Installation
|
||
|
||
### Ports
|
||
|
||
`deploy/docker-compose.yml` maps **465**, **587**, and **25** to the host. Port
|
||
465 (smtps) is always active. Port **587** is published even when
|
||
`SUBMISSION_ENABLE=false`; nothing listens until you set it to `true`. Port
|
||
**25** is published even when `INBOUND_RELAY_ENABLE=false`; Postfix does not
|
||
listen on port 25 until you set **`INBOUND_RELAY_ENABLE=true`** and/or
|
||
**`DMARC_REPORTS_ENABLE=true`** (see [Inbound relay](#inbound-relay) and
|
||
[DMARC reports](#dmarc-reports)). Harmless extra publishes can look like open
|
||
ports in external scans.
|
||
|
||
### Local trial
|
||
|
||
The [README quick start](../README.md#quick-start) runs a single container
|
||
with `PANEL_COOKIE_SECURE=false` and port 8080 published on localhost. No
|
||
reverse proxy, no `./certs` bind mount — Postfix still starts, but Overview
|
||
and Health will report missing TLS material until you mount PEM files at
|
||
`/etc/postfix/tls/fullchain.pem` and `privkey.pem`.
|
||
|
||
The one-time setup link is always printed as
|
||
`https://<SELFPOST_HOSTNAME>/setup/<token>` (and written the same way to
|
||
`/data/setup-token`). For a local trial that means rewriting the host and
|
||
scheme to `http://127.0.0.1:8080/setup/<token>` — the path token is what
|
||
matters; the hostname in the printed URL is not reachable as written.
|
||
|
||
**What works:** the full panel — setup, domains, applications, outbound log,
|
||
queue, system log. **What does not:** reliable outbound delivery to the
|
||
public internet (no PTR, no real DNS for your domains, port 25 may be blocked
|
||
on your network, HELO does not match anything receivers trust).
|
||
|
||
To exercise SMTP locally as well, generate a throwaway self-signed certificate
|
||
and mount it before `docker run`:
|
||
|
||
```sh
|
||
mkdir -p /tmp/selfpost-certs
|
||
openssl req -x509 -newkey rsa:2048 \
|
||
-keyout /tmp/selfpost-certs/privkey.pem \
|
||
-out /tmp/selfpost-certs/fullchain.pem \
|
||
-days 1 -nodes -subj '/CN=mail.local.test'
|
||
```
|
||
|
||
Add `-v /tmp/selfpost-certs:/etc/postfix/tls:ro` to the `docker run` command
|
||
(and keep `SELFPOST_HOSTNAME=mail.local.test` so it matches the certificate CN).
|
||
Clients must skip TLS verification — the cert is not from a public CA.
|
||
|
||
### Initial setup
|
||
|
||
On first start the one-time setup URL is printed in the container log
|
||
(`docker compose logs -f`) and written to `/data/setup-token` inside the
|
||
container — `./data/setup-token` on the host, mode `0600` — then deleted when
|
||
setup completes. The link is `https://<SELFPOST_HOSTNAME>/setup/<token>` (path
|
||
token, not a query string), valid for ten minutes. Open it to choose the
|
||
administrator username and password — until then the panel has no login, and
|
||
its sign-in page says there is no administrator yet and points at the setup
|
||
link. If this host ships container logs to a central aggregator, prefer reading the
|
||
file:
|
||
|
||
```sh
|
||
docker compose exec selfpost cat /data/setup-token
|
||
```
|
||
|
||
### Full deployment
|
||
|
||
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](#reverse-proxy-mandatory) on 443 — port 8080 is
|
||
bound to localhost in the default compose file.
|
||
|
||
| Artefact | Path |
|
||
|---|---|
|
||
| Compose file (fixed image tag) | [deploy/docker-compose.yml](../deploy/docker-compose.yml) |
|
||
| Environment template | [deploy/.env.example](../deploy/.env.example) |
|
||
| Apache vhost (recommended) | [deploy/apache/selfpost-vhost.conf](../deploy/apache/selfpost-vhost.conf) |
|
||
| nginx | [deploy/nginx/](../deploy/nginx/) |
|
||
| Caddy | [deploy/caddy/](../deploy/caddy/) |
|
||
| Traefik | [deploy/traefik/](../deploy/traefik/) |
|
||
|
||
**1. Fetch the base files.**
|
||
|
||
```sh
|
||
mkdir -p selfpost/data selfpost/certs && cd selfpost
|
||
curl -O https://raw.githubusercontent.com/mixeme/selfpost/main/deploy/docker-compose.yml
|
||
curl -O https://raw.githubusercontent.com/mixeme/selfpost/main/deploy/.env.example
|
||
cp .env.example .env
|
||
```
|
||
|
||
Edit `.env` — at minimum set `SELFPOST_HOSTNAME` to your mail hostname (bare
|
||
FQDN, e.g. `mail.example.com`). It must match the PTR record you request from
|
||
your provider and the certificate your proxy will obtain. See [Environment
|
||
variables](#environment-variables) for the full list.
|
||
|
||
**2. Reverse proxy and TLS.** Pick and set up one proxy — see [Reverse proxy
|
||
(mandatory)](#reverse-proxy-mandatory) for the per-proxy commands. The same
|
||
certificate must end up under `./certs` as `fullchain.pem` and `privkey.pem`
|
||
so Postfix can serve it on 465 (and 587 if enabled).
|
||
|
||
**3. Start SelfPost.** If you used Apache on the host (the recommended
|
||
option), start only the base compose file from your `selfpost/` directory:
|
||
|
||
```sh
|
||
docker compose up -d
|
||
```
|
||
|
||
The nginx/Caddy/Traefik fragments already include `docker compose up -d` —
|
||
skip this if you ran one of those.
|
||
|
||
**Get the setup URL** — open it in a browser to create the admin account (see
|
||
[Initial setup](#initial-setup)):
|
||
|
||
```sh
|
||
docker compose logs selfpost 2>&1 | grep -m1 'http'
|
||
```
|
||
|
||
```sh
|
||
cat ./data/setup-token
|
||
```
|
||
|
||
**4. DNS and sending.** Before sending real mail:
|
||
|
||
1. Confirm PTR/rDNS for the server IP points at `SELFPOST_HOSTNAME` (Server ›
|
||
Health → *Re-check DNS*) — see [Server-level DNS](#server-level-dns-ptrrdns).
|
||
2. For each domain you add in the panel, publish SPF, DKIM, and DMARC at the
|
||
same time ([Domain-level DNS](#domain-level-dns-spf-dkim-dmarc)).
|
||
3. Warm up a new IP gradually ([IP warmup](#ip-warmup)).
|
||
|
||
#### Fixed image tag
|
||
|
||
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
|
||
deliberately never `:latest`. The current pin is `2.3.0`. Intermediate
|
||
CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that
|
||
image was published. Pinning matters because of the backup version check (see
|
||
[Full backup and restore](#full-backup-and-restore)): the panel binary's
|
||
embedded version and the image tag that produced it are the same value by
|
||
construction (the release CI stamps both from one git tag — see
|
||
`.github/workflows/release.yml`), so the pin is what makes "restore into the
|
||
same version" a checkable fact rather than a guess. Upgrade by bumping the tag
|
||
deliberately, not by riding a moving target — see [Upgrading](#upgrading).
|
||
|
||
### Environment variables
|
||
|
||
Copy [deploy/.env.example](../deploy/.env.example) to `.env` next to your
|
||
`docker-compose.yml`. The table below lists every variable an operator is
|
||
expected to set; defaults match the code exactly.
|
||
|
||
| Variable | Purpose | Default | Set in |
|
||
|---|---|---|---|
|
||
| `SELFPOST_HOSTNAME` | Mail-server identity: Postfix HELO/EHLO, SASL realm, certificate CN/SAN, and the hostname the PTR check expects. Bare FQDN only — no scheme or port. | *(required)* | `.env` |
|
||
| `SUBMISSION_ENABLE` | When `true`, also listen on port 587 with STARTTLS (RFC 6409 submission) alongside the primary 465/smtps listener. | `false` | `.env` |
|
||
| `INBOUND_RELAY_ENABLE` | When `true`, accept mail on port 25 for domains configured under *Inbound › Domains* in the panel and forward them to the upstream you set. Off by default — the outbound path is unchanged. See [Inbound relay](#inbound-relay). | `false` | `.env` |
|
||
| `DMARC_REPORTS_ENABLE` | When `true`, accept DMARC aggregate reports on port 25 only for report addresses configured in the panel, parse gzip/XML, and show summaries under *Outbound › DMARC reports*. Off by default. See [DMARC reports](#dmarc-reports). | `false` | `.env` |
|
||
| `DMARC_RATE_LIMIT_MESSAGES_PER_IP` | Per-client-IP cap on port 25 when DMARC ingest is on. When inbound relay is also enabled, **`INBOUND_RATE_LIMIT_MESSAGES_PER_IP`** applies to the shared listener instead. | `20` | `.env` |
|
||
| `DMARC_MESSAGE_SIZE_LIMIT` | Maximum report message size in bytes when DMARC ingest is on. | `5242880` (5 MiB) | `.env` |
|
||
| `INBOUND_ANTISPAM_MILTER` | Optional milter on the inbound relay listener only (requires `INBOUND_RELAY_ENABLE=true`; not 465/587 or DMARC-only port 25). Empty = off. Format `inet:host:port` or `unix:/path`. Example with [deploy/antispam/docker-compose.antispam.yml](../deploy/antispam/docker-compose.antispam.yml): `inet:antispam:11332`. The panel reads it to report on the *Spam filter* box of the inbound pages and to check that the filter answers (Server › Health). | *(empty)* | `.env` |
|
||
| `INBOUND_ANTISPAM_MILTER_ACTION` | What Postfix does if that milter is down: `accept` (fail-open) or `tempfail` (defer). The *Spam filter* box states which. | `accept` | `.env` |
|
||
| `INBOUND_SPAM_LOG_RETENTION_DAYS` | How long the inbound journal keeps a row, 7-365 days; it also never holds more than 10 000 rows. Seeds the setting on the first start with the inbound relay on. | `90` | `.env` |
|
||
| `INBOUND_JOURNAL_MILTER_SOCKET` | Unix socket of the panel's inbound journal milter, the last milter on port 25 while the inbound relay is on. It records accepted inbound mail and never refuses any; there is no reason to change it. | `/run/selfpost/inbound-journal.sock` | compose |
|
||
| `INBOUND_RATE_LIMIT_MESSAGES_PER_IP` | Coarse per-client-IP cap on inbound smtpd (`smtpd_client_message_rate_limit`). Uses the same window as `RATE_LIMIT_WINDOW_SECONDS`. | `20` | `.env` |
|
||
| `INBOUND_MESSAGE_SIZE_LIMIT` | Maximum message size in bytes on inbound smtpd (`message_size_limit`). When both inbound relay and DMARC ingest are on, the shared listener uses the **greater** of this and `DMARC_MESSAGE_SIZE_LIMIT`. | `26214400` (25 MiB) | `.env` |
|
||
| `RATE_LIMIT_MESSAGES_PER_IP` | Level-1 backstop: maximum messages one client IP may submit per window (Postfix `smtpd_client_message_rate_limit`). See [Rate limiting — level 1](#rate-limiting--level-1-ip-backstop). | `100` | `.env` |
|
||
| `RATE_LIMIT_WINDOW_SECONDS` | Level-1 window length in seconds (Postfix `anvil_rate_time_unit`). | `3600` | `.env` |
|
||
| `SEND_LOG_RETENTION_DAYS` | Initial default for how many days of send-log history are kept before the background sweep deletes rows, and with them the `mail.log` lines kept for each message (1–2 KB a message) — the main driver of `/data` growth over time and of the size of a full backup. After the first panel start, change retention under **Server › Settings** (global role); the env value is only used to seed SQLite when the setting has never been saved. | `90` | `.env` |
|
||
| `PANEL_SESSION_IDLE_DAYS` | Sliding idle timeout for the panel login session, in days. There is no absolute cap: a user who keeps coming back stays signed in indefinitely. | `7` | `.env` |
|
||
| `SELFPOST_DNS_RESOLVERS` | Comma-separated recursive resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly (so they report what the internet sees, not what this host's stub resolver synthesises). | `1.1.1.1:53`, `8.8.8.8:53`, `9.9.9.9:53` when unset | `.env` |
|
||
| `TRUSTED_PROXY_CIDR` | Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to supply `X-Forwarded-For` for login, setup, and account-change rate-limiting. **Leave unset unless you know the exact address of your reverse proxy.** A wrong value lets a client spoof its rate-limit key by sending a forged `X-Forwarded-For` header — the panel trusts the last hop only when the TCP peer matches one of these CIDRs. Behind the default Apache host-network setup this is typically the Docker bridge gateway, e.g. `172.18.0.1`. | *(empty — XFF ignored)* | `.env` |
|
||
|
||
TLS certificate paths (`TLS_CERT_FILE`, `TLS_KEY_FILE`) are fixed in
|
||
[deploy/docker-compose.yml](../deploy/docker-compose.yml) to match the `./certs`
|
||
bind mount — configure the mount, not these variables.
|
||
|
||
The image also reads a number of internal, non-operator env vars (paths,
|
||
timeouts, tuning) — not part of this interface; see
|
||
[architecture.md § Configuration](architecture.md#configuration) if you need
|
||
them.
|
||
|
||
### Reverse proxy (mandatory)
|
||
|
||
SelfPost's panel speaks plain HTTP and never terminates TLS itself — a reverse
|
||
proxy in front of it is not optional. The proxy is also the project's only
|
||
source of TLS certificates: whatever it obtains via ACME/Let's Encrypt gets
|
||
bind-mounted **read-only** into the SelfPost container, and Postfix uses those
|
||
same PEM files for TLS on 465 (and 587, if enabled). If the panel and the mail
|
||
service share one hostname — the common case — it's genuinely one certificate
|
||
serving both.
|
||
|
||
SelfPost isn't tied to a specific proxy; pick whichever fits your host:
|
||
|
||
| Proxy | Where certs live | Fragment |
|
||
|---|---|---|
|
||
| **Apache** (default/recommended) | Host disk, via the certbot Apache plugin — PEM files ready to bind-mount, no extraction step. | [deploy/apache/selfpost-vhost.conf](../deploy/apache/selfpost-vhost.conf) |
|
||
| nginx | Host disk, via a certbot sidecar container — same PEM-ready shape as Apache. | [deploy/nginx/](../deploy/nginx/) |
|
||
| Caddy | Automatic ACME, zero extra containers — simplest, but its on-disk cert path is versioned internal layout, not a stable API; verify it for the Caddy version you run. | [deploy/caddy/](../deploy/caddy/) |
|
||
| Traefik | Bundled inside `acme.json` — needs a small extraction script to produce standalone PEM files. | [deploy/traefik/](../deploy/traefik/) |
|
||
|
||
Apache is the recommended default because the certbot Apache plugin already
|
||
writes plain `fullchain.pem`/`privkey.pem` files to a predictable path with no
|
||
extra moving parts between "certificate issued" and "Postfix can read it."
|
||
|
||
**The proxy needs no security configuration of its own.** The panel emits its
|
||
own `Content-Security-Policy`, `Strict-Transport-Security`, `X-Frame-Options`,
|
||
`X-Content-Type-Options` and `Referrer-Policy` — deliberately, so the part
|
||
that's easy to get wrong lives in the service rather than in a config file
|
||
somebody edits under pressure. There is exactly one thing the proxy must do:
|
||
**pass the original `Host` header through**. All four fragments above already
|
||
do (Apache `ProxyPreserveHost On`, nginx `proxy_set_header Host $host`, Caddy
|
||
and Traefik by default). A proxy that rewrites `Host` instead makes the panel
|
||
reject every form submission as cross-origin — the log says so explicitly,
|
||
printing the `Origin` and `Host` it compared.
|
||
|
||
In every case the proxy terminates HTTPS for the panel; the resulting
|
||
certificate must end up under `./certs` as `fullchain.pem` and `privkey.pem`.
|
||
|
||
**Apache (recommended, on the host).** Install Apache with `ssl`, `proxy`, and
|
||
`proxy_http` enabled. Copy
|
||
[deploy/apache/selfpost-vhost.conf](../deploy/apache/selfpost-vhost.conf) into your
|
||
vhost directory, replace `mail.example.com` with your hostname, enable the site,
|
||
then issue a certificate:
|
||
|
||
```sh
|
||
sudo certbot --apache -d mail.example.com
|
||
```
|
||
|
||
Point `./certs` at the PEM files certbot wrote (symlink is fine):
|
||
|
||
```sh
|
||
ln -s /etc/letsencrypt/live/mail.example.com certs
|
||
```
|
||
|
||
**nginx (containerised).** From the `deploy/` directory, merge the nginx
|
||
fragment and issue the first certificate before nginx can serve HTTPS:
|
||
|
||
```sh
|
||
docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml \
|
||
run --rm certbot certonly --webroot -w /var/www/certbot \
|
||
-d mail.example.com --email you@example.com --agree-tos --no-eff-email
|
||
|
||
docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d
|
||
```
|
||
|
||
Edit [deploy/nginx/nginx.conf.example](../deploy/nginx/nginx.conf.example) and
|
||
replace `mail.example.com` first. The fragment bind-mounts certbot's output into
|
||
both nginx and SelfPost.
|
||
|
||
**Caddy (containerised, automatic ACME).** Edit
|
||
[deploy/caddy/Caddyfile](../deploy/caddy/Caddyfile) and the `<hostname>` placeholders
|
||
in [deploy/caddy/docker-compose.caddy.yml](../deploy/caddy/docker-compose.caddy.yml),
|
||
then:
|
||
|
||
```sh
|
||
docker compose -f docker-compose.yml -f caddy/docker-compose.caddy.yml up -d
|
||
```
|
||
|
||
Verify Caddy's on-disk cert path for your version before relying on the
|
||
default mount — see the comment at the top of the Caddy compose fragment.
|
||
|
||
**Traefik (containerised).** Edit the `Host(...)` label and ACME email in
|
||
[deploy/traefik/docker-compose.traefik.yml](../deploy/traefik/docker-compose.traefik.yml),
|
||
start the stack, then extract PEM files for Postfix whenever Traefik issues or
|
||
renews a certificate:
|
||
|
||
```sh
|
||
docker compose -f docker-compose.yml -f traefik/docker-compose.traefik.yml up -d
|
||
./traefik/extract-cert.sh ./traefik/letsencrypt/acme.json mail.example.com ./traefik/extracted-certs
|
||
```
|
||
|
||
Schedule `extract-cert.sh` (cron or a timer) alongside Traefik's renewals.
|
||
|
||
## Instance administration
|
||
|
||
After sign-in the global role opens on **Overview** — the place to answer "is
|
||
the service healthy and will mail be accepted?" A user with the *domain* role
|
||
opens on the first list they reach: Outbound › Domains, or Inbound › Domains
|
||
when that is all they are assigned.
|
||
|
||
### The panel
|
||
|
||
The menu is grouped by mail direction, and a path reads like the menu:
|
||
`/<group>/<page>`, an entity under its list, an action under the thing it
|
||
changes.
|
||
|
||
| Menu | Pages | Path |
|
||
|---|---|---|
|
||
| Overview | Overview | `/overview` |
|
||
| Outbound | Domains · Log · Queue · DMARC reports | `/outbound/domains`, `/outbound/log`, `/outbound/queue`, `/outbound/dmarc` |
|
||
| Inbound | Domains | `/inbound/domains` |
|
||
| Server | Health · System log · Backup · Users · Settings | `/server/health`, `/server/log`, `/server/backup`, `/server/users`, `/server/settings` |
|
||
| User menu (your name) | Account · Help · Sign out | `/account`, `/help`, `POST /logout` |
|
||
|
||
A page a user cannot reach, or a feature that is off, leaves no gap in the
|
||
menu: a group with nothing in it is absent. *Inbound* appears only with
|
||
`INBOUND_RELAY_ENABLE=true` and *DMARC reports* only with
|
||
`DMARC_REPORTS_ENABLE=true`. Everything under `/server/`, and Overview and the
|
||
queue, answer 404 to a user with the *domain* role — the same answer a missing
|
||
page gets.
|
||
|
||
Pages that used to be one are now several. The old Status page is **Overview**
|
||
(one verdict per check) and **Server › Health** (the tables behind them). The
|
||
old Settings page is **Account** (your own name, e-mail and password) and
|
||
**Server › Settings** (the instance). A domain has its own page, **Domain
|
||
settings** under it, and an **application** form of its own under the domain.
|
||
|
||
### Help
|
||
|
||
**Help** (user menu) is a page of topics, one box per section of the panel —
|
||
Overview, Outbound, Inbound, Server, Account — and a user is shown the sections
|
||
their menu shows. The **?** in the head of a box opens the topic about that box
|
||
on the Help page, scrolled to it. The topics explain what a check
|
||
or control *means* — kernel counters, TLS on port 465, forward-confirmed PTR,
|
||
DNS publish hints, rate limits, DMARC report addresses, the spam filter, and
|
||
similar. They are not a second copy of this document: installation,
|
||
reverse-proxy setup, backup/restore, environment variables, and security
|
||
assumptions stay here in `docs/guide.md`.
|
||
|
||
### Overview and Health
|
||
|
||
**Overview** (`/overview`, global role) shows the stamp — the worst of its
|
||
checks — and one card per check: machine (processor and memory), supervised
|
||
processes (Postfix, OpenDKIM, panel), TLS certificate, queue, milter sockets
|
||
and reverse DNS. A check the panel could not run shows as a warning, never as
|
||
fine. Below the cards are the outbound domain list and, with the inbound relay
|
||
on, the inbound one. Each card leads to its table on **Server › Health**
|
||
(`/server/health`) — the queue card to the Outbound queue — and Health has the
|
||
readings, in the order of what stops mail first:
|
||
|
||
- **Machine** — processor (core and thread counts), memory and swap, and
|
||
per-interface network throughput and totals, read from the kernel's
|
||
counters; CPU and throughput are measured between refreshes, so they appear
|
||
one refresh after the page opens. A fully busy processor or a machine out of
|
||
memory is a warning, because both delay or kill the mail path; throughput is
|
||
only reported.
|
||
- **Processes**, **TLS certificate** (validity and expiry) and **Milter
|
||
sockets** (OpenDKIM, the sender check and the journal-milter).
|
||
- **Sender check** — what the strict check of the `From:` line has cost
|
||
today, in the last 30 days and in the last 12 months: for how long the
|
||
panel did not answer while Postfix was running, and how many messages
|
||
Postfix deferred because of it (see [Who an application may send
|
||
as](#who-an-application-may-send-as)). A restart of the whole container is
|
||
not counted: mail was stopped for another reason then. What is counted as
|
||
a message is a connection Postfix turned away - it refuses before a
|
||
message is offered - so a monitor that only connects during an outage is
|
||
counted too. Days are UTC days. The card warns when
|
||
a message was deferred in the last 24 hours. If these numbers do not stay
|
||
at nothing, the panel is in the way of your mail more than it should be -
|
||
report it.
|
||
- **Hostname and reverse DNS** — compares `SELFPOST_HOSTNAME` against the PTR
|
||
record the internet publishes for this server's IP (forward-confirmed
|
||
reverse DNS), see [Server-level DNS](#server-level-dns-ptrrdns); use
|
||
*Re-check DNS* after changing DNS.
|
||
- **Configuration** — the **Reload configuration** button re-applies OpenDKIM
|
||
tables and the Postfix sender map from the database (and inbound relay maps
|
||
when `INBOUND_RELAY_ENABLE=true`) — use it if daemons drifted from what the
|
||
panel shows after manual edits under `/data`. It does **not** rebuild DMARC
|
||
Postfix maps; a full restore Resync does (see [Restore](#restore)).
|
||
|
||
### Queue and System log
|
||
|
||
- **Outbound › Queue** (`/outbound/queue`, global role) — live view of
|
||
messages Postfix is still trying to deliver or deferring, as `postqueue -p`
|
||
lists them. It only shows: nothing there deletes or forces a message. A box
|
||
states this instance's retry policy — first retry delay, later backoff cap,
|
||
how long a message stays in the queue — from `postconf -h`, read once when
|
||
the panel starts. A `postconf -e` override inside the container is visible
|
||
after the next panel (or container) restart. There is no maximum attempt
|
||
count: Postfix retries until the message is delivered or the queue lifetime
|
||
runs out.
|
||
- **Server › System log** (`/server/log`) — tail of `/data/log/mail.log`
|
||
(Postfix and related daemon lines). The log rotates daily (14 files kept)
|
||
with a `postfix reload` after each rotation; a background loop checks every
|
||
six hours. It lives in the data volume, so it survives a container recreate
|
||
along with the rest of the state — `./data/log/` on the host — but it is
|
||
*not* included in backups: it is diagnostics, not state.
|
||
|
||
### Account
|
||
|
||
**Account** (`/account`, every role) is the signed-in user's own: **Profile**
|
||
(username and e-mail) and **Password**. Saving the profile does not ask for the
|
||
password; changing the password asks for the current one and is rate-limited
|
||
like a sign-in. The e-mail is the user's own address: it is where the panel
|
||
sends [notifications](#notifications) while *Send me notifications* is ticked
|
||
beside it, and what a domain's *Use my e-mail* button fills in (see
|
||
[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc)). Application SASL logins
|
||
are separate and are not changed here.
|
||
|
||
### Settings
|
||
|
||
**Server › Settings** (`/server/settings`, global role) holds what is true for
|
||
the whole instance: how many days the **Outbound log** keeps its rows before the
|
||
background sweep deletes them (7–365 days; takes effect on the next six-hour
|
||
prune cycle without a container restart), and, read-only, the level-1 rate
|
||
limit that comes from `.env` ([Rate limiting — level 1](#rate-limiting--level-1-ip-backstop)).
|
||
There is no panel-wide DMARC report address: each domain has its own. The page
|
||
also has the switch for [notifications](#notifications) and the two DNS records
|
||
that make them arrive.
|
||
|
||
### Notifications
|
||
|
||
The panel writes to its users when something needs them. Four things are
|
||
reported, each once:
|
||
|
||
| What | When a message is sent | A second message |
|
||
|---|---|---|
|
||
| A sending domain's DKIM, SPF or DMARC record stops matching | after two hourly checks in a row found it wrong. A resolver that did not answer is not a mismatch; a missing DMARC record is advice on the domain's page, not a notification | when two checks in a row find the records right again |
|
||
| A level-2 rate limit refuses mail of a sending domain's applications | within a minute of the first refusal | at most once a day for the same domain, while it goes on |
|
||
| The TLS certificate is about to expire | 14 days before | 3 days before, if it is still the same certificate |
|
||
| A message has waited in the queue for more than three days | at the next hourly check | when no message that old is left |
|
||
|
||
A user with the **global** role is told all of it; a user with the **domain**
|
||
role is told about the DNS records and rate limits of the outbound domains
|
||
assigned to them. A message goes to the e-mail on the user's
|
||
[Account](#account) page and only while *Send me notifications* is ticked
|
||
there; a user without an e-mail gets none. **Server › Settings** has one switch
|
||
that turns notifications off for everyone. With it off nothing is checked, so
|
||
what is wrong when it is turned on again is reported then.
|
||
|
||
Notifications are the panel's own mail. They are sent from
|
||
`postmaster@<SELFPOST_HOSTNAME>`, one message per recipient, are not rows of
|
||
the Outbound log and count against no rate limit. They name the page of the
|
||
panel to open and carry no link: the panel does not know the address its
|
||
reverse proxy serves it under.
|
||
|
||
**Two DNS records make them arrive.** The server's own name is not one of your
|
||
sending domains, so it needs records of its own, shown with the values to
|
||
publish under **Server › Settings › Notification mail**:
|
||
|
||
- a **DKIM** key for the hostname, which the panel generates and signs
|
||
notifications with (`<selector>._domainkey.<hostname>`, TXT);
|
||
- an **SPF** record for the hostname: `v=spf1 a -all` (TXT).
|
||
|
||
**Server › Health** shows whether each is in place in the *Notification mail*
|
||
card. Without them notifications are still sent, and a receiver may file them
|
||
as spam or, if the parent domain publishes a strict DMARC policy, refuse them.
|
||
Mail of your sending domains depends on neither. If the hostname is itself one
|
||
of your sending domains, the DKIM record is that domain's and is already
|
||
published - and whoever administers that domain can then send mail that
|
||
looks like the panel's; a hostname that is nobody's sending domain is the
|
||
better choice.
|
||
|
||
The e-mail on Account may be at a public mail host (Gmail and the like); only
|
||
a domain's DMARC report address may not.
|
||
|
||
A relay that cannot send cannot say so: none of the four is "the relay is
|
||
down". Watch that from outside ([Container health](#container-health)).
|
||
|
||
### Users
|
||
|
||
**Server › Users** (`/server/users`, global role only) creates, edits, and
|
||
deletes panel users. Everyone in the panel is an administrator, so the two
|
||
roles name a *reach*, not a rank, and are called `global` and `domain` in the
|
||
list, the role select and the page headers:
|
||
|
||
- **global** — every page and every domain: Overview, Server (Health, System
|
||
log, Backup, Users, Settings), the Outbound queue, and every outbound and
|
||
inbound domain. The only role that adds or deletes a domain and that manages
|
||
users.
|
||
- **domain** — only the domains assigned to the user, outbound and inbound
|
||
**separately**: the user form has one list for each, and the same name on
|
||
both lists is two assignments. Each list has **All**, which includes domains
|
||
added later; with it ticked the rows below are ignored. A *domain* user needs
|
||
at least one domain, or *All*, on either list. A group with nothing assigned
|
||
is absent from that user's menu.
|
||
|
||
What the *domain* role reaches inside what is assigned:
|
||
|
||
| | `global` | `domain` |
|
||
|---|---|---|
|
||
| Overview, Server, the Outbound queue | yes | no (404) |
|
||
| Outbound: domain page, applications, Domain settings, log | every domain | assigned domains |
|
||
| Outbound › DMARC reports | every report, with the ingest statistics | reports of assigned domains, without the ingest box |
|
||
| Inbound: domain page, upstream, recipients | every domain | assigned inbound domains |
|
||
| Add or delete a domain, either direction | yes | no |
|
||
| Export a domain (Domain settings) | yes | assigned domains |
|
||
| Import a domain (Server › Backup) | yes | no |
|
||
| Account, Help | yes | yes |
|
||
|
||
A domain that is not assigned, one that does not exist, and a user with no
|
||
reach in that direction all get the same 404.
|
||
|
||
The panel refuses to remove or demote the **last** global user, so it can never
|
||
end up with none. A user cannot delete the account they are signed in with.
|
||
The user form also sets the user's e-mail, which the users list shows.
|
||
|
||
### Sessions
|
||
|
||
A login survives a container restart: sessions live in SQLite, not in
|
||
memory. A session belongs to the user it was created for, by id: deleting the
|
||
user ends their sessions, and renaming them keeps theirs. Expiry is a sliding
|
||
idle window (`PANEL_SESSION_IDLE_DAYS`, default seven days) with no absolute
|
||
lifetime cap — a user who keeps using the panel stays signed in indefinitely.
|
||
HTMX polling on the monitoring screens (Overview, Health, Outbound log and
|
||
queue, System log) does **not** count as activity, so an auto-refreshing tab
|
||
left open will not keep a session alive forever.
|
||
|
||
- Changing **your own** password on **Account** signs out every other session
|
||
of yours but leaves the current browser signed in.
|
||
- A password that a global user sets for **another** user on the user form
|
||
ends all of that user's sessions; set for oneself, it behaves like the
|
||
previous item.
|
||
- Signing out (`POST /logout`) ends only the current session — other browsers or
|
||
tabs for the same user keep working until their session rows expire.
|
||
|
||
### Upgrading
|
||
|
||
Bump the pinned image tag in `docker-compose.yml` to the target release, then
|
||
`docker compose up -d`. The backup version check requires the running image
|
||
to match the version that created a full backup — see [Fixed image
|
||
tag](#fixed-image-tag).
|
||
|
||
### Container health
|
||
|
||
The image declares a Docker `HEALTHCHECK` that probes `GET /healthz` on port
|
||
8080 (unauthenticated). It returns `200 ok` when OpenDKIM, the panel, and
|
||
Postfix are all `RUNNING` under supervisord; otherwise `503 unhealthy`. This
|
||
catches a dead mail path that would still leave the HTTP server up, but it
|
||
does **not** verify TLS certificates, DNS records, or end-to-end delivery —
|
||
use the authenticated [Overview and Health](#overview-and-health) pages for
|
||
that. External monitoring
|
||
can use the same endpoint through the reverse proxy if you expose it, or poll
|
||
`docker inspect` health state on the host.
|
||
|
||
### Server-level DNS (PTR/rDNS)
|
||
|
||
Once, for the machine itself: **PTR/rDNS** for the server's IP, pointing at
|
||
its mail hostname. Most receiving mail servers weigh this heavily; get it
|
||
from whoever assigns the IP (hosting provider's panel/support), not from
|
||
your own DNS zone.
|
||
|
||
**Server › Health** verifies the server's hostname against this record
|
||
(forward-confirmed reverse DNS), and Overview shows the verdict. Results are
|
||
cached for about one minute; use *Re-check DNS* right after publishing a
|
||
record.
|
||
|
||
Per-domain DNS (SPF, DKIM, DMARC) is a separate scope — see
|
||
[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc).
|
||
|
||
### Rate limiting — level 1 (IP backstop)
|
||
|
||
SelfPost applies two independent layers of rate limiting; both can refuse a
|
||
submission, but only level 2 (domain/application, see
|
||
[Domain administration](#rate-limiting--level-2-domain-and-application))
|
||
writes a `rejected` row in the send log. Level-2 ceilings set in the panel
|
||
cannot exceed level 1 (the panel shows the level-1 values and rejects higher
|
||
numbers).
|
||
|
||
Level 1 is always on, configured via `.env`:
|
||
|
||
- `RATE_LIMIT_MESSAGES_PER_IP` → Postfix `smtpd_client_message_rate_limit`
|
||
- `RATE_LIMIT_WINDOW_SECONDS` → Postfix `anvil_rate_time_unit`
|
||
|
||
This is an anvil limit per connecting client IP. It keeps working even if the
|
||
journal-milter (level 2) is down. There is no per-IP bypass.
|
||
|
||
### Full backup and restore
|
||
|
||
**Full backup** is a self-contained project archive: `data/` (SQLite, all
|
||
domains' DKIM keys, all applications' SASL credentials, `manifest.json` with
|
||
the version that created it), plus `docker-compose.yml`, `.env`, and `certs/`
|
||
from the operator directory next to `./data`. Delivery logs under `data/log/`
|
||
are excluded, and so is the Postfix queue: the archive is taken while the
|
||
instance runs, and a copy of a queue Postfix is working on can hold a message
|
||
twice or not at all. Mail still waiting for delivery is therefore not carried
|
||
over, and a restored instance starts with an empty queue; to keep the queue,
|
||
stop the instance and use `tar` (methods 3 and 4 below). The base compose file mounts the project
|
||
directory read-only at `/selfpost-deploy` so the panel and CLI can read those
|
||
deploy files — without that mount, *Full backup* refuses with an error.
|
||
|
||
#### Taking a full backup
|
||
|
||
There are four ways to capture a full backup. They differ in whether the
|
||
instance keeps running, what goes into the archive, and what happens on restore.
|
||
|
||
**1. Panel — while the container is running (no downtime).** *Server › Backup*
|
||
→ *Full backup*. *Encrypt with a password* is ticked; untick it for a plain
|
||
archive. Same archive as the CLI below. The panel runs as its own user, not as
|
||
root, so **the private key in `certs/` has to be readable by it**: give the
|
||
file to the container's `selfpost` group, which has the fixed id 998 and holds
|
||
both the panel and Postfix —
|
||
|
||
```sh
|
||
chgrp 998 certs/privkey.pem && chmod 640 certs/privkey.pem
|
||
```
|
||
|
||
— and have whatever renews the certificate do the same (a certbot deploy hook,
|
||
for one). A key readable by root alone is enough for Postfix, but then the
|
||
panel refuses to make a backup and names the file: it does not hand over an
|
||
archive without the key.
|
||
|
||
**2. `selfpost-backup` CLI — while the container is running (no downtime).**
|
||
The panel button and this command call the same code path: a consistent SQLite
|
||
snapshot via `VACUUM INTO`, then a gzip tar of `data/` (minus diagnostics) plus
|
||
deploy files. `docker exec` runs the command as root, so it reads the private
|
||
key whatever its permissions.
|
||
|
||
```sh
|
||
docker exec <container> selfpost-backup > selfpost-backup.tar.gz
|
||
```
|
||
|
||
Use `-o /path/inside/container` to write inside the container instead of
|
||
stdout. Optional encryption: set `SELFPOST_BACKUP_PASSWORD` or pass
|
||
`-password-file` — see [Encrypting a backup or
|
||
export](#encrypting-a-backup-or-export).
|
||
|
||
**3. `tar` of the whole project directory — with the service stopped.** Stop
|
||
the instance so nothing is writing to SQLite, then archive the operator
|
||
directory (the folder that contains `docker-compose.yml`, `.env`, `data/`, and
|
||
usually `certs/`):
|
||
|
||
```sh
|
||
docker compose down
|
||
tar czf ../selfpost-backup.tar.gz .
|
||
docker compose up -d
|
||
```
|
||
|
||
Postfix delivery logs under `data/log/` are included and are often the bulk of
|
||
the archive; add `--exclude='./data/log'` if you only want state. Unlike the
|
||
panel/CLI backup, this archive has no `data/manifest.json`, so restore does not
|
||
run the version guard or the one-time post-restore *Resync* (see
|
||
[Restore](#restore) below) — a normal `docker compose up` is enough when the
|
||
files were captured cleanly while stopped.
|
||
|
||
**4. `tar` of `./data` only — with the service stopped.** Same as (3), but only
|
||
the data volume:
|
||
|
||
```sh
|
||
docker compose down
|
||
tar czf ../selfpost-data.tar.gz ./data
|
||
docker compose up -d
|
||
```
|
||
|
||
This is **not** self-contained: you must keep `docker-compose.yml`, `.env`,
|
||
and `certs/` separately (another backup, or unchanged on the same host).
|
||
Restore is `tar xzf selfpost-data.tar.gz` into an existing project directory,
|
||
not into an empty one. Also includes `data/log/` unless you exclude it.
|
||
|
||
**Do not `tar` while the container is running.** SQLite uses WAL mode; copying
|
||
or archiving `./data` (or the whole project directory) while the panel and
|
||
Postfix are writing can produce an inconsistent database. The panel and CLI
|
||
backup avoid this by snapshotting the database on a live instance; the `tar`
|
||
methods avoid it by stopping first.
|
||
|
||
| | Panel / `selfpost-backup` | `tar` project dir (stopped) | `tar` `./data` only (stopped) |
|
||
|--|---------------------------|------------------------------|-------------------------------|
|
||
| Downtime | None | Yes (`docker compose down`) | Yes |
|
||
| Self-contained archive | Yes | Yes | No — deploy files separate |
|
||
| SQLite consistency | `VACUUM INTO` snapshot | Safe when stopped | Safe when stopped |
|
||
| `data/log/` | Excluded | Included (optional exclude) | Included (optional exclude) |
|
||
| Postfix queue | No | Yes | Yes |
|
||
| `certs/` private key | Yes (the panel needs it readable by group 998) | Yes | No — deploy files separate |
|
||
| `data/manifest.json` | Written (version stamp) | Absent | Absent |
|
||
| Restore version check | Yes — must match image tag | No | No |
|
||
| Post-restore *Resync* | Yes, on first boot | No | No |
|
||
| Password encryption (`.spbk`) | Yes | No — encrypt the `.tar.gz` yourself if needed | No |
|
||
|
||
Prefer the panel or CLI when you cannot afford downtime. Prefer a stopped `tar`
|
||
of the whole project directory when you already plan maintenance and want a
|
||
plain archive without going through the container. Use `tar` of `./data` only
|
||
when deploy files are managed elsewhere and never change.
|
||
|
||
#### Restore
|
||
|
||
**Panel and CLI archives** (with `data/manifest.json`) restore by unpacking into
|
||
an **empty project directory** (not into `./data` alone) and starting a
|
||
container of the **exact same image version** that created the backup — SelfPost
|
||
refuses to start otherwise and tells you which tag to use. On the first
|
||
successful start after restore, `data/manifest.json` from the archive is
|
||
**deleted** — it guards only that one boot, so a later in-place upgrade is not
|
||
blocked. On that same first boot the panel also runs one **Resync** — OpenDKIM's
|
||
tables and Postfix's sender map are re-derived from SQLite (inbound relay maps
|
||
when `INBOUND_RELAY_ENABLE=true`; DMARC maps when `DMARC_REPORTS_ENABLE=true`)
|
||
and both daemons are reloaded, healing any drift between the extracted files and
|
||
the database. The *Reload configuration* button on Server › Health resyncs
|
||
OpenDKIM, the sender map, and inbound maps only — not DMARC maps. This is why the
|
||
compose file pins a fixed tag rather than `:latest`: without a known version,
|
||
there'd be no way to tell which image restoring a given backup actually requires
|
||
(see [Fixed image tag](#fixed-image-tag)).
|
||
|
||
**Stopped-`tar` archives** (methods 3 and 4 above) have no `manifest.json`, so
|
||
there is no version guard and no automatic post-restore *Resync*. Unpack a
|
||
self-contained project archive into an empty directory (or replace `./data` in
|
||
place for a data-only archive) and `docker compose up -d`. Use an image tag
|
||
compatible with the data on disk; when in doubt, match the tag that was running
|
||
when the archive was taken.
|
||
|
||
**Restoring in place** (same host — recovering from data loss, or rolling
|
||
back after a bad change):
|
||
|
||
```sh
|
||
# 1. Stop the instance being replaced
|
||
docker compose down
|
||
|
||
# 2. Move the current project aside rather than deleting it
|
||
mv . ../selfpost.before-restore
|
||
mkdir selfpost && cd selfpost
|
||
|
||
# 3. Unpack the backup into the fresh directory
|
||
tar xzf ../selfpost-backup.tar.gz
|
||
|
||
# 4. docker-compose.yml in the archive must pin the exact tag the backup was
|
||
# made with — check if unsure:
|
||
tar xzf ../selfpost-backup.tar.gz -O data/manifest.json
|
||
|
||
# 5. Start it and watch the boot
|
||
docker compose up -d
|
||
docker compose logs -f selfpost
|
||
```
|
||
|
||
A version mismatch at step 5 refuses to start and leaves `/data` untouched —
|
||
the panel exits with a message naming the tag to use, e.g.:
|
||
|
||
```
|
||
backup: this backup was created by SelfPost 1.2.3 but this image is 1.5.0 — restore into the matching image (selfpost:1.2.3)
|
||
```
|
||
|
||
Fix the tag in `docker-compose.yml`, `docker compose pull && docker compose up
|
||
-d` again — the manifest is still there because the failed boot never got to
|
||
delete it.
|
||
|
||
**Moving to a different host** is the same flow: create an empty project
|
||
directory, unpack the backup there, edit `.env` (and `docker-compose.yml` if
|
||
needed) for the new hostname or proxy, then `docker compose up -d`. The archive
|
||
carries `certs/` from the old host — re-issue certificates when the hostname or
|
||
IP changes. Set up the reverse-proxy vhost separately (not in the backup).
|
||
|
||
The inbound log and the filter lists are in the database and so in the
|
||
backup, and so are the list files under `data/antispam/`; the panel writes
|
||
those files again from the database on the first start after a restore. What
|
||
the backup does not hold is the spam filter's own side: the `antispam/`
|
||
directory with the compose fragment and the rspamd configuration. Like the
|
||
reverse-proxy fragments it is part of the installation, not of its state -
|
||
copy it from [deploy/antispam/](../deploy/antispam/) again, or the restored
|
||
instance runs with no filter.
|
||
|
||
**Restoring an encrypted (`.spbk`) backup** needs a running container to
|
||
decrypt it first — any container with the `selfpost-backup` CLI works; decryption
|
||
does not read `/data` and performs no version check. Start one normally
|
||
(step 5, but on an empty project you have not unpacked yet), then:
|
||
|
||
```sh
|
||
docker exec -i <container> selfpost-backup -decrypt < backup.spbk > selfpost-backup.tar.gz
|
||
```
|
||
|
||
Stop it, wipe the project directory again, and continue from step 2 above with
|
||
the resulting `.tar.gz` — see [Encrypting a backup or
|
||
export](#encrypting-a-backup-or-export) for the decrypt command's password
|
||
options.
|
||
|
||
**Archives and data directories from 1.x cannot be restored into 2.0.** The
|
||
database is refused at start and left untouched — 2.0 starts from an empty data
|
||
directory. Carry each domain over with its
|
||
[export file](#exporting-and-importing-a-single-domain) instead.
|
||
|
||
Restoring an archive taken **before** a session row was removed can bring
|
||
that session back: session rows travel with the backup, and a browser that
|
||
still holds the matching cookie is signed in again on the next request if the
|
||
restored row's idle expiry has not passed. `POST /logout` removes only the
|
||
current session; there is no "logout everywhere" — see
|
||
[Sessions](#sessions) for what ends a user's sessions.
|
||
|
||
See also [Exporting and importing a single
|
||
domain](#exporting-and-importing-a-single-domain) — a different, domain-scoped
|
||
operation: *Export domain* is on the domain's settings page, *Import a domain*
|
||
on *Server › Backup* (`/server/backup`).
|
||
|
||
Both a full backup and a domain export are **secrets** — they contain the
|
||
password hashes of the panel's users (full backup), TLS private keys and `.env`
|
||
(full backup), or working application credentials (domain export) in the clear
|
||
or in directly reversible form. Treat them like any other credential material:
|
||
restrict who can read them, don't email them around — and encrypt them, which
|
||
SelfPost does unless you untick the box.
|
||
|
||
#### Encrypting a backup or export
|
||
|
||
Both download forms carry an **Encrypt with a password** checkbox, ticked by
|
||
default. Ticked, the file that comes down is an encrypted envelope instead of
|
||
the plain archive; unticked, it is the plain archive:
|
||
|
||
| Artefact | Plain | Encrypted |
|
||
|----------|-------|-----------|
|
||
| Full backup | `.tar.gz` | `.spbk` (**S**elf**P**ost **b**ac**k**up) |
|
||
| Domain export | `.json` | `.spde` (**S**elf**P**ost **d**omain **e**xport) |
|
||
|
||
The suffixes are for the operator only — the server detects an encrypted file
|
||
by its magic bytes (`SELFPOST1`), not by the extension.
|
||
|
||
The key is derived from the password with scrypt and the contents are sealed
|
||
with AES-256-GCM, in chunks, so a truncated or altered file fails to open rather
|
||
than restoring quietly. **SelfPost does not store the password** — lose it and
|
||
the file is unrecoverable, which is the entire point.
|
||
|
||
*Import a domain* takes an encrypted export directly: choose a `.spde` file and
|
||
the password field appears (driven by the file extension in the browser; the
|
||
server also detects the envelope by its magic bytes). A plain `.json` export
|
||
needs no password.
|
||
|
||
A full backup has to be turned back into a plain archive before it can be
|
||
unpacked into `/data`, which the CLI does with the same password:
|
||
|
||
```sh
|
||
docker exec -i <container> selfpost-backup -decrypt < backup.spbk > backup.tar.gz
|
||
```
|
||
|
||
The CLI also *writes* encrypted backups for scripted/cron use. The password
|
||
comes from `SELFPOST_BACKUP_PASSWORD` or `-password-file <path>` (first line),
|
||
never from a command-line argument, which would be visible in the process list:
|
||
|
||
```sh
|
||
docker exec -e SELFPOST_BACKUP_PASSWORD="$PW" <container> selfpost-backup > backup.spbk
|
||
```
|
||
|
||
With no password set, the CLI keeps writing the plain `.tar.gz` it always has.
|
||
|
||
### Inbound relay
|
||
|
||
Optional backup-MX / forwarder: Postfix accepts mail on port **25** for
|
||
domains you list under *Inbound › Domains* and hands each message to the
|
||
upstream host you configure. It is **not** mailboxes, IMAP, or webmail —
|
||
SelfPost never stores the message locally.
|
||
|
||
**Off by default.** Set `INBOUND_RELAY_ENABLE=true` in `.env` and recreate the
|
||
container. Until then there is no inbound-relay `smtp inet` listener, no
|
||
*Inbound* group in the menu, and `/inbound/…` is 404. (Port 25 can still
|
||
listen when only `DMARC_REPORTS_ENABLE=true` — see [DMARC
|
||
reports](#dmarc-reports).) Outbound 465/587 is unchanged.
|
||
|
||
**Panel** (`/inbound/domains`): the global role adds and deletes domains. On a
|
||
domain's page, set the upstream host/port and TLS to that hop (opportunistic /
|
||
required / off), and choose recipients — an allow-list, or any address at that
|
||
domain. Inbound domains are assigned to a user with the *domain* role
|
||
separately from outbound ones (see [Users](#users)); such a user sees and
|
||
changes the upstream and recipients of the inbound domains assigned to them,
|
||
and cannot add or delete one. The upstream may be any host and port, including
|
||
loopback and private addresses — see the accepted risks in
|
||
[security.md](security.md). A domain with an empty upstream is kept in the
|
||
database but is **not** published into Postfix maps, so mail is never accepted
|
||
with nowhere to send it.
|
||
|
||
**DNS.** Unlike sending domains, an inbound domain needs an **MX** record that
|
||
points at `SELFPOST_HOSTNAME`. The domain page shows the value to publish
|
||
(`10 <hostname>.`) and a check that succeeds when *any* MX host matches this
|
||
server — other MX targets (a primary mail server) are fine; this is how
|
||
backup-MX is meant to work. Use *Re-check DNS* after publishing.
|
||
|
||
**Not an open relay.** The inbound smtpd offers no SASL. It accepts only
|
||
domains in `relay_domains` and only listed recipients (`relay_recipient_maps`);
|
||
everything else is `reject_unauth_destination` / `reject_unlisted_recipient`.
|
||
Prefer an explicit recipient list so unknown addresses are refused at RCPT
|
||
and never generate a bounce (backscatter).
|
||
|
||
**Anti-spam.** SelfPost does not ship a filter. To attach one, set
|
||
`INBOUND_ANTISPAM_MILTER` (requires inbound relay enabled; not on DMARC-only
|
||
port 25) and merge
|
||
[deploy/antispam/docker-compose.antispam.yml](../deploy/antispam/docker-compose.antispam.yml)
|
||
the same way as the nginx/Caddy fragments:
|
||
|
||
```sh
|
||
docker compose -f docker-compose.yml -f antispam/docker-compose.antispam.yml up -d
|
||
```
|
||
|
||
The milter sees the real client IP, HELO and PTR — unlike the upstream, which
|
||
only sees SelfPost. Default action is fail-open (`accept`) so a down sidecar
|
||
does not block backup-MX; set `INBOUND_ANTISPAM_MILTER_ACTION=tempfail` to
|
||
defer instead.
|
||
|
||
The fragment mounts one file into the sidecar,
|
||
[deploy/antispam/rspamd/milter_headers.conf](../deploy/antispam/rspamd/milter_headers.conf):
|
||
it makes rspamd write its verdict - the action and the score with its symbols
|
||
- into two headers of every message it lets through. SelfPost records them
|
||
with the message, and the upstream receives them with the mail. Copy the
|
||
`antispam/rspamd/` directory next to the fragment; without the file accepted
|
||
mail is still recorded, with no verdict.
|
||
|
||
The same file makes rspamd **sign** each message, in a third header,
|
||
`X-SelfPost-Filter`. The filter is attached so that mail still flows when it
|
||
is down, and then a sender could write a verdict of his own into those
|
||
headers; SelfPost therefore records a verdict only next to a signature it can
|
||
recompute. The key is `data/antispam/filter.key`: SelfPost creates it on its
|
||
first start with the inbound relay on, and the sidecar reads it from the
|
||
directory it already mounts - there is nothing to configure. If Inbound › Log
|
||
shows *selfpost* for mail the filter did judge, the sidecar is running without
|
||
this file: copy `antispam/rspamd/` again and restart the sidecar. Nothing of
|
||
rspamd's needs clearing, then or after a change of the lists or of the key: it
|
||
rereads the list files by itself and reads the key anew for every message. A
|
||
restart is needed only for a change of its own configuration - the files of
|
||
`antispam/rspamd/`.
|
||
|
||
The same directory holds
|
||
[multimap.conf](../deploy/antispam/rspamd/multimap.conf), the rules for the
|
||
**allow and deny lists** kept in the panel. SelfPost does not apply the lists
|
||
itself: it writes them as six files under `data/antispam/` (`allow_ip.map`,
|
||
`allow_from.map`, `allow_domain.map` and the three `deny_` ones), the fragment
|
||
mounts that directory into the sidecar, and rspamd rereads a file within
|
||
seconds of a change - no restart. An entry is a sender address, a sender
|
||
domain (that domain, not its subdomains) or a client address or network. A
|
||
denied sender is refused outright. An allowed one is given a weight that no
|
||
spam score outweighs, so it is accepted - unless a deny entry matches the same
|
||
message: **deny wins**, whichever entries they are. **A sender address or
|
||
domain on the allow list is not proof of anything**: it is the envelope
|
||
sender, which the sending server states and nobody checks, so whoever knows an
|
||
allowed address can send as it. Allow a client address or network where you
|
||
can. The files are readable by
|
||
anyone on the host; they hold addresses, not secrets.
|
||
|
||
In the panel the lists are **Inbound › Filter lists** (global role), and what
|
||
port 25 decided about each message is **Inbound › Log**: accepted mail with the
|
||
filter's verdict, refused mail with the reply it was given. A row of the log
|
||
leads to the lists with its sender filled in. Beside each entry of a list is
|
||
how often it decided a message, counted from the log - so over the days the
|
||
log keeps - and *never* marks an entry that may have outlived its use. The
|
||
count depends on two things in
|
||
[multimap.conf](../deploy/antispam/rspamd/multimap.conf): the message of the
|
||
deny rules and the names of the allow symbols; change them and every entry
|
||
reads *never*. A user with the *domain* role
|
||
sees the log of the inbound domains assigned to them, and not the lists. How
|
||
long the log keeps a row is set under **Server › Settings**.
|
||
|
||
The panel only reports this setting; it does not change it. Every inbound
|
||
domain page has a **Spam filter** box — on or off, what happens to mail while
|
||
the filter is down, and that the filter belongs to the server, not to the
|
||
domain — and the domain list says in its head whether the filter is on. The
|
||
filter's address (`INBOUND_ANTISPAM_MILTER`) is shown to the global role only,
|
||
so a user with the *domain* role sees the state without the server's internals.
|
||
For the global role the filter is also one of the milter sockets on **Server ›
|
||
Health** and in the Overview card: the panel connects to the address (one
|
||
second at most, nothing is sent) and, if there is no answer, says whether
|
||
inbound mail is being deferred (`tempfail`) or going through unfiltered
|
||
(`accept`).
|
||
|
||
**Inbound mail is not in the Outbound log.** Port 25 runs its own milter chain:
|
||
the optional anti-spam milter, and nothing else. OpenDKIM does not sign relayed
|
||
mail (and a signing outage therefore cannot defer it), and the journal-milter
|
||
does not file it — the [Outbound log](#outbound-log) stays a record of what
|
||
*you* sent.
|
||
This also keeps a forged `From:` on inbound mail from counting against a
|
||
sending domain's [level-2 rate limit](#rate-limiting--level-2-domain-and-application).
|
||
|
||
Inbound configuration lives in SQLite and `/data/postfix/` map files, so it
|
||
is included in a [full backup](#full-backup-and-restore). Single-domain
|
||
export/import is sending domains only.
|
||
|
||
### DMARC reports
|
||
|
||
Optional ingest of DMARC **aggregate** reports (`rua=`): Postfix accepts mail on
|
||
port **25** only for addresses you configure, pipes each message to the panel
|
||
ingest worker, and stores parsed summaries in SQLite. Forensic reports (`ruf=`)
|
||
are not stored. This is separate from [Inbound relay](#inbound-relay) — no
|
||
backup-MX, no forwarding upstream.
|
||
|
||
**Off by default.** Set `DMARC_REPORTS_ENABLE=true` in `.env` and recreate the
|
||
container. Until then there is no report ingest, no *DMARC reports* entry in
|
||
the Outbound menu, and `/outbound/dmarc` is 404. Outbound 465/587 is unchanged.
|
||
|
||
**Addresses.** A domain's report address (`rua=`) is one of three choices, set
|
||
in that domain's **Domain settings** (Outbound › Domains › the domain › Domain
|
||
settings) and nowhere else:
|
||
|
||
- **No reports** — the state of a new domain; `rua=` is left out of the DMARC
|
||
record.
|
||
- **SelfPost hosted** — `dmarc-reports+<domain>@<hostname>`, an address on this
|
||
server whose reports are parsed and shown under *DMARC reports*. Offered only
|
||
while `DMARC_REPORTS_ENABLE=true`.
|
||
- **A specific address** — any mailbox, typed for this domain; it receives the
|
||
raw XML. Beside the field, *Use my e-mail* fills it with the e-mail of your
|
||
profile (Account); it only fills the field, and saving is the form's own
|
||
button. The button is absent when your profile has no e-mail.
|
||
|
||
Changing the choice changes the DMARC record to publish; the domain page shows
|
||
the new value. There is no panel-wide or per-user default. Only addresses on
|
||
`SELFPOST_HOSTNAME` that a domain has chosen are accepted on port 25 - the
|
||
hosted address `dmarc-reports+<domain>@<hostname>` of a domain set to
|
||
*SelfPost hosted*, or a typed address that happens to be on this host. There
|
||
is no mailbox for the server as a whole.
|
||
|
||
**DNS.** Publish the usual `_dmarc` TXT on each sending domain with
|
||
`rua=mailto:…` pointing at the address the domain shows. Receivers deliver to the
|
||
address domain — publish **MX** for `SELFPOST_HOSTNAME` (or the report
|
||
address domain if different) so reports reach this server. If a hub domain
|
||
authorises external destinations, publish `_report._dmarc` there too; the panel
|
||
checks it on the domain page.
|
||
|
||
**Panel.** *Outbound › DMARC reports* lists recent reports. The global role also
|
||
sees the *Ingest* box — whether ingest works, the last report received, reports
|
||
kept this week and parse failures. Open a domain's roll-up from the list or
|
||
from *DMARC reports* in the domain page's menu. A user with the *domain* role
|
||
sees the reports of the domains assigned to them, without the *Ingest* box.
|
||
Summaries are pruned (500 kept for each domain, 90 days max).
|
||
|
||
A message that is not a report the relay keeps — unreadable as an aggregate
|
||
report, about a domain not sent from here, or delivered to another domain's
|
||
report address — is dropped without a retry and without a bounce. It adds one
|
||
to *parse failures*, and the reason is on the message's delivery line in the mail log
|
||
(*Server › System log*, `dropped: …`).
|
||
|
||
**Not an open relay.** The inbound smtpd offers no SASL. With DMARC ingest
|
||
alone, `check_recipient_access` permits only configured report addresses;
|
||
everything else is rejected. With inbound relay enabled too, both allow-lists
|
||
apply.
|
||
|
||
**Reports are checked before they are stored.** The address form is
|
||
predictable, so the report XML is treated as untrusted input: a report is
|
||
accepted only when the domain it claims in `policy_published` is a sending
|
||
domain configured here, and — when it arrived at a per-domain hosted address —
|
||
only when that domain matches the address's `+tag`. Anything else is refused
|
||
and counted in *parse failures* on the DMARC reports page, so a stranger cannot file
|
||
reports under your domain or push genuine ones out through the retention cap.
|
||
Reports are accepted as gzip, zip, or plain XML.
|
||
|
||
Parsed report data lives in SQLite and is included in a
|
||
[full backup](#full-backup-and-restore).
|
||
|
||
## Domain administration
|
||
|
||
### Outbound domains
|
||
|
||
**Outbound › Domains** (`/outbound/domains`) lists sending domains with the
|
||
verdict of each DNS check, the DKIM selector, the number of applications and
|
||
recent mail, and hosts the add-domain form (**global role only**). A user with
|
||
the *domain* role sees only the domains assigned to them. Three pages belong
|
||
to a domain:
|
||
|
||
- **The domain page** (`/outbound/domains/{id}`) — the *DNS records* to
|
||
publish and what is in DNS now, the *Applications* (SASL logins) with *New
|
||
password* and *Delete* in their rows, and the *Connection* settings. *Re-check
|
||
DNS* and *Add application* are in its head; its menu leads to the domain's
|
||
log and DMARC reports.
|
||
- **The application form** (`…/applications/new`, `…/applications/{aid}`) —
|
||
who the application may send as, its client IP allow-list and its rate limit
|
||
are validated and saved together, or not at all, also when the application
|
||
is created. A password is shown once, on the page that answers the creation
|
||
or *New password*, and is never shown again.
|
||
- **Domain settings** (`…/settings`) — the *DMARC report address* (see [DMARC
|
||
reports](#dmarc-reports)), the domain's *Rate limit* (see [Rate limiting —
|
||
level 2](#rate-limiting--level-2-domain-and-application)), *Export domain*
|
||
and, for the global role, *Delete domain*, which asks once more.
|
||
|
||
#### Who an application may send as
|
||
|
||
An application sends as any address of its domain, or only as the addresses
|
||
listed for it. Since `2.4.0` that rule covers both senders a message has:
|
||
|
||
- the **envelope sender** (`MAIL FROM`), which Postfix checks;
|
||
- the **`From:` line** of the message - the one a mail program shows and the
|
||
one the DKIM signature is chosen by - which the panel checks.
|
||
|
||
A message whose `From:` is anything else is refused with `550 5.7.1` and a
|
||
text naming the line: another of your domains, the server's own name, an
|
||
address at a domain that is not on this server, a line with two addresses,
|
||
or no `From:` at all. The line is read strictly, because two programs read
|
||
it - the check and the signer - and must not be able to read it differently:
|
||
outside a quoted display name it may hold one `@`, the address's own, and no
|
||
other name this server signs for may follow an `@` anywhere in it, a quoted
|
||
name included. A display name with a comma or brackets has to be quoted
|
||
(`"Shop, Inc." <orders@…>`), as the mail standard asks and mail libraries
|
||
do. The refusal
|
||
is permanent, so the application does not send the message again; it is a
|
||
row of the [Outbound log](#outbound-log) tagged `wrong From`, and the row's
|
||
page shows what the line said.
|
||
|
||
What this stops working when you upgrade from an earlier version:
|
||
|
||
- an application that sends with the `From:` of another of your domains -
|
||
give that domain an application of its own;
|
||
- an application with a list whose envelope address and `From:` address
|
||
differ - add the second address to the list;
|
||
- a contact form that puts its visitor's address into `From:` - put it into
|
||
`Reply-To:` and send from an address of your own, which is also what
|
||
DMARC requires.
|
||
|
||
The check is strict: while the panel does not answer, Postfix answers with a
|
||
temporary error (`451`, and `454` to the login) and applications send again
|
||
later; nothing is accepted unchecked. **Server › Health** counts how often
|
||
that happens.
|
||
|
||
*Export domain* writes a single-domain file; *Import a domain* on *Server ›
|
||
Backup* reads one back in (**global role only**) — see [Exporting and importing
|
||
a single domain](#exporting-and-importing-a-single-domain).
|
||
|
||
### Domain-level DNS (SPF, DKIM, DMARC)
|
||
|
||
For *every* sending domain you add in the panel (outbound). An inbound
|
||
forwarding domain is a different object — it needs an MX, not these TXT
|
||
records; see [Inbound relay](#inbound-relay).
|
||
|
||
- **SPF** — a TXT record on the domain authorizing this server to send on its
|
||
behalf (e.g. `v=spf1 a mx ip4:<server IP> -all`, adjusted to your setup).
|
||
- **DKIM** — a TXT record with the exact value the panel shows on that
|
||
domain's page (*DNS records* box), one selector per domain.
|
||
- **DMARC** — a `_dmarc` TXT record. The panel suggests `p=none` (monitoring
|
||
only, safe to publish immediately). Set `rua=` to receive aggregate reports
|
||
by choosing the domain's report address in *Domain settings*: with [DMARC
|
||
reports](#dmarc-reports) enabled, the SelfPost-hosted address; otherwise an
|
||
address typed for the domain — a mailbox elsewhere that receives mail (*Use
|
||
my e-mail* fills in your profile's) — or no reports, which leaves `rua=` out.
|
||
The record the page shows follows that choice. If `rua=` points at another
|
||
domain, publish `_report._dmarc` on that hub domain too; the panel checks
|
||
it. Public mail hosts (Gmail, Outlook, …) cannot be used as external
|
||
report destinations.
|
||
|
||
Skipping any of the three records is the single most common reason mail
|
||
lands in spam even though SelfPost delivered it correctly — DKIM passing
|
||
doesn't help if SPF/DMARC are absent. **Whenever you add a new domain in the
|
||
panel, add its DNS records at the same time**, not later.
|
||
|
||
Each domain's page has a *DNS records* box comparing the published DKIM
|
||
record against the key this server signs with, plus the domain's SPF,
|
||
DMARC, and (when configured) DMARC report-authorisation records. Results are
|
||
cached for a few minutes; use *Re-check DNS* right after publishing a record.
|
||
The SPF check is deliberately shallow — it looks for a mechanism that
|
||
literally covers this server's address and does not follow `include:` or
|
||
`redirect=`, so a record that authorizes the server through an include is
|
||
reported as "cannot tell" rather than as a failure.
|
||
|
||
Server-level DNS (the PTR/rDNS record) is a separate, once-per-machine scope
|
||
— see [Server-level DNS](#server-level-dns-ptrrdns).
|
||
|
||
### IP warmup
|
||
|
||
A brand-new IP has no sending history, so receiving servers are cautious with
|
||
it regardless of how correct your DKIM/SPF/DMARC are. Start with low volume to
|
||
a domain, increase gradually over days/weeks rather than sending everything on
|
||
day one, and check the IP against major blocklists (Spamhaus and similar)
|
||
before and during warmup. This is inherent to how mail reputation works on the
|
||
public internet, not something SelfPost's configuration can shortcut.
|
||
|
||
### Rate limiting — level 2 (domain and application)
|
||
|
||
Level 2 is optional, configured in each domain's settings (the domain) and
|
||
on each application's form (the application), and layers on top of
|
||
the always-on [level-1 IP backstop](#rate-limiting--level-1-ip-backstop).
|
||
Level-2 ceilings cannot exceed level 1 (the panel shows the level-1 values
|
||
and rejects higher numbers). When a level-2 ceiling is exceeded, Postfix
|
||
returns a 4xx and the refusal is recorded in the [Outbound log](#outbound-log)
|
||
as `rejected`.
|
||
|
||
**Level 2 — domain** — a message ceiling and window for **every** client IP
|
||
sending as that domain. When unset, only level 1 applies for non-privileged
|
||
senders.
|
||
|
||
**Client IP allow-list (application)** — optional restriction on an
|
||
application: when enabled, list one or more client IPs that may **submit** mail
|
||
as that application; a session from any other address is refused with a 4xx at
|
||
`MAIL FROM`. When disabled, any client IP is allowed. This is independent of
|
||
rate limits, and it takes exact addresses only — CIDR ranges are not parsed.
|
||
|
||
It is **not an authentication boundary.** The SASL login itself still succeeds;
|
||
the check runs in the journal-milter, on the same fail-open path as level 2
|
||
(below), so if that milter does not answer the restriction is skipped rather
|
||
than enforced. Use it to narrow where an application may send from, not to contain a
|
||
leaked password — for that, regenerate the password.
|
||
|
||
**Level 2 — application** — optional override of the domain limit for one
|
||
application (≤ level 1). The ceiling may be **higher or lower** than the domain
|
||
limit. When active, it replaces the domain limit for that login; when unset,
|
||
the domain limit (or level 1 alone) applies.
|
||
|
||
**Manual and auto mode** — each domain and application limit can be
|
||
**Manual** (you set the ceiling and window) or **Auto**. Auto derives
|
||
`max_messages` from sending statistics: `ceil(average msg/h × multiplier)`
|
||
over the level-1 window (`RATE_LIMIT_WINDOW_SECONDS`), capped at level 1.
|
||
The panel shows 30-day statistics (total, peak and average msg/h) on the
|
||
domain page; level-1 refusals are not in the send log, so totals
|
||
under-count strict IP limits. When retention is below 30 days, statistics
|
||
use `min(30, retention)` days. With no traffic in the window, auto stays
|
||
inactive until messages are sent. Auto limits are recalculated every six
|
||
hours and on demand via **Recalculate now**.
|
||
|
||
**Level 2 is best-effort, not a guarantee.** It runs inside the
|
||
journal-milter and is deliberately fail-open: if the rate-limit lookup hits
|
||
a store error, or the connecting client's IP is not available to the
|
||
milter, level 2 is skipped and the message is accepted rather than held up.
|
||
The same applies if the journal-milter itself does not answer — Postfix is
|
||
configured to accept rather than defer when it cannot reach it. (Since
|
||
`2.4.0` a panel that is down altogether defers mail anyway, through the
|
||
[sender check](#who-an-application-may-send-as) of the same process.) Level 1
|
||
is the backstop that keeps working even when level 2 cannot run, and it is
|
||
the only one of the two that does not depend on the panel process.
|
||
|
||
### Outbound log
|
||
|
||
**Outbound › Log** (`/outbound/log`) is a searchable send log with server-side
|
||
filters by domain and application; a user with the *domain* role sees only the
|
||
rows of their domains. It refreshes its rows without resetting a filter being
|
||
chosen. A row identifies its message and nothing more — time,
|
||
sender, recipient, subject and status `queued` (accepted, not yet
|
||
delivered), `sent` (handed off successfully), `deferred` (Postfix is
|
||
retrying), `bounced` (final failure), or refused before it was queued. A
|
||
refused row says why in its tag: `rate-limited` (a [level-2 rate
|
||
limit](#rate-limiting--level-2-domain-and-application)), `wrong client` (the
|
||
application's client IP list) or `wrong From` (see [Who an application may
|
||
send as](#who-an-application-may-send-as)); a row refused before `2.4.0`
|
||
says `rejected`;
|
||
*Details* opens that row's own page (`/outbound/log/{id}`). That page carries
|
||
the sending domain, the application it was submitted under, the Postfix
|
||
queue id and the journal id, beside the message's history — when it was
|
||
accepted and what Postfix later reported for the recipient. A `deferred`
|
||
or `bounced` row includes this Postfix's retry intervals (first delay,
|
||
backoff cap, queue lifetime), the same numbers the Outbound queue shows; users
|
||
with the *domain* role see them here because they cannot open the queue. Under
|
||
both sit the `mail.log` lines for its queue id: the connection to the
|
||
receiving server, the server's reply, and the status that reply was filed
|
||
as. The lines are kept in the database with the row, so they stay on the
|
||
page after `mail.log` has rotated and are deleted together with the row; a
|
||
message accepted before `2.2.0` shows them only while they are still in the
|
||
current file, and the page says so when there are none. Retention is set under **Server › Settings** (global
|
||
role); `SEND_LOG_RETENTION_DAYS` in `.env` is only the initial default until it
|
||
is changed there.
|
||
|
||
### Exporting and importing a single domain
|
||
|
||
Domain settings → *Export domain* to write the file, *Server › Backup* →
|
||
*Import a domain* to read it back in. This moves one domain — its DKIM key, its
|
||
applications' **working** SASL passwords, and configured **rate limits**
|
||
(mode, ceilings, multipliers) and client IP allow-lists — to a different SelfPost
|
||
instance without regenerating anything, so DNS (the DKIM TXT record)
|
||
doesn't need to change. Unlike a full restore (see [Full backup and
|
||
restore](#full-backup-and-restore)), this works across different
|
||
hostnames/instances. *Import* is for the global role only; *export* is
|
||
available to any user who can access the domain, **including a user with the
|
||
*domain* role** for a domain assigned to them — so that user can walk away with
|
||
the domain's working SASL passwords, in the clear if they untick the encryption
|
||
box. Weigh that when deciding which domains to assign to a *domain* user.
|
||
|
||
A domain export is a secret in the same way a full backup is. The *Encrypt with
|
||
a password* box is ticked by default, and the export is sealed the same way —
|
||
see [Encrypting a backup or export](#encrypting-a-backup-or-export).
|