Compare commits
178
Commits
v2.0.0
...
faa3c11ae7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
faa3c11ae7 | ||
|
|
005922bc7d | ||
|
|
fa97deb677 | ||
|
|
6099716bf4 | ||
|
|
43ebbc9b20 | ||
|
|
14de9a05c8 | ||
|
|
a07ba5776c | ||
|
|
a556a13f67 | ||
|
|
a27f8332ab | ||
|
|
466a3d2089 | ||
|
|
3b440c2a53 | ||
|
|
a3050afb66 | ||
|
|
1c6e04b7d3 | ||
|
|
68ec8b274c | ||
|
|
b76f1ef1a0 | ||
|
|
aaa94120be | ||
|
|
2c8aec79b4 | ||
|
|
6023feb405 | ||
|
|
dc78b0ed76 | ||
|
|
95af363214 | ||
|
|
b09e525521 | ||
|
|
a39eda5a0b | ||
|
|
dab7715e80 | ||
|
|
d6ed6bcb30 | ||
|
|
eaa7de0754 | ||
|
|
32dfe4a2cf | ||
|
|
006bed7022 | ||
|
|
eee089ad4b | ||
|
|
b90a2f0321 | ||
|
|
2b8e0c086d | ||
|
|
80e3979748 | ||
|
|
ca876c1631 | ||
|
|
643892ab91 | ||
|
|
07be0cfb58 | ||
|
|
48832d223a | ||
|
|
76b21b3597 | ||
|
|
242515c305 | ||
|
|
508ac96c79 | ||
|
|
8e3241ef98 | ||
|
|
68b3a7b23f | ||
|
|
9eddd49602 | ||
|
|
ec08c8606b | ||
|
|
3584adcbc2 | ||
|
|
2cf118cafc | ||
|
|
cad1182150 | ||
|
|
fbd0acd035 | ||
|
|
2e1544dac5 | ||
|
|
3c63ecc1ad | ||
|
|
9ca50d33fd | ||
|
|
98e09fef4a | ||
|
|
ea8b6bfa6d | ||
|
|
d20009c0f0 | ||
|
|
f2ad699473 | ||
|
|
ee37d16dc7 | ||
|
|
463d4ff3fe | ||
|
|
e174537512 | ||
|
|
c8f669714e | ||
|
|
0ed03b2094 | ||
|
|
55aef6e721 | ||
|
|
31c2b50457 | ||
|
|
a658da11f7 | ||
|
|
599149bf83 | ||
|
|
ad3d834392 | ||
|
|
b113e72c71 | ||
|
|
ce363f48dd | ||
|
|
a8b6276b0d | ||
|
|
811055d4c0 | ||
|
|
0f984ff8d8 | ||
|
|
3c8628074f | ||
|
|
e1ddf12744 | ||
|
|
af130e268a | ||
|
|
1ca87f2d1a | ||
|
|
5dcff8c2b9 | ||
|
|
57142b21b5 | ||
|
|
5154bba61c | ||
|
|
415334c2cc | ||
|
|
ac985d03a8 | ||
|
|
df96690ba6 | ||
|
|
65d916f26a | ||
|
|
e166a2ebd6 | ||
|
|
c452606175 | ||
|
|
4678015bbd | ||
|
|
5be109b871 | ||
|
|
ef18ef21a0 | ||
|
|
8b53d63160 | ||
|
|
cb5a4a22e1 | ||
|
|
5747f0b25c | ||
|
|
a618ffb0e4 | ||
|
|
e469034477 | ||
|
|
f9e75de8d1 | ||
|
|
60c7b85f84 | ||
|
|
f51f5db245 | ||
|
|
a59e3d4d61 | ||
|
|
db36aa2c0e | ||
|
|
7af37654a1 | ||
|
|
63520f1678 | ||
|
|
ffefa9d6df | ||
|
|
9adb44845c | ||
|
|
dfe10f6c27 | ||
|
|
9efc9c7005 | ||
|
|
96425dd515 | ||
|
|
8b9798884a | ||
|
|
80bab92c8c | ||
|
|
937b41ddb7 | ||
|
|
cc2ca3c968 | ||
|
|
70b4eb5585 | ||
|
|
86665f08e6 | ||
|
|
c3d99b1f15 | ||
|
|
b9353db507 | ||
|
|
7fdf09839f | ||
|
|
6d9273f418 | ||
|
|
e3bcafb6d9 | ||
|
|
2a96008803 | ||
|
|
8f226bd82f | ||
|
|
c817f0833c | ||
|
|
89fd0a6116 | ||
|
|
c171fb5bc7 | ||
|
|
8542e673a8 | ||
|
|
cbff3e7e33 | ||
|
|
972516d1c4 | ||
|
|
ce7b09a65c | ||
|
|
f09b60aaa2 | ||
|
|
400aa76737 | ||
|
|
b905f3e560 | ||
|
|
c9027381e5 | ||
|
|
50fd6b859f | ||
|
|
06ad174fb9 | ||
|
|
87fa0a4381 | ||
|
|
eff8010d39 | ||
|
|
394eb67c4c | ||
|
|
972a9f825c | ||
|
|
a990e7cc23 | ||
|
|
815236a607 | ||
|
|
a6845ae948 | ||
|
|
1e6836b3c1 | ||
|
|
fc184ecc01 | ||
|
|
81df131217 | ||
|
|
3d17206063 | ||
|
|
4468aa8dd7 | ||
|
|
977d63a2ea | ||
|
|
ae06d8b47e | ||
|
|
1f44e40382 | ||
|
|
d398f488e2 | ||
|
|
89774ab847 | ||
|
|
c731a98a0f | ||
|
|
5f86457848 | ||
|
|
00feb7c1c7 | ||
|
|
d2a778edc0 | ||
|
|
7192c3861e | ||
|
|
e0ccff257d | ||
|
|
c1da784193 | ||
|
|
9d330bed77 | ||
|
|
970c8ca8da | ||
|
|
19156814b8 | ||
|
|
d2b97b3963 | ||
|
|
d4efdb483d | ||
|
|
56c5dfee40 | ||
|
|
82d4a209be | ||
|
|
737611a07e | ||
|
|
eea40bcd49 | ||
|
|
27fd355e53 | ||
|
|
94e94490af | ||
|
|
28ec784ae2 | ||
|
|
a3cc52d6d4 | ||
|
|
d65d632505 | ||
|
|
dd0fefdcb7 | ||
|
|
75ebbf6a57 | ||
|
|
ede3783734 | ||
|
|
d70371a50c | ||
|
|
21e961cb29 | ||
|
|
82f212ab39 | ||
|
|
b55b33ae92 | ||
|
|
8c9a71e6a0 | ||
|
|
646ec1e149 | ||
|
|
8496b289ec | ||
|
|
4dd46ea355 | ||
|
|
505260e227 | ||
|
|
09d9625f32 |
@@ -1,22 +0,0 @@
|
||||
---
|
||||
description: Panel UI mockups use the design system primitives, not a reskin of live templates.
|
||||
globs: docs/assets/panel-ui/**
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Panel UI system
|
||||
|
||||
Source of truth: [docs/assets/panel-ui/system.html](docs/assets/panel-ui/system.html) and `system.css`.
|
||||
|
||||
Do not copy `internal/web/view/templates` into mockups. Do not mark `<main>` as `ops`/`form` to pick width. Do not fix empty columns, Host/Type height, or split Save/Delete with one-off CSS.
|
||||
|
||||
Compose screens from `stack`, `pair`, `measure`, `fill`, `field-row`, `actions-row`:
|
||||
- Two peer jobs → `pair` (one child shrinks to `measure`).
|
||||
- Tables/logs/DNS → `fill` (nowrap + overflow-x on the card). Add-to-list is a `measure` row inside that card, not a second card above it.
|
||||
- Confirm/login/user form → `measure`.
|
||||
- Host ‖ Type and two equal inputs → `field-row` (shared grid row, not two `.code` paddings).
|
||||
- Save + Delete → `actions-row` inside the card; never submit inside `<form>` and danger after `</form>`.
|
||||
- Danger zone is the second column of the last `pair`, never a full-width `fill`.
|
||||
- Card chrome is title + optional `?` + body + actions. Help is not sprinkled later.
|
||||
- Phone: `pair` stacks; `field-row` does not; tables become lists.
|
||||
- One screen is one HTML file. Shared chrome is `shell.js`. Do not put every screen in one hash-SPA.
|
||||
@@ -4,6 +4,9 @@
|
||||
.gitattributes
|
||||
dev
|
||||
docs
|
||||
# test/e2e/.stage holds what an e2e run leaves behind (DKIM keys, sasldb2);
|
||||
# the e2e's own sink image is built from test/e2e/sink as its own context.
|
||||
test
|
||||
bin
|
||||
*.exe
|
||||
README.md
|
||||
|
||||
@@ -6,7 +6,7 @@ name: release
|
||||
# You can also run it manually via workflow_dispatch with an explicit SemVer X.Y.Z.
|
||||
#
|
||||
# Native per-architecture builds (see docs/development.md), not qemu: running
|
||||
# the full Postfix/OpenDKIM stack under emulation for the e2e gate below is
|
||||
# the full Postfix and panel stack under emulation for the e2e gate below is
|
||||
# impractically slow. Each arch builds, e2e-gates and pushes its own tag on its
|
||||
# own native runner; a merge job then combines them into the one manifest tag
|
||||
# documented in deploy/docker-compose.yml. "test, then push" (not push-by-digest
|
||||
@@ -89,8 +89,27 @@ jobs:
|
||||
cache-dependency-path: test/e2e/go.sum
|
||||
|
||||
- name: e2e (gates publishing — see docs/development.md)
|
||||
# The e2e builds selfpost:e2e again through compose.override.yml, and
|
||||
# that rebuild is the image pushed below. Without the version here it
|
||||
# was stamped "e2e" (audit 2026-10-12, H1); the suite now checks the
|
||||
# stamp against this variable.
|
||||
env:
|
||||
SELFPOST_E2E_VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: cd test/e2e && go test -v -timeout 20m ./...
|
||||
|
||||
- name: Check the version stamp of the image about to be pushed
|
||||
env:
|
||||
WANT: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for bin in panel selfpost-backup; do
|
||||
got=$(docker run --rm --entrypoint "$bin" selfpost:e2e -version)
|
||||
if [ "$got" != "$WANT" ]; then
|
||||
echo "::error::selfpost:e2e $bin -version = '$got', want '$WANT'; refusing to push"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
|
||||
@@ -24,6 +24,9 @@ jobs:
|
||||
with:
|
||||
go-version: "1.26"
|
||||
cache: true
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
test/e2e/go.sum
|
||||
|
||||
- name: Format
|
||||
run: |
|
||||
@@ -37,5 +40,24 @@ jobs:
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
# Unused code, dead branches and the usual slips go vet does not look
|
||||
# for. The version is pinned: a new release adds checks, and a check
|
||||
# that appears under a push is a red build nobody asked for. 2026.1 is
|
||||
# the release for Go 1.26. test/e2e is a module of its own, so ./...
|
||||
# skips it; it is checked in its own step below.
|
||||
- name: Staticcheck
|
||||
run: go run honnef.co/go/tools/cmd/staticcheck@2026.1 ./...
|
||||
|
||||
- name: Test
|
||||
run: go test ./...
|
||||
|
||||
# The e2e module runs only in release.yml, after a release is published;
|
||||
# a type error there would first show up then. Compile it here without
|
||||
# running it: `go test -run` would still run TestMain, which brings up
|
||||
# the whole stand.
|
||||
- name: Compile the e2e
|
||||
working-directory: test/e2e
|
||||
run: |
|
||||
go vet ./...
|
||||
go test -c -o /dev/null .
|
||||
go run honnef.co/go/tools/cmd/staticcheck@2026.1 ./...
|
||||
|
||||
+1187
File diff suppressed because it is too large
Load Diff
@@ -106,13 +106,23 @@ 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:
|
||||
>
|
||||
> ```sh
|
||||
> docker build -f build/Dockerfile --build-arg VERSION=2.7.0 \
|
||||
> -t ghcr.io/mixeme/selfpost:2.7.0 .
|
||||
> ```
|
||||
|
||||
```sh
|
||||
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.0.0
|
||||
ghcr.io/mixeme/selfpost:2.7.0
|
||||
```
|
||||
|
||||
**Get the setup URL** (pick one):
|
||||
|
||||
+85
-22
@@ -1,12 +1,12 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# SelfPost — single Debian-slim image running postfix + opendkim + panel under
|
||||
# SelfPost — single Debian-slim image running postfix + panel under
|
||||
# supervisord. Build from the repository root:
|
||||
#
|
||||
# docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .
|
||||
|
||||
# ---- build stage -------------------------------------------------------------
|
||||
FROM golang:1.26-bookworm AS build
|
||||
FROM golang:1.26-trixie AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
@@ -30,57 +30,120 @@ RUN go vet ./... \
|
||||
-o /out/selfpost-backup ./cmd/selfpost-backup
|
||||
|
||||
# ---- runtime stage -----------------------------------------------------------
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
FROM debian:trixie-slim AS runtime
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Preseed Postfix so its install is non-interactive and yields a working
|
||||
# main.cf. The real relay configuration is generated by the panel.
|
||||
# (postfix 3.10.13-0+deb13u1, debian/templates:14 and :50:
|
||||
# postfix/main_mailer_type and postfix/mailname are the questions the install
|
||||
# asks; debian/postfix.postinst:156-171 writes main.cf for any answer but "No
|
||||
# configuration".)
|
||||
# openssl is named because postfix-cert-reload.sh runs it, not left to arrive
|
||||
# with ca-certificates. python3 arrives with supervisor (supervisor 4.2.5-3,
|
||||
# debian/control:29, ${python3:Depends}) - crashexit.py. sasl2-bin brings
|
||||
# saslpasswd2 (cyrus-sasl2 2.1.28+dfsg1-9, debian/rules:207,
|
||||
# debian/sasl2-bin.install:3, usr/sbin) and depends on db-util
|
||||
# (debian/control:36); libsasl2-modules brings the PLAIN and LOGIN plugins
|
||||
# (debian/libsasl2-modules.install:4 and :6); the sasldb auxprop plugin is in
|
||||
# libsasl2-modules-db (debian/libsasl2-modules-db.install:1), a Depends of
|
||||
# libsasl2-2 (debian/control:65). db-util is named for /usr/bin/db_dump, which
|
||||
# the panel runs (internal/app/sasl.go); that db-util ships that path on
|
||||
# trixie is not in a source read here - the image build and the e2e sasldb
|
||||
# test say whether it does.
|
||||
# No DKIM package is installed: the panel signs itself (internal/milter,
|
||||
# sign.go) and makes the keys in Go (internal/domain/dkim.go); OpenDKIM left
|
||||
# the image in 2.8.0 (docs/plans/panel-dkim-signing.md).
|
||||
# systemd-standalone-sysusers is named so that cron-daemon-common, which
|
||||
# logrotate brings (logrotate 3.22.0-1, debian/control: "Depends: cron |
|
||||
# anacron | cron-daemon | systemd-sysv"), creates its group with it rather
|
||||
# than with the whole of systemd, the first alternative of its "Depends:
|
||||
# systemd | systemd-standalone-sysusers | systemd-sysusers" (dpkg -s in the
|
||||
# image, 2026-10-15). Nothing here runs systemd.
|
||||
RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \
|
||||
&& echo "postfix postfix/main_mailer_type string Internet Site" | debconf-set-selections \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
postfix \
|
||||
opendkim \
|
||||
opendkim-tools \
|
||||
sasl2-bin \
|
||||
libsasl2-modules \
|
||||
db-util \
|
||||
supervisor \
|
||||
logrotate \
|
||||
ca-certificates \
|
||||
openssl \
|
||||
curl \
|
||||
systemd-standalone-sysusers \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Unprivileged user for the panel process.
|
||||
RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel
|
||||
# Debian's postfix runs its chroot sync on every "postfix check" and "postfix
|
||||
# start-fg" (postfix 3.10.13-0+deb13u1, debian/patches/
|
||||
# debian-run-configure-instance-from-create-missing.patch); with no service
|
||||
# chrooted (build/postfix-config.sh) it would only copy /etc/resolv.conf and
|
||||
# the like into the queue under /data on each start. SYNC_CHROOT empty turns
|
||||
# it off (debian/configure-instance.in:22-24 and :44; debian/README.Debian:136-138).
|
||||
RUN printf 'SYNC_CHROOT=\n' > /etc/default/postfix
|
||||
|
||||
# Shared group bridging the unprivileged services: the panel generates
|
||||
# per-domain DKIM keys, application SASL accounts (sasldb2) and the Postfix
|
||||
# sender map, while OpenDKIM and Postfix (different users) must read them.
|
||||
# Membership in this group — plus setgid dirs under /data (set up in
|
||||
# entrypoint.sh) — lets OpenDKIM read the panel-owned keys and lets Postfix read
|
||||
# the sasldb2/sender map, and lets the panel reach the supervisor control socket
|
||||
# to signal OpenDKIM/Postfix reloads without any process running as root.
|
||||
RUN groupadd --system selfpost \
|
||||
# Unprivileged user for the panel process, with ids of its own outside the
|
||||
# system range for the same reason as selfpost's below: its files under
|
||||
# /data show on the host by number, and a number from that range is a
|
||||
# system group of the host's too (useradd gave the panel group 998 in the
|
||||
# trixie image, systemd-network on the test server's host; bookworm's
|
||||
# 999:999 was the host's systemd-journal group). The entrypoint gives /data
|
||||
# back to it by name on every start, so files of the old ids follow.
|
||||
RUN groupadd --system --gid 10999 panel && useradd --system --uid 10999 --gid panel --no-create-home --shell /usr/sbin/nologin panel
|
||||
|
||||
# Shared group bridging the unprivileged services: the panel writes the
|
||||
# application SASL accounts (sasldb2) and the Postfix sender map and opens the
|
||||
# milter sockets, while Postfix (another user) must read and reach them.
|
||||
# Membership in this group - plus setgid dirs under /data and /run/selfpost
|
||||
# (set up in entrypoint.sh) - lets Postfix read the sasldb2/sender map and
|
||||
# connect to the panel's milters. The DKIM keys are the panel's alone. (The
|
||||
# supervisor control socket has a group of its own, `supervisor`, below.)
|
||||
# The id is fixed, because the host sees it: an operator gives the TLS
|
||||
# private key on the host to this group by number, so that the panel can put
|
||||
# it into a full backup (guide § Taking a full backup). It lies outside the
|
||||
# range the distribution hands out to system groups, 100-999 (adduser 3.152,
|
||||
# /etc/adduser.conf: "Default: FIRST_SYSTEM_GID=100, LAST_SYSTEM_GID=999";
|
||||
# passwd 1:4.17.4-2, /etc/login.defs: SYS_GID_MIN 101, SYS_GID_MAX 999 - both
|
||||
# read in the image, 2026-10-15), so no package can take it, and far above
|
||||
# the 1000 a host starts its users' groups at, so a host is unlikely to have
|
||||
# a group of that number that would read the key too. Until 2.7.0 it was
|
||||
# 998, inside that range: in the trixie image systemd took 998 for
|
||||
# systemd-network and the build failed, and on the test server's host 998
|
||||
# is systemd-network too (seen 2026-10-15).
|
||||
# groupadd(8) refuses a --gid already in use, so an id taken after all would
|
||||
# fail this build rather than share the group.
|
||||
RUN groupadd --system --gid 10998 selfpost \
|
||||
&& usermod -aG selfpost panel \
|
||||
&& usermod -aG selfpost opendkim \
|
||||
&& usermod -aG selfpost postfix
|
||||
|
||||
# The panel reads the mail queue with `postqueue -p` (and the status page's
|
||||
# queue card). postqueue is setgid postdrop, which normally gives it the group
|
||||
# needed to reach Postfix's showq socket — but the documented deployment runs
|
||||
# needed to reach Postfix's showq socket - but the documented deployment runs
|
||||
# with `no-new-privileges`, which disables setgid transitions, so the panel
|
||||
# would always see "Permission denied". Making `panel` a real member of postdrop
|
||||
# grants the same access without relying on a setgid escalation the hardening
|
||||
# deliberately forbids. postdrop membership is read-side only: it does not let
|
||||
# deliberately forbids. The same membership is what lets the panel submit its
|
||||
# own notifications with sendmail(1) - postdrop, too, is setgid and would fail
|
||||
# to write the maildrop directory (internal/notify/mailer.go). It does not let
|
||||
# the panel bypass any Postfix restriction that a local user does not already
|
||||
# have through the world-executable sendmail.
|
||||
# (Postfix 3.10.13, conf/postfix-files:140-141: postdrop and postqueue are
|
||||
# root:$setgid_group 2755; Debian sets them root:postdrop 02555, postfix
|
||||
# 3.10.13-0+deb13u1, debian/postfix.postinst:134-141. Linux,
|
||||
# Documentation/userspace-api/no_new_privs.rst:
|
||||
# with no_new_privs, execve() no longer changes the gid for a setgid file.)
|
||||
RUN usermod -aG postdrop panel
|
||||
# The supervisord control socket's group (supervisord.conf, [unix_http_server]):
|
||||
# the panel alone - not Postfix's user, which shares `selfpost` for the files
|
||||
# under /data.
|
||||
RUN groupadd --system supervisor \
|
||||
&& usermod -aG supervisor panel
|
||||
|
||||
# Runtime directories: milter sockets and the consolidated persistent root.
|
||||
RUN mkdir -p /run/opendkim /run/selfpost /data \
|
||||
&& chown opendkim:opendkim /run/opendkim \
|
||||
RUN mkdir -p /run/selfpost /data \
|
||||
&& chown panel:panel /run/selfpost /data
|
||||
|
||||
COPY --from=build /out/panel /usr/local/bin/panel
|
||||
@@ -97,9 +160,9 @@ COPY --chmod=0644 internal/web/view/static/OFL.txt /usr/share/doc/selfpost/OFL.t
|
||||
# POSIX permissions (e.g. Windows tar sync) cannot land group-writable config
|
||||
# that logrotate would silently ignore — see docs/development.md § Building
|
||||
# binaries and the image.
|
||||
COPY --chmod=0644 build/opendkim.conf /etc/opendkim.conf
|
||||
COPY --chmod=0644 build/logrotate-mail.conf /etc/logrotate.d/mail
|
||||
COPY --chmod=0755 build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh
|
||||
COPY --chmod=0755 build/postfix-reload.sh /usr/local/bin/postfix-reload.sh
|
||||
COPY --chmod=0755 build/postfix-config.sh /usr/local/bin/postfix-config.sh
|
||||
COPY --chmod=0755 build/postfix-cert-reload.sh /usr/local/bin/postfix-cert-reload.sh
|
||||
COPY --chmod=0755 build/logrotate-loop.sh /usr/local/bin/logrotate-loop.sh
|
||||
@@ -112,7 +175,7 @@ COPY --chmod=0644 build/supervisord.conf /etc/supervisor/supervisord.conf
|
||||
# Outbound delivery dials remote MXs on 25 as a client even when inbound is off.
|
||||
EXPOSE 8080 465 587 25
|
||||
|
||||
# Liveness probe: panel HTTP plus mail-path processes (opendkim, panel, postfix).
|
||||
# Liveness probe: panel HTTP plus mail-path processes (panel, postfix).
|
||||
# Does not verify TLS, DNS, or end-to-end delivery — see docs/guide.md Operations.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
|
||||
CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1
|
||||
|
||||
+81
-13
@@ -1,17 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Supervisor event listener: bring the container down on unrecoverable failure.
|
||||
|
||||
If any managed program exhausts its restart retries and enters FATAL, this
|
||||
listener signals supervisord (its parent) to terminate, so the whole container
|
||||
exits and Docker's `restart` policy can recreate it cleanly — rather than
|
||||
lingering "alive" with a dead Postfix/OpenDKIM/panel.
|
||||
If a managed program of the mail path exhausts its restart retries and
|
||||
enters FATAL, or keeps crashing - CRASH_LIMIT unexpected exits within
|
||||
CRASH_WINDOW seconds - this listener signals supervisord (its parent) to
|
||||
terminate, so the whole container exits and Docker's `restart` policy starts it
|
||||
again, rather than lingering "alive" with a dead Postfix or panel.
|
||||
FATAL alone did not cover the second: a program reaches FATAL only through
|
||||
exits before its startsecs, and one that crashed after it was restarted for
|
||||
ever (audit 2026-10-12, L32; supervisor 4.2.5, supervisor/process.py,
|
||||
Subprocess.finish()). logrotate is not on the mail path: its FATAL (a config
|
||||
it would ignore) stays on Server > Health, and mail keeps flowing
|
||||
(assumptions C-V5).
|
||||
|
||||
Communication with supervisord uses the event listener protocol over stdin and
|
||||
stdout, so stdout must carry only protocol tokens.
|
||||
|
||||
supervisor 4.2.5, docs/events.rst, "Event Listener Notification Protocol": the
|
||||
listener writes READY\\n, reads one header line of key:value tokens whose len
|
||||
is the payload's length and eventname the event's type, reads the payload, and
|
||||
answers "RESULT 2\\nOK". A PROCESS_STATE_FATAL payload is a token set,
|
||||
"processname:cat groupname:cat from_state:BACKOFF"; a PROCESS_STATE_EXITED one
|
||||
adds "expected:0" for an exit code the program's exitcodes do not list, "1"
|
||||
otherwise (docs/events.rst; supervisor/events.py, ProcessStateEvent.payload()).
|
||||
supervisord starts the listener as its own child, and on SIGTERM it shuts down
|
||||
(docs/running.rst, "Signals"); it is PID 1 here (build/entrypoint.sh, exec).
|
||||
|
||||
CRASHEXIT_DRYRUN=1 writes "crashexit: would shut down" instead of signalling,
|
||||
for the test (build/crashexit_test.go).
|
||||
"""
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Programs whose failure does not take the container down
|
||||
# (build/supervisord.conf): logrotate is not on the mail path, postfix-reload
|
||||
# runs on demand - a reload it could not start is reported to whoever asked for
|
||||
# it - and cert-reload looks for a renewed certificate once a day (a crash of
|
||||
# it leaves the one Postfix serves).
|
||||
SPARED = {"logrotate", "postfix-reload", "cert-reload"}
|
||||
|
||||
# CRASH_LIMIT unexpected exits of one program within CRASH_WINDOW seconds are a
|
||||
# program that does not stay up. A program on the mail path restarts in
|
||||
# seconds; five times in ten minutes is no passing fault.
|
||||
CRASH_LIMIT = 5
|
||||
CRASH_WINDOW = 600
|
||||
|
||||
|
||||
def write_stdout(s):
|
||||
@@ -19,7 +53,20 @@ def write_stdout(s):
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def say(s):
|
||||
sys.stderr.write("crashexit: " + s + "\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
def shut_down():
|
||||
if os.environ.get("CRASHEXIT_DRYRUN") == "1":
|
||||
say("would shut down")
|
||||
return
|
||||
os.kill(os.getppid(), signal.SIGTERM)
|
||||
|
||||
|
||||
def main():
|
||||
crashes = {} # program -> times of its unexpected exits in the window
|
||||
while True:
|
||||
# Tell supervisord we are ready for the next event.
|
||||
write_stdout("READY\n")
|
||||
@@ -29,16 +76,37 @@ def main():
|
||||
return
|
||||
headers = dict(pair.split(":", 1) for pair in line.split())
|
||||
payload_len = int(headers.get("len", 0))
|
||||
if payload_len:
|
||||
sys.stdin.read(payload_len)
|
||||
|
||||
# We only subscribe to PROCESS_STATE_FATAL, so any event means a managed
|
||||
# program can no longer be restarted. Take the container down.
|
||||
sys.stderr.write(
|
||||
"crashexit: a managed process entered FATAL; shutting down container\n"
|
||||
payload = sys.stdin.read(payload_len) if payload_len else ""
|
||||
tokens = dict(
|
||||
pair.split(":", 1) for pair in payload.split() if ":" in pair
|
||||
)
|
||||
sys.stderr.flush()
|
||||
os.kill(os.getppid(), signal.SIGTERM)
|
||||
name = tokens.get("processname", "")
|
||||
event = headers.get("eventname", "PROCESS_STATE_FATAL")
|
||||
|
||||
if event == "PROCESS_STATE_EXITED":
|
||||
if name not in SPARED and tokens.get("expected") == "0":
|
||||
now = time.monotonic()
|
||||
times = [t for t in crashes.get(name, []) if now - t < CRASH_WINDOW]
|
||||
times.append(now)
|
||||
crashes[name] = times
|
||||
if len(times) >= CRASH_LIMIT:
|
||||
say(
|
||||
"%s exited unexpectedly %d times in %d seconds; shutting down container"
|
||||
% (name, len(times), CRASH_WINDOW)
|
||||
)
|
||||
shut_down()
|
||||
crashes[name] = []
|
||||
elif name in SPARED:
|
||||
say(
|
||||
"%s entered FATAL; it is not on the mail path, the container stays up"
|
||||
% name
|
||||
)
|
||||
else:
|
||||
say(
|
||||
"%s entered FATAL; shutting down container"
|
||||
% (name or "a managed process")
|
||||
)
|
||||
shut_down()
|
||||
|
||||
write_stdout("RESULT 2\nOK")
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// Package build holds the image's scripts; this file tests crashexit.py, the
|
||||
// supervisor event listener that takes the container down.
|
||||
package build
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// event is one event as supervisord writes it to a listener's stdin
|
||||
// (supervisor 4.2.5, docs/events.rst: a header line whose len is the
|
||||
// payload's length, then the payload).
|
||||
func event(name, payload string) string {
|
||||
return fmt.Sprintf("ver:3.0 server:supervisor serial:1 pool:crashexit poolserial:1 eventname:%s len:%d\n%s", name, len(payload), payload)
|
||||
}
|
||||
|
||||
// runListener feeds events to crashexit.py in dry run and returns what it
|
||||
// wrote to stderr.
|
||||
func runListener(t *testing.T, events ...string) string {
|
||||
t.Helper()
|
||||
// python3 on Linux; on Windows "python3" may be the Store's stub, which
|
||||
// runs nothing.
|
||||
names := []string{"python3", "python"}
|
||||
if runtime.GOOS == "windows" {
|
||||
names = []string{"python", "python3"}
|
||||
}
|
||||
python := ""
|
||||
for _, n := range names {
|
||||
if p, err := exec.LookPath(n); err == nil {
|
||||
python = p
|
||||
break
|
||||
}
|
||||
}
|
||||
if python == "" {
|
||||
t.Skip("no python")
|
||||
}
|
||||
cmd := exec.Command(python, "crashexit.py")
|
||||
cmd.Env = append(os.Environ(), "CRASHEXIT_DRYRUN=1")
|
||||
cmd.Stdin = strings.NewReader(strings.Join(events, ""))
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("crashexit.py: %v\n%s", err, stderr.String())
|
||||
}
|
||||
// Python on Windows writes its text stdout with \r\n.
|
||||
if n := strings.Count(strings.ReplaceAll(string(out), "\r\n", "\n"), "RESULT 2\nOK"); n != len(events) {
|
||||
t.Errorf("answered %d of %d events:\n%q", n, len(events), out)
|
||||
}
|
||||
return stderr.String()
|
||||
}
|
||||
|
||||
// A program on the mail path that keeps crashing after its startsecs - never
|
||||
// FATAL - takes the container down at the fifth unexpected exit (audit
|
||||
// 2026-10-12, L32); expected exits, spared programs and fewer crashes do not.
|
||||
func TestCrashexitCountsCrashes(t *testing.T) {
|
||||
exit := func(name, expected string) string {
|
||||
return event("PROCESS_STATE_EXITED", "processname:"+name+" groupname:"+name+" from_state:RUNNING expected:"+expected+" pid:7")
|
||||
}
|
||||
var four []string
|
||||
for i := 0; i < 4; i++ {
|
||||
four = append(four, exit("panel", "0"))
|
||||
}
|
||||
if got := runListener(t, four...); strings.Contains(got, "would shut down") {
|
||||
t.Errorf("four crashes shut down:\n%s", got)
|
||||
}
|
||||
if got := runListener(t, append(four, exit("panel", "0"))...); !strings.Contains(got, "panel exited unexpectedly 5 times") || !strings.Contains(got, "would shut down") {
|
||||
t.Errorf("five crashes did not shut down:\n%s", got)
|
||||
}
|
||||
var calm []string
|
||||
for i := 0; i < 6; i++ {
|
||||
calm = append(calm, exit("panel", "1"), exit("logrotate", "0"))
|
||||
}
|
||||
calm = append(calm, exit("postfix", "0"), exit("postfix", "0"), exit("postfix", "0"))
|
||||
if got := runListener(t, calm...); strings.Contains(got, "would shut down") {
|
||||
t.Errorf("expected exits, spared programs and three crashes of another shut down:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// FATAL of a mail-path program shuts down at once; of a spared one, not.
|
||||
func TestCrashexitFatal(t *testing.T) {
|
||||
fatal := func(name string) string {
|
||||
return event("PROCESS_STATE_FATAL", "processname:"+name+" groupname:"+name+" from_state:BACKOFF")
|
||||
}
|
||||
if got := runListener(t, fatal("postfix")); !strings.Contains(got, "postfix entered FATAL; shutting down") || !strings.Contains(got, "would shut down") {
|
||||
t.Errorf("postfix FATAL:\n%s", got)
|
||||
}
|
||||
if got := runListener(t, fatal("logrotate"), fatal("cert-reload")); strings.Contains(got, "would shut down") {
|
||||
t.Errorf("a spared program's FATAL shut down:\n%s", got)
|
||||
}
|
||||
}
|
||||
+140
-46
@@ -58,36 +58,78 @@ esac
|
||||
# — the one place still running as root — before handing off to supervisord,
|
||||
# which starts the panel as the panel user.
|
||||
#
|
||||
# Mode must stay world-traversable (0755): OpenDKIM and Postfix reach their
|
||||
# trees under /data as other users. Go's testing.TempDir is 0700, and a bare
|
||||
# chown would leave that mode in place — opendkim then cannot read KeyTable and
|
||||
# the container crash-loops (e2e TestHostnameGate/valid_hostname_starts).
|
||||
# Mode must stay world-traversable (0755): Postfix reaches its trees under
|
||||
# /data as another user. Go's testing.TempDir is 0700, and a bare chown would
|
||||
# leave that mode in place — OpenDKIM, which read its tables there until
|
||||
# 2.8.0, then could not, and the container crash-looped (e2e
|
||||
# TestHostnameGate/valid_hostname_starts).
|
||||
# (Go: testing.TempDir uses os.MkdirTemp, src/os/tempfile.go: "created with
|
||||
# mode 0o700".)
|
||||
chown panel:panel /data
|
||||
chmod 755 /data
|
||||
|
||||
# What follows runs as root over a tree the panel user writes: /data is the
|
||||
# panel's, and so is /data/postfix. A symbolic link the panel put where a
|
||||
# directory or a file of its own is expected would lead root's mkdir, ": >",
|
||||
# chmod and chgrp to whatever it names (chmod follows a link; so do a
|
||||
# redirect and mkdir -p; chown -R and find do not descend into one given as
|
||||
# their operand). None of these paths is ever a link in a working
|
||||
# installation, so a link there is removed and the path made anew below.
|
||||
# (Security review of base-trixie, 2026-10-15: no escalation found, as the
|
||||
# TLS key copy and the read-only mounts stand, but nothing for root to
|
||||
# follow either.)
|
||||
drop_links() {
|
||||
for p in "$@"; do
|
||||
if [ -L "$p" ]; then
|
||||
echo "entrypoint: removing the symbolic link $p" >&2
|
||||
rm -f "$p"
|
||||
fi
|
||||
done
|
||||
}
|
||||
drop_links /data/dkim /data/dkim.new /data/opendkim /data/sasl /data/postfix /data/antispam /data/log \
|
||||
/data/dkim/keys /data/dkim/selectors /data/opendkim/keys \
|
||||
/data/sasl/sasldb2 /data/postfix/queue \
|
||||
/data/postfix/sender_login_maps /data/postfix/relay_domains \
|
||||
/data/postfix/transport /data/postfix/relay_recipients /data/postfix/tls_policy \
|
||||
/data/log/mail.log
|
||||
# Restored backups or previously-created state may contain panel-owned files
|
||||
# under /data; make sure they stay writable without disturbing anything that a
|
||||
# later phase deliberately hands to another service. /data/log is exempt: it is
|
||||
# deliberately owned by postfix (postlogd writes the delivery log there) and is
|
||||
# normalised on its own below.
|
||||
find /data -mindepth 1 -maxdepth 1 ! -user panel ! -name log ! -name postfix -exec chown -R panel:panel {} +
|
||||
# normalised on its own below, as are the trees shared through group selfpost
|
||||
# and the one OpenDKIM read until 2.8.0, which is left as it was (below).
|
||||
# A file's owner is a number: an entry whose user or group is not the panel's
|
||||
# by that number - unpacked by root, or written under another image whose
|
||||
# image gave the panel other ids (999:999 until 2.7.0, 10999:10999 since) - is
|
||||
# handed over whole.
|
||||
find /data -mindepth 1 -maxdepth 1 ! -name log ! -name postfix ! -name opendkim ! -name sasl ! -name antispam \
|
||||
\( ! -user panel -o ! -group panel \) -exec chown -R panel:panel {} +
|
||||
|
||||
# DKIM key tree. The panel (user `panel`) generates keys and writes
|
||||
# the OpenDKIM tables; OpenDKIM (user `opendkim`) must read them. Normalise the
|
||||
# tree on every start so it is correct whether /data is fresh, restarted, or
|
||||
# just restored from a backup:
|
||||
# - group `selfpost` + setgid on directories so anything the panel creates
|
||||
# inherits the shared group OpenDKIM traverses;
|
||||
# - private keys and tables group-readable (0640);
|
||||
# - both table files present (empty is fine) BEFORE OpenDKIM starts, so the
|
||||
# daemon comes up cleanly with no domains yet.
|
||||
mkdir -p /data/opendkim/keys
|
||||
for t in /data/opendkim/KeyTable /data/opendkim/SigningTable; do
|
||||
[ -e "$t" ] || : > "$t"
|
||||
done
|
||||
chown -R panel:selfpost /data/opendkim
|
||||
find /data/opendkim -type d -exec chmod 2750 {} +
|
||||
chmod 0640 /data/opendkim/KeyTable /data/opendkim/SigningTable
|
||||
find /data/opendkim/keys -type f -name '*.private' -exec chmod 0640 {} +
|
||||
# DKIM keys and the selector table, /data/dkim: the panel's alone - it makes
|
||||
# the keys, writes the table and signs (internal/domain, Keys; internal/milter,
|
||||
# sign.go). Until 2.8.0 the keys were under /data/opendkim, shared with
|
||||
# OpenDKIM through group selfpost. The first start without /data/dkim copies
|
||||
# them over and leaves the old tree as it was, for an image of 2.7.x rolled
|
||||
# back to (docs/plans/panel-dkim-signing.md, Q2). The copy is made as the
|
||||
# panel user, which can read nothing there the panel could not, into a
|
||||
# directory beside, renamed into place when it is whole: a start that stops
|
||||
# midway copies again. A copy that fails stops the start (set -e) - the
|
||||
# panel would otherwise make the server's own name a new key, and its
|
||||
# published record would no longer match. The selector table is written by
|
||||
# the panel before its signer serves (cmd/panel/signer.go).
|
||||
# (util-linux 2.41, setpriv(1): --reuid, --regid, --clear-groups; cp(1)
|
||||
# -P: never follow a symbolic link in the source.)
|
||||
rm -rf /data/dkim.new
|
||||
if [ ! -e /data/dkim ] && [ -d /data/opendkim/keys ]; then
|
||||
setpriv --reuid=panel --regid=panel --clear-groups -- \
|
||||
sh -c 'mkdir /data/dkim.new && cp -RP /data/opendkim/keys /data/dkim.new/keys'
|
||||
mv /data/dkim.new /data/dkim
|
||||
echo "entrypoint: the DKIM keys were copied from /data/opendkim to /data/dkim; /data/opendkim is left for a rollback" >&2
|
||||
fi
|
||||
mkdir -p /data/dkim/keys
|
||||
chown -R panel:panel /data/dkim
|
||||
find /data/dkim -type d -exec chmod 0700 {} +
|
||||
find /data/dkim -type f -exec chmod 0600 {} +
|
||||
|
||||
# Application SASL accounts. The panel (user `panel`) writes the
|
||||
# sasldb2 via saslpasswd2; Postfix (user `postfix`) reads it to authenticate SMTP
|
||||
@@ -98,6 +140,10 @@ mkdir -p /data/sasl
|
||||
chown -R panel:selfpost /data/sasl
|
||||
chmod 2750 /data/sasl
|
||||
[ -e /data/sasl/sasldb2 ] && chmod 0640 /data/sasl/sasldb2
|
||||
# The copy a password change is made on (internal/app, replaceSASLDB), left
|
||||
# by a panel killed mid-change: a second copy of every password, which the
|
||||
# next change would remove anyway.
|
||||
rm -f /data/sasl/sasldb2.new
|
||||
|
||||
# Postfix state under /data (architecture.md § Persistence). The panel writes
|
||||
# sender_login_maps; Postfix owns the on-disk queue tree under queue/.
|
||||
@@ -107,43 +153,71 @@ mkdir -p /data/postfix/queue
|
||||
# Empty files keep postfix check happy if the flag is on before any domain exists.
|
||||
for f in relay_domains transport relay_recipients tls_policy; do
|
||||
[ -e "/data/postfix/$f" ] || : > "/data/postfix/$f"
|
||||
chown panel:selfpost "/data/postfix/$f"
|
||||
chmod 0640 "/data/postfix/$f"
|
||||
done
|
||||
# Every map here is the panel's to write and Postfix's to read - the sender
|
||||
# map, the inbound and the DMARC maps alike - so each file beside queue/ is
|
||||
# handed to the panel, group selfpost, whatever its name. A restored archive's
|
||||
# files arrive owned by whoever unpacked them (a backup records no owner, and
|
||||
# tar run as root makes them root's), and the first start after a restore
|
||||
# reads every one of them to compare it with the archive's record: one left
|
||||
# to root alone is a restore refused on every start (review of audit stage 3,
|
||||
# H1 - the DMARC maps were not among the names listed here).
|
||||
find /data/postfix -mindepth 1 -maxdepth 1 -type f -exec chown panel:selfpost {} + -exec chmod 0640 {} +
|
||||
chown panel:selfpost /data/postfix
|
||||
chmod 2750 /data/postfix
|
||||
chown panel:selfpost /data/postfix/sender_login_maps
|
||||
chmod 0640 /data/postfix/sender_login_maps
|
||||
|
||||
# Filter lists for the external spam filter (written by the panel when
|
||||
# INBOUND_RELAY_ENABLE=true, read by the antispam sidecar, which mounts this
|
||||
# directory). The sidecar runs under a user of its own in another container,
|
||||
# so the directory and its files are readable by anyone: they hold sender
|
||||
# addresses and networks, no secrets.
|
||||
mkdir -p /data/antispam
|
||||
chown -R panel:selfpost /data/antispam
|
||||
chmod 0755 /data/antispam
|
||||
|
||||
# Delivery log (architecture.md § Log tailer). postlogd writes it as user
|
||||
# `postfix`; the panel reads it for the log-tailer and the System log page. It
|
||||
# lives under /data — not the ephemeral /var/log — so the delivery lines that
|
||||
# resolve a "queued" send-log row survive a container recreate.
|
||||
#
|
||||
# postlogd creates a missing log itself, but at 0600, which the unprivileged
|
||||
# panel cannot read; so create it here (and re-normalise an existing one, plus
|
||||
# whatever logrotate left behind) at 0640 owned postfix:selfpost. The setgid
|
||||
# postlogd creates a missing log itself, by default at 0600, which the
|
||||
# unprivileged panel cannot read (postfix-config.sh now asks it for 0640,
|
||||
# maillog_file_permissions); so create it here (and re-normalise an existing
|
||||
# one, plus whatever logrotate left behind) at 0640 owned postfix:selfpost. The setgid
|
||||
# directory keeps the shared group on anything created inside it later, and
|
||||
# 2750 keeps it group-traversable but not group-writable — logrotate refuses to
|
||||
# rotate a log whose directory is writable by a non-root group.
|
||||
# Postfix 3.10.13: src/util/logwriter.c:99, logwriter_open_or_die(), creates the
|
||||
# file with logwriter_perms, 0600 (:86) unless maillog_file_permissions says
|
||||
# otherwise (proto/postconf.proto:18500, default 0600; Postfix 3.9 and later).
|
||||
# logrotate 3.22.0, logrotate.c:1406-1408, findNeedRotating(): "skipping ...
|
||||
# because parent directory has insecure permissions" when the directory's
|
||||
# group is not root and has write permission.
|
||||
mkdir -p /data/log
|
||||
[ -e /data/log/mail.log ] || : > /data/log/mail.log
|
||||
chown -R postfix:selfpost /data/log
|
||||
chmod 2750 /data/log
|
||||
find /data/log -type f -exec chmod 0640 {} +
|
||||
|
||||
# Milter socket directories. Postfix (user `postfix`) must actually CONNECT to
|
||||
# both milter sockets — OpenDKIM's and the panel's journal-milter — not just
|
||||
# probe them at start-up. The sockets are created by the opendkim and panel
|
||||
# users respectively, so bridge them to `postfix` through the shared `selfpost`
|
||||
# group: group-owned + setgid dirs mean each socket created inside inherits
|
||||
# group `selfpost`, and group-traversable (2750) lets postfix reach it. Without
|
||||
# this, smtpd cannot talk to OpenDKIM and, because signing is strict
|
||||
# (default_action=tempfail), rejects all mail.
|
||||
mkdir -p /run/opendkim /run/selfpost
|
||||
chown opendkim:selfpost /run/opendkim
|
||||
# The milter socket directory. Postfix (user `postfix`) must actually CONNECT
|
||||
# to the panel's milter sockets, not just probe them at start-up. The panel
|
||||
# user creates them, so they are bridged to `postfix` through the shared
|
||||
# `selfpost` group: a group-owned + setgid dir means each socket created
|
||||
# inside inherits group `selfpost`, and group-traversable (2750) lets postfix
|
||||
# reach it. Without this, smtpd cannot talk to the signer and, because
|
||||
# signing is strict (default_action=tempfail), defers all mail.
|
||||
mkdir -p /run/selfpost
|
||||
chown panel:selfpost /run/selfpost
|
||||
chmod 2750 /run/opendkim /run/selfpost
|
||||
chmod 2750 /run/selfpost
|
||||
# A container that was killed leaves its sockets behind, and a file nobody
|
||||
# listens on would satisfy postfix-wrapper.sh's wait: Postfix would start
|
||||
# before the sender check does and defer mail until it is there. The check's
|
||||
# alive mark goes too: it tells the downtime of the panel restarting inside a
|
||||
# running container, and a restart of the whole container is not the check's
|
||||
# downtime (guide § Health) - a mark left from before would count the whole
|
||||
# outage when supervisord cannot say since when Postfix runs.
|
||||
# (test(1), -S: true for any socket file, whether or not anything listens.)
|
||||
rm -f /run/selfpost/*.sock /run/selfpost/*.alive
|
||||
|
||||
# Generate the outbound-relay Postfix configuration from the environment (spec
|
||||
# 5). Kept out of the image build so cert paths, rate limits, hostname and the
|
||||
@@ -151,11 +225,12 @@ chmod 2750 /run/opendkim /run/selfpost
|
||||
# start the same way the /data normalisation above is.
|
||||
/usr/local/bin/postfix-config.sh
|
||||
|
||||
# Initialise the persistent queue tree on first start or after restore. postfix
|
||||
# set-permissions reads queue_directory from main.cf (set by postfix-config.sh).
|
||||
if [ ! -d /data/postfix/queue/active ]; then
|
||||
postfix set-permissions
|
||||
fi
|
||||
# The queue tree, on a first start or after a restore, is already built here:
|
||||
# postfix-config.sh ends with "postfix check", whose check-fatal runs
|
||||
# "post-install create-missing" (Postfix 3.10.13, conf/postfix-script:314;
|
||||
# conf/postfix-files:55, "$queue_directory/active:d:$mail_owner:-:700:ucr").
|
||||
# A "postfix set-permissions" for a missing active/ stood here and never ran
|
||||
# (assumptions C-P34).
|
||||
chown -R postfix:postfix /data/postfix/queue
|
||||
# The recursive chown above would leave public/ and maildrop/ in group postfix.
|
||||
# Postfix keeps those two in its setgid group: postqueue and postdrop are setgid
|
||||
@@ -167,6 +242,25 @@ chgrp postdrop /data/postfix/queue/public /data/postfix/queue/maildrop
|
||||
# It would also leave the queue directory itself and pid/ owned by postfix.
|
||||
# Postfix keeps those two owned by root (master, which runs as root, is the only
|
||||
# writer) and logs "warning: not owned by root" for each at every start.
|
||||
# (Postfix 3.10.13, conf/postfix-files:51-68: $queue_directory root 755, maildrop
|
||||
# $setgid_group 730, public $setgid_group 710, pid root 755, the other queue
|
||||
# directories $mail_owner 700; conf/postfix-script:354-356, "check-warn": "not
|
||||
# owned by root".)
|
||||
chown root /data/postfix/queue /data/postfix/queue/pid
|
||||
|
||||
# [program:postfix]'s startsecs (build/supervisord.conf): longer than the
|
||||
# wrapper's wait for the milter sockets, so its exit at the end of the wait is
|
||||
# one supervisord counts as too quick. A value that is not a number stops the
|
||||
# start here.
|
||||
wait_timeout="${MILTER_WAIT_TIMEOUT:-30}"
|
||||
case "$wait_timeout" in
|
||||
'' | *[!0-9]*)
|
||||
echo "entrypoint: MILTER_WAIT_TIMEOUT must be a whole number of seconds, got '$wait_timeout'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
# expr, not $((...)): dash's arithmetic reads "010" as octal and refuses
|
||||
# "08"; expr takes decimal (review of stage 2c, F11).
|
||||
export POSTFIX_STARTSECS=$(expr "$wait_timeout" + 10)
|
||||
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
|
||||
|
||||
+44
-10
@@ -7,18 +7,49 @@
|
||||
# before the reload is lost. `create` (rather than `nocreate`) matters here
|
||||
# beyond timing: a postlogd-triggered recreate lands the file at 0600 owned by
|
||||
# postfix, which the unprivileged panel process cannot read — confirmed on a
|
||||
# live container — so logrotate must be the one to create it.
|
||||
# live container (date not recorded, Postfix 3.7.11), and so in the source:
|
||||
# Postfix 3.10.13, src/util/logwriter.c:99, logwriter_open_or_die(), creates it
|
||||
# with logwriter_perms, 0600 (:86) unless maillog_file_permissions says
|
||||
# otherwise (Postfix 3.9 and later). build/postfix-config.sh now sets that to
|
||||
# 0640, so a file postlogd makes is readable too; logrotate still creates it,
|
||||
# so that the new log is there, with its mode and owner, at once.
|
||||
# The reload: Postfix 3.10.13, conf/postfix-script:535-566, "logrotate" (mv,
|
||||
# then kill -HUP master); master_conf.c:92, master_config() -> :147,
|
||||
# master_restart_service() -> master_status_cleanup(), on which postlogd exits
|
||||
# (src/master/dgram_server.c:241, dgram_server_abort(), :640 on
|
||||
# MASTER_STATUS_FD), and the next postlogd opens the path anew
|
||||
# (src/postlogd/postlogd.c:236, logwriter_open_or_die()).
|
||||
#
|
||||
# logrotate itself only rotates once the configured "daily" period has elapsed
|
||||
# (tracked in /var/lib/logrotate/status), so it is safe to invoke this more
|
||||
# often than daily — polling merely bounds how late a legitimate rotation runs.
|
||||
# (tracked in its state file), so it is safe to invoke this more often than
|
||||
# daily — polling merely bounds how late a legitimate rotation runs. The state
|
||||
# file is beside the log, under /data, not at the default
|
||||
# /var/lib/logrotate/status (logrotate 3.22.0-1: debian/rules:11,
|
||||
# --with-state-file-path) in the container's own filesystem: a recreated
|
||||
# container started without it, logrotate.c:456, newState(), records a log it
|
||||
# has no state for as rotated today, and so a host that recreates the container
|
||||
# daily - an .env change, an image bump - never rotated mail.log at all (audit
|
||||
# 2026-10-12, M19). (logrotate 3.22.0, logrotate.8.in:74: "-s, --state
|
||||
# statefile".) The directory is postfix:selfpost 2750 and logrotate runs as
|
||||
# root.
|
||||
set -eu
|
||||
|
||||
CONFIG=/etc/logrotate.d/mail
|
||||
STATE=/data/log/.logrotate.status
|
||||
INTERVAL="${LOGROTATE_INTERVAL_SECONDS:-21600}"
|
||||
|
||||
# logrotate refuses configs writable by group or others and exits 0 while
|
||||
# ignoring them — fail here so supervisord reports the fault.
|
||||
# logrotate refuses configs writable by group or others and goes on without
|
||||
# them — fail here so supervisord reports the fault. (logrotate 3.22.0,
|
||||
# config.c:1099, readConfigFile(): as root, mode & 0022 gives (:1160-1165)
|
||||
# "Ignoring ... because it is writable by group or others." and returns 1; a
|
||||
# config not owned by root is ignored the same way (:1168-1173). Since 3.22.0
|
||||
# that makes logrotate's exit status 1 (logrotate.c:3278-3279 and :3301;
|
||||
# ChangeLog.md, 3.22.0: "in case a configuration file was skipped due to
|
||||
# unsafe permissions the exit status after rotattion will be `1` (#508)");
|
||||
# 3.21.0 returned 0 there and exited 0. So the output is looked at whatever
|
||||
# the status. "Potentially dangerous mode" is only a warning there
|
||||
# (:1154-1157), for any mode & 07533 other than 0400 - this loop chooses to
|
||||
# treat it as fatal too.)
|
||||
logrotate_config_ok() {
|
||||
mode=$(stat -c '%a' "$CONFIG")
|
||||
mode=${mode#0}
|
||||
@@ -39,16 +70,19 @@ if ! logrotate_config_ok; then
|
||||
fi
|
||||
|
||||
run_logrotate() {
|
||||
out=$(logrotate "$CONFIG" 2>&1) || {
|
||||
echo "$out" >&2
|
||||
return 1
|
||||
}
|
||||
status=0
|
||||
out=$(logrotate -s "$STATE" "$CONFIG" 2>&1) || status=$?
|
||||
case "$out" in
|
||||
*Ignoring*|*Potentially\ dangerous\ mode*)
|
||||
echo "$out" >&2
|
||||
logrotate_config_fatal
|
||||
echo "logrotate-loop: refusing to run: logrotate did not take $CONFIG as it is (above)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [ "$status" -ne 0 ]; then
|
||||
echo "$out" >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
# build/postfix-config.sh (maillog_file) and build/entrypoint.sh: postlogd
|
||||
# writes it as user postfix, the unprivileged panel reads it through the shared
|
||||
# selfpost group, hence create 0640 postfix selfpost rather than 0644 root root.
|
||||
# (logrotate 3.22.0, logrotate.8:402: "create mode owner group" makes the new log
|
||||
# right after the rotation; "delaycompress" leaves the previous log, .1,
|
||||
# uncompressed until the next cycle.)
|
||||
/data/log/mail.log {
|
||||
daily
|
||||
rotate 14
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# OpenDKIM — per-domain signing.
|
||||
#
|
||||
# Signing is strictly per-domain: each sending domain has its own key pair and
|
||||
# selector. The panel generates keys under /data/opendkim/keys/<domain>/ and
|
||||
# keeps KeyTable/SigningTable in sync as domains are added or removed, then asks
|
||||
# supervisord to send this process SIGUSR1 to reload the tables.
|
||||
#
|
||||
# Both table files are created (empty) by entrypoint.sh before OpenDKIM starts,
|
||||
# so the daemon comes up cleanly on a fresh /data with no domains yet and simply
|
||||
# signs nothing until the first domain is added.
|
||||
|
||||
Syslog no
|
||||
UMask 007
|
||||
Mode s
|
||||
|
||||
# SigningTable uses refile: so the "*@example.com" left-hand patterns match any
|
||||
# local-part for a domain; KeyTable maps each domain to its key and selector.
|
||||
KeyTable /data/opendkim/KeyTable
|
||||
SigningTable refile:/data/opendkim/SigningTable
|
||||
|
||||
# The private keys are owned by `panel` and read by `opendkim` through the shared
|
||||
# `selfpost` group, so they are deliberately group-readable (mode 0640). That is
|
||||
# safe on this single-tenant, private bind mount, but it trips OpenDKIM's default
|
||||
# key-safety check, so the check is disabled here by design.
|
||||
RequireSafeKeys no
|
||||
|
||||
Socket local:/run/opendkim/opendkim.sock
|
||||
PidFile /run/opendkim/opendkim.pid
|
||||
UserID opendkim
|
||||
Background no
|
||||
@@ -1,25 +1,94 @@
|
||||
#!/bin/sh
|
||||
# Periodic `postfix reload` so refreshed TLS certificates are picked up (spec
|
||||
# 5.2 p.4). The reverse-proxy renews the PEM files in the read-only mount every
|
||||
# few months; Postfix only re-reads them on reload. A simple daily reload is
|
||||
# more than enough (a day of staleness is harmless) and far simpler than an
|
||||
# inotify watcher — the spec explicitly prefers this.
|
||||
# Picks up a renewed TLS certificate while the container runs (spec 5.2 p.4;
|
||||
# audit 2026-10-12, H2).
|
||||
#
|
||||
# Runs under supervisord as root, so it can reload Postfix directly. It sleeps
|
||||
# first, then reloads in a loop: no reload at container start (the wrapper is
|
||||
# still bringing Postfix up then) and none until at least one interval has
|
||||
# passed. A reload is harmless when nothing changed.
|
||||
set -eu
|
||||
# Postfix does not read the mounted files: postfix-config.sh copies them at
|
||||
# start into /etc/postfix/tls-internal, because Postfix wants the key owned by
|
||||
# root with mode 0600, which a read-only bind mount owned by the host's user
|
||||
# cannot give. A renewal of the mounted files therefore reaches Postfix only
|
||||
# through a new copy. Every interval this compares the mounted files with the
|
||||
# copy; when they differ it copies both beside their targets, checks that the
|
||||
# copies - not the mounted files, which a renewer may be rewriting - are a
|
||||
# certificate and the key that belongs to it, renames both over the targets
|
||||
# and reloads Postfix. When nothing changed it does nothing.
|
||||
# (Postfix 3.10.13: smtpd loads the certificate in pre_jail_init(), once per
|
||||
# process, src/smtpd/smtpd.c:6442; "postfix reload" is SIGHUP to master,
|
||||
# conf/postfix-script:277, after which the old smtpd processes exit on the closed
|
||||
# status pipe, src/master/master_conf.c, master_config() ->
|
||||
# master_restart_service(), and new ones load the files again.)
|
||||
#
|
||||
# Runs under supervisord as root. It sleeps first: at container start the
|
||||
# wrapper is still bringing Postfix up. It never exits non-zero, so a failure
|
||||
# does not trip the crashexit listener; it is logged and tried again.
|
||||
set -u
|
||||
umask 077 # the key's copy is never readable by others, not even for an instant
|
||||
|
||||
INTERVAL="${TLS_RELOAD_INTERVAL_SECONDS:-86400}"
|
||||
SRC_CERT="${TLS_CERT_FILE:-/etc/postfix/tls/fullchain.pem}"
|
||||
SRC_KEY="${TLS_KEY_FILE:-/etc/postfix/tls/privkey.pem}"
|
||||
DIR=/etc/postfix/tls-internal
|
||||
CERT="$DIR/fullchain.pem"
|
||||
KEY="$DIR/privkey.pem"
|
||||
PENDING=0 # a copy was renewed and Postfix has not been reloaded since
|
||||
|
||||
reload() {
|
||||
if postfix reload; then
|
||||
echo "cert-reload: postfix reloaded ($1)"
|
||||
PENDING=0
|
||||
else
|
||||
echo "cert-reload: postfix reload failed, will try again after ${INTERVAL}s" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# same_pair says whether a certificate file and a private key file belong
|
||||
# together, by comparing the public key each one prints. openssl-x509(1) and
|
||||
# openssl-pkey(1) do not say the two print it byte for byte alike; measured
|
||||
# in the image, 2026-10-15, OpenSSL 3.0.22: identical for RSA-2048, P-256 and
|
||||
# P-384 keys, and for a P-256 key written in SEC1 form ("EC PRIVATE KEY") -
|
||||
# certbot 2.0 and later issue ECDSA keys by default (assumptions C-X1).
|
||||
same_pair() {
|
||||
cert_pub=$(openssl x509 -noout -pubkey -in "$1" 2>/dev/null) || return 1
|
||||
key_pub=$(openssl pkey -pubout -in "$2" 2>/dev/null) || return 1
|
||||
[ -n "$cert_pub" ] && [ "$cert_pub" = "$key_pub" ]
|
||||
}
|
||||
|
||||
refresh() {
|
||||
if [ "$PENDING" = 1 ]; then
|
||||
reload "renewed certificate, earlier reload had failed"
|
||||
fi
|
||||
if [ "$(postconf -h smtpd_tls_cert_file 2>/dev/null)" != "$CERT" ]; then
|
||||
# Postfix reads the mounted files themselves (they were missing at
|
||||
# start, so no copy was made): a reload is what picks up a change.
|
||||
reload "periodic TLS refresh"
|
||||
return
|
||||
fi
|
||||
if [ ! -r "$SRC_CERT" ] || [ ! -r "$SRC_KEY" ]; then
|
||||
echo "cert-reload: cannot read ${SRC_CERT} or ${SRC_KEY}; Postfix keeps the certificate it has" >&2
|
||||
return
|
||||
fi
|
||||
if cmp -s "$SRC_CERT" "$CERT" && cmp -s "$SRC_KEY" "$KEY"; then
|
||||
return
|
||||
fi
|
||||
rm -f "$CERT.new" "$KEY.new"
|
||||
if ! cp "$SRC_CERT" "$CERT.new" || ! cp "$SRC_KEY" "$KEY.new"; then
|
||||
rm -f "$CERT.new" "$KEY.new"
|
||||
echo "cert-reload: copying the renewed certificate failed, will try again after ${INTERVAL}s" >&2
|
||||
return
|
||||
fi
|
||||
if ! same_pair "$CERT.new" "$KEY.new"; then
|
||||
rm -f "$CERT.new" "$KEY.new"
|
||||
echo "cert-reload: the mounted certificate and key differ from Postfix's copy but do not belong together; will look again after ${INTERVAL}s" >&2
|
||||
return
|
||||
fi
|
||||
chown root:root "$CERT.new" "$KEY.new"
|
||||
chmod 0644 "$CERT.new"
|
||||
chmod 0600 "$KEY.new"
|
||||
mv -f "$KEY.new" "$KEY" && mv -f "$CERT.new" "$CERT"
|
||||
PENDING=1
|
||||
reload "renewed certificate"
|
||||
}
|
||||
|
||||
while true; do
|
||||
sleep "${INTERVAL}"
|
||||
if postfix reload; then
|
||||
echo "cert-reload: postfix reloaded (periodic TLS refresh)"
|
||||
else
|
||||
# Never exit non-zero: a transient reload failure must not trip the
|
||||
# crashexit listener and take the container down. Log and retry next cycle.
|
||||
echo "cert-reload: postfix reload failed, will retry after ${INTERVAL}s" >&2
|
||||
fi
|
||||
refresh
|
||||
done
|
||||
|
||||
+426
-37
@@ -23,6 +23,20 @@ set -eu
|
||||
# splits a restriction list on commas as well as on whitespace, so every value
|
||||
# passed here is written with commas only; a value with whitespace is a bug in
|
||||
# this script and stops the container.
|
||||
#
|
||||
# Postfix 3.10.13, man/man5/master.5:215-226, "-o name=value (short form)":
|
||||
# "The "short form" does not support whitespace around the "=" or in
|
||||
# parameter values" (the long form "-o { name = value }" would keep it; this
|
||||
# script does not write it). src/postconf/postconf_edit.c:415,
|
||||
# pcf_edit_master(): "-Pe does not accept whitespace in parameter value".
|
||||
# src/smtpd/smtpd_check.c:664, smtpd_check_parse(), and src/milter/milter.c:646,
|
||||
# milter_new(), split their lists on CHARS_COMMA_SP: commas or whitespace.
|
||||
#
|
||||
# master.5:41-42, SYNTAX: a line that starts with whitespace continues a
|
||||
# logical line, so the "-o" line awk inserts below the header belongs to the
|
||||
# service. A later postconf -F/-P re-parses a matched entry with its
|
||||
# continuation lines (postconf_edit.c:134, pcf_gobble_cf_line()) and prints
|
||||
# it whole again, keeping these options.
|
||||
master_cf_set_smtp_inet_o() {
|
||||
param="$1"
|
||||
value="$2"
|
||||
@@ -62,16 +76,27 @@ master_cf_set_smtp_inet_o() {
|
||||
# sasldb2 accounts are looked up under. The panel creates accounts under realm
|
||||
# $SELFPOST_HOSTNAME (SASL_REALM), so myhostname MUST match or authentication
|
||||
# fails. Fall back to the container hostname only outside a real deployment.
|
||||
# Postfix 3.10.13, src/xsasl/xsasl_cyrus_server.c:339, xsasl_cyrus_server_create():
|
||||
# sasl_server_new() gets var_myhostname as serverFQDN; Cyrus SASL 2.1.28,
|
||||
# plugins/sasldb.c, sasldb_auxprop_lookup(): with no user realm the account is
|
||||
# looked up under serverFQDN.
|
||||
HOSTNAME_VALUE="${SELFPOST_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}"
|
||||
|
||||
# TLS material supplied by the reverse-proxy through a read-only bind mount. The
|
||||
# relay requires TLS on 465; if these files are absent the master still starts
|
||||
# but TLS handshakes on 465 fail until they appear.
|
||||
# but TLS handshakes on 465 fail until they appear. Postfix 3.10.13,
|
||||
# src/smtpd/smtpd.c: pre_jail_init() only warns when the TLS context cannot be
|
||||
# made, and smtpd_proto() then drops each wrapper-mode connection (:5846,
|
||||
# "Wrapper-mode request dropped ... TLS context initialization failed"); every
|
||||
# new smtpd process loads the files again in pre_jail_init().
|
||||
#
|
||||
# Postfix insists the private key is root-owned and mode 0600. The bind mount
|
||||
# is often :ro and owned by the host user (e2e TempDir / CI runner UID), so
|
||||
# copy into a writable internal dir and normalise ownership before postconf
|
||||
# and `postfix check`.
|
||||
# Postfix's TLS_README advises a private key owned by root and readable by no
|
||||
# other user; Postfix does not enforce it (smtpd loads the key as root in
|
||||
# pre_jail_init(), Postfix 3.10.13 src/smtpd/smtpd.c), but the mount cannot
|
||||
# give that mode - it is :ro and owned by the host's user, often readable by
|
||||
# group 10998 for the panel's backup. So the pair is copied into a directory of
|
||||
# Postfix's own, the key root:root 0600, before postconf and `postfix check`
|
||||
# (assumptions D-arch:154).
|
||||
TLS_CERT_SRC="${TLS_CERT_FILE:-/etc/postfix/tls/fullchain.pem}"
|
||||
TLS_KEY_SRC="${TLS_KEY_FILE:-/etc/postfix/tls/privkey.pem}"
|
||||
TLS_INTERNAL_DIR=/etc/postfix/tls-internal
|
||||
@@ -93,13 +118,82 @@ fi
|
||||
RATE_MSGS="${RATE_LIMIT_MESSAGES_PER_IP:-100}"
|
||||
RATE_WINDOW="${RATE_LIMIT_WINDOW_SECONDS:-3600}"
|
||||
|
||||
# Milter sockets: OpenDKIM (signing, strict) and the panel journal-milter
|
||||
# (monitoring, fail-open). Fixed container paths, matched by postfix-wrapper.sh.
|
||||
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}"
|
||||
# The settings below reach main.cf and master.cf as numbers or time values.
|
||||
# A value that is neither, or one under the parameter's minimum, passes
|
||||
# `postfix check` and kills the daemon that reads it - every smtpd at its
|
||||
# first connection, or anvil and with it the rate limit (audit 2026-10-12,
|
||||
# L31; review of stage 5) - so the start stops here, saying which and why.
|
||||
# Counts and sizes are whole numbers from min up to 2147483647, an int's top
|
||||
# (Postfix 3.10.13 reads the counts with get_mail_conf_int*, src/global/
|
||||
# mail_conf_int.c, check_mail_conf_int(): an int, held to the parameter's own minimum;
|
||||
# message_size_limit is a long, src/global/mail_params.h:2014, and is held to
|
||||
# the int's top here all the same). A time value
|
||||
# is a whole number from 1 with an optional unit, s m h d or w, at most
|
||||
# 2147483647 seconds
|
||||
# (proto/postconf.proto:266-268: "Specify a non-zero time value (an integral value
|
||||
# plus an optional one-letter suffix ...). Time units: s (seconds), m
|
||||
# (minutes), h (hours), d (days), w (weeks)"; one unit - "1h30m" is read as
|
||||
# 1h, src/global/conv_time.c - so it is refused here).
|
||||
need_number() {
|
||||
case "$2" in
|
||||
'' | *[!0-9]*)
|
||||
echo "postfix-config: $1 must be a whole number, got '$2'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [ "${#2}" -gt 10 ] || [ "$2" -lt "$3" ] || [ "$2" -gt 2147483647 ]; then
|
||||
echo "postfix-config: $1 must be from $3 to 2147483647, got '$2'" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
need_time() {
|
||||
if ! printf '%s' "$2" | grep -Eq '^[1-9][0-9]{0,8}[smhdw]?$'; then
|
||||
echo "postfix-config: $1 must be a time value - a whole number from 1 with an optional s, m, h, d or w - got '$2'" >&2
|
||||
exit 1
|
||||
fi
|
||||
# conv_time multiplies by the unit and refuses what passes an int.
|
||||
n=${2%[smhdw]}
|
||||
case "$2" in
|
||||
*w) n=$((n * 604800)) ;;
|
||||
*d) n=$((n * 86400)) ;;
|
||||
*h) n=$((n * 3600)) ;;
|
||||
*m) n=$((n * 60)) ;;
|
||||
esac
|
||||
if [ "$n" -gt 2147483647 ]; then
|
||||
echo "postfix-config: $1 is more than 2147483647 seconds, got '$2'" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
# A rate of 0 is no limit, a size of 0 none (proto/postconf.proto,
|
||||
# smtpd_client_message_rate_limit, message_size_limit); the window is
|
||||
# anvil_rate_time_unit, at least 1 second (src/anvil/anvil.c, main(): its
|
||||
# time table's minimum 1).
|
||||
need_number RATE_LIMIT_MESSAGES_PER_IP "$RATE_MSGS" 0
|
||||
need_number RATE_LIMIT_WINDOW_SECONDS "$RATE_WINDOW" 1
|
||||
need_number INBOUND_RATE_LIMIT_MESSAGES_PER_IP "${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}" 0
|
||||
need_number INBOUND_MESSAGE_SIZE_LIMIT "${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}" 0
|
||||
need_number DMARC_RATE_LIMIT_MESSAGES_PER_IP "${DMARC_RATE_LIMIT_MESSAGES_PER_IP:-20}" 0
|
||||
need_number DMARC_MESSAGE_SIZE_LIMIT "${DMARC_MESSAGE_SIZE_LIMIT:-5242880}" 0
|
||||
need_time MILTER_CONNECT_TIMEOUT "${MILTER_CONNECT_TIMEOUT:-15s}"
|
||||
need_time MILTER_COMMAND_TIMEOUT "${MILTER_COMMAND_TIMEOUT:-15s}"
|
||||
need_time MILTER_CONTENT_TIMEOUT "${MILTER_CONTENT_TIMEOUT:-30s}"
|
||||
|
||||
# Milter sockets, all the panel's: its journal-milter (monitoring,
|
||||
# fail-open), the sender check and the DKIM signer (both strict). Fixed
|
||||
# container paths, matched by postfix-wrapper.sh.
|
||||
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
|
||||
# The panel's sender check (the From: line against the login, strict).
|
||||
SENDER_SOCK="${SENDER_CHECK_SOCKET:-/run/selfpost/sender.sock}"
|
||||
# The panel's DKIM signer (strict): applications' mail, signed with the key
|
||||
# of the login's domain, and the panel's own, with the server's name's
|
||||
# (cmd/panel/signer.go). Matched by postfix-wrapper.sh.
|
||||
SIGN_SOCK="${DKIM_SIGN_SOCKET:-/run/selfpost/sign.sock}"
|
||||
SIGN_LOCAL_SOCK="${DKIM_SIGN_LOCAL_SOCKET:-/run/selfpost/sign-local.sock}"
|
||||
|
||||
# Persistent panel-managed sender map; texthash needs no postmap, so the
|
||||
# unprivileged panel can rewrite it and just ask for a reload.
|
||||
# unprivileged panel can rewrite it and just ask for a reload. Postfix 3.10.13,
|
||||
# html/DATABASE_README.html:458-464, "texthash": no postmap(1), and the file is read
|
||||
# once by each process - a reload makes the new processes read it again.
|
||||
SENDER_LOGIN_MAPS="${POSTFIX_SENDER_LOGIN_MAPS:-/data/postfix/sender_login_maps}"
|
||||
# Transit mail queue under /data so deferred/active messages survive container
|
||||
# recreate (architecture.md § Persistence). Distinct from sender_login_maps.
|
||||
@@ -117,6 +211,9 @@ SUBMISSION_ENABLE="${SUBMISSION_ENABLE:-false}"
|
||||
INBOUND_RELAY_ENABLE="${INBOUND_RELAY_ENABLE:-false}"
|
||||
INBOUND_ANTISPAM_MILTER="${INBOUND_ANTISPAM_MILTER:-}"
|
||||
INBOUND_ANTISPAM_MILTER_ACTION="${INBOUND_ANTISPAM_MILTER_ACTION:-accept}"
|
||||
# The panel's inbound journal milter (monitoring, fail-open), on port 25 only
|
||||
# and only while the inbound relay is on. Matched by postfix-wrapper.sh.
|
||||
INBOUND_JOURNAL_SOCK="${INBOUND_JOURNAL_MILTER_SOCKET:-/run/selfpost/inbound-journal.sock}"
|
||||
INBOUND_RATE_LIMIT_MESSAGES_PER_IP="${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}"
|
||||
INBOUND_MESSAGE_SIZE_LIMIT="${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}"
|
||||
RELAY_DOMAINS_MAP="${POSTFIX_RELAY_DOMAINS:-/data/postfix/relay_domains}"
|
||||
@@ -141,30 +238,63 @@ DMARC_RELAY_DOMAINS_MAP="${POSTFIX_DMARC_RELAY_DOMAINS:-/data/postfix/dmarc_rela
|
||||
# directory and the file with the ownership postlogd writes and the panel reads.
|
||||
MAIL_LOG_PATH="${MAIL_LOG:-/data/log/mail.log}"
|
||||
|
||||
# /etc/postfix outlives a restart of the container, so a cleanup limit set for
|
||||
# port 25 before is taken back here and set again below when it still applies.
|
||||
postconf -PX "cleanup/unix/message_size_limit" 2>/dev/null || true
|
||||
|
||||
# --- main.cf -----------------------------------------------------------------
|
||||
# maillog_file lives under the persistent /data bind mount (architecture.md §
|
||||
# Log tailer). Postfix's default maillog_file_prefixes are only /var and
|
||||
# /dev/stdout — without /data, `postfix check` rejects the path.
|
||||
# inet_protocols=all in a bridge network without IPv6: Postfix 3.10.13,
|
||||
# src/util/inet_proto.c:200, inet_proto_init(), keeps IPv6 when socket(PF_INET6)
|
||||
# succeeds, and src/smtp/smtp_connect.c:1102-1142, smtp_connect_inet(), goes on
|
||||
# to the next address when a connection fails - an AAAA that cannot be reached
|
||||
# costs one failed attempt, then IPv4. Postfix
|
||||
# 3.10.13, src/global/mail_params.h:4406, DEF_MAILLOG_FILE_PFXS "/var, /dev/stdout";
|
||||
# src/global/maillog_client.c:232, maillog_client_init(): fatal "does not match any
|
||||
# prefix", in every Postfix program that logs, postfix(1) itself included.
|
||||
#
|
||||
# maillog_file_permissions=0640: the log is the panel's to read, through
|
||||
# group selfpost (the setgid /data/log, build/entrypoint.sh). entrypoint.sh
|
||||
# and logrotate's "create" make it 0640 themselves; this is for a file that
|
||||
# Postfix makes - one removed while the container runs - which would
|
||||
# otherwise be 0600 and unreadable to the panel. (Postfix 3.10.13,
|
||||
# src/global/mail_params.h:4418, DEF_MAILLOG_FILE_PERMS "0600";
|
||||
# src/global/mail_params.c:997, set_logwriter_create_perms(); src/util/
|
||||
# logwriter.c:143-148: "644", "640" or "600", leading zeros ignored, anything
|
||||
# else a warning and 0600; :99, logwriter_open_or_die() creates with it;
|
||||
# Postfix 3.9 and later, proto/postconf.proto:18500-18509.)
|
||||
postconf -e \
|
||||
"myhostname=${HOSTNAME_VALUE}" \
|
||||
"maillog_file=${MAIL_LOG_PATH}" \
|
||||
"maillog_file_prefixes=/var,/dev/stdout,/data" \
|
||||
"maillog_file_permissions=0640" \
|
||||
"mydestination=" \
|
||||
"relayhost=" \
|
||||
"inet_interfaces=all" \
|
||||
"inet_protocols=all"
|
||||
|
||||
# postlogd is mandatory whenever maillog_file is set (MAILLOG_README). Debian's
|
||||
# postlogd is mandatory whenever maillog_file is set (Postfix 3.10.13,
|
||||
# html/MAILLOG_README.html:42, "Configuring logging to file"; conf/postfix-script:333,
|
||||
# "check-fatal": "missing 'postlog' service in master.cf"). Debian's
|
||||
# stock master.cf usually has it; pin it explicitly so a stripped/upgraded
|
||||
# image cannot lose the service.
|
||||
postconf -M "postlog/unix-dgram=postlog unix-dgram n - n - 1 postlogd"
|
||||
|
||||
# This is an outbound relay: no local delivery, no per-user aliases. Empty
|
||||
# these so a misfiled recipient never gets delivered locally.
|
||||
# these so a misfiled recipient never gets delivered locally. An empty
|
||||
# mydestination is not enough: an address literal of an interface the
|
||||
# container listens on - x@[127.0.0.1] - is local too (Postfix 3.10.13,
|
||||
# src/global/resolve_local.c, resolve_local(), own_inet_addr()), so local(8)
|
||||
# itself is replaced by a refusal, as Postfix's null-client example does
|
||||
# (README_FILES/STANDARD_CONFIGURATION_README:209, "local_transport = error:local
|
||||
# mail delivery is disabled"; assumptions C-P8).
|
||||
postconf -e \
|
||||
"local_recipient_maps=" \
|
||||
"alias_maps=" \
|
||||
"alias_database="
|
||||
"alias_database=" \
|
||||
"local_transport=error:local mail delivery is disabled"
|
||||
|
||||
# Outbound delivery: straight to the recipient MX, opportunistic TLS.
|
||||
postconf -e \
|
||||
@@ -172,7 +302,10 @@ postconf -e \
|
||||
"smtp_tls_note_starttls_offer=yes"
|
||||
|
||||
# TLS server material shared by every inbound service. auth_only guarantees
|
||||
# credentials are never accepted before TLS is up on any port.
|
||||
# credentials are never accepted before TLS is up on any port. Postfix 3.10.13,
|
||||
# src/smtpd/smtpd_sasl_proto.c:188, smtpd_sasl_auth_cmd(): "504 5.5.4 Encryption
|
||||
# required" while auth_only is set and TLS is not up; src/smtpd/smtpd.c:6546-6547,
|
||||
# pre_jail_init(): wrappermode implies enforce, and enforce implies auth_only.
|
||||
postconf -e \
|
||||
"smtpd_tls_cert_file=${TLS_CERT}" \
|
||||
"smtpd_tls_key_file=${TLS_KEY}" \
|
||||
@@ -180,11 +313,30 @@ postconf -e \
|
||||
"smtpd_tls_auth_only=yes" \
|
||||
"smtpd_tls_loglevel=1"
|
||||
|
||||
# TLS 1.2 at least, opportunistic or required - and the client's too where an
|
||||
# inbound domain requires TLS to its upstream (smtp_tls_mandatory_protocols,
|
||||
# the security review of stage 5) (audit 2026-10-12, L31; Postfix
|
||||
# 3.10.13, proto/postconf.proto:13240-13250, smtpd_tls_protocols: since 3.6 the
|
||||
# default is ">=TLSv1", and a lower bound is written ">=version"). A sender that
|
||||
# offers less on port 25 sends in the clear, as one without TLS does. VRFY
|
||||
# answers no question about who is accepted on port 25 (relay_recipient_maps),
|
||||
# and the banner names no distribution (Debian's adds "(Debian)": postfix
|
||||
# 3.10.13-0+deb13u1, debian/main.cf.in:15 "(@DEB_VENDOR@)", filled in by
|
||||
# debian/rules:118; the upstream default is "$myhostname ESMTP $mail_name").
|
||||
postconf -e \
|
||||
"smtpd_tls_protocols=>=TLSv1.2" \
|
||||
"smtpd_tls_mandatory_protocols=>=TLSv1.2" \
|
||||
"smtp_tls_mandatory_protocols=>=TLSv1.2" \
|
||||
"disable_vrfy_command=yes" \
|
||||
'smtpd_banner=$myhostname ESMTP'
|
||||
|
||||
# SASL: Cyrus with the local sasldb2 the panel maintains. The realm is left
|
||||
# implicit (smtpd_sasl_local_domain empty) so the authenticated name Postfix
|
||||
# uses for sender_login_maps is the BARE login the panel writes into the map;
|
||||
# the sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname as
|
||||
# the server realm, which equals the realm the accounts were created under.
|
||||
# (Postfix 3.10.13, src/smtpd/smtpd_sasl_glue.c:249, smtpd_sasl_activate(): an empty
|
||||
# smtpd_sasl_local_domain is a NULL user realm; see HOSTNAME_VALUE above.)
|
||||
postconf -e \
|
||||
"smtpd_sasl_auth_enable=yes" \
|
||||
"smtpd_sasl_type=cyrus" \
|
||||
@@ -194,17 +346,30 @@ postconf -e \
|
||||
"smtpd_sasl_tls_security_options=noanonymous" \
|
||||
"broken_sasl_auth_clients=yes"
|
||||
|
||||
# Sender binding (the critical anti-spoofing control). texthash resolves the
|
||||
# full address first, then the "@domain" wildcard, so both address modes work
|
||||
# from the same map.
|
||||
# Sender binding (the critical anti-spoofing control). smtpd looks the sender
|
||||
# up in this map with mail_addr_find(), the order being smtpd's and not
|
||||
# texthash's: the full address, the address without its extension, then the
|
||||
# "@domain" wildcard - so both address modes work from the same map (Postfix
|
||||
# 3.10.13, src/smtpd/smtpd_check.c:3933, reject_auth_sender_login_mismatch();
|
||||
# src/global/mail_addr_find.c, mail_addr_find_opt() with MA_FIND_DEFAULT).
|
||||
#
|
||||
# recipient_delimiter is said here and not left to the distribution's main.cf:
|
||||
# it is what lets an application listed as invoices@ send as invoices+march@,
|
||||
# and the panel's sender check makes the same second try for the From: line
|
||||
# (extensionDelimiter in internal/milter/sender.go) - the two must agree.
|
||||
# (mail_addr_find_opt() strips the extension at var_rcpt_delim,
|
||||
# strip_addr_internal(), for its second try.)
|
||||
postconf -e \
|
||||
"smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}" \
|
||||
"recipient_delimiter=+" \
|
||||
"queue_directory=${QUEUE_DIR}"
|
||||
|
||||
# Restrictions: authenticated clients only, no relay to foreign destinations,
|
||||
# and every authenticated sender address must be owned by its login. NO
|
||||
# permit_mynetworks anywhere — authorisation is by credentials, never by
|
||||
# network. This is what makes an open relay impossible.
|
||||
# network. This is what makes an open relay impossible. (Postfix 3.10.13,
|
||||
# src/smtpd/smtpd_check.c:5076, smtpd_check_rcpt(), :5160-5173: the relay and
|
||||
# recipient lists are evaluated in turn and the first REJECT ends them.)
|
||||
postconf -e \
|
||||
"smtpd_helo_required=yes" \
|
||||
"smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
|
||||
@@ -212,28 +377,86 @@ postconf -e \
|
||||
"smtpd_sender_restrictions=reject_sender_login_mismatch, permit"
|
||||
|
||||
# Level-1 rate limit by client IP. Backstop that keeps working even if the
|
||||
# journal-milter (level 2) is down.
|
||||
# journal-milter (level 2) is down. Postfix 3.10.13, src/smtpd/smtpd.c:2566,
|
||||
# mail_cmd(), :2624: "450 4.7.1 Error: too much mail from", except for clients
|
||||
# in smtpd_client_event_limit_exceptions (:2619, hogger_list; default
|
||||
# $mynetworks, src/smtpd/smtpd.c:779).
|
||||
# anvil_rate_time_unit is one global window for every anvil limit (postconf(5)).
|
||||
# "Client IP" is the address the container sees: an IPv4 client's own, but
|
||||
# for a client over IPv6 the compose network's gateway, through Docker's
|
||||
# proxy - measured on a live server, 2026-10-15, Docker 29.6.1 (assumptions
|
||||
# C-K5; guide, "Rate limiting - level 1").
|
||||
#
|
||||
# mynetworks is said here, loopback only, and not left to the package: it is
|
||||
# what the exceptions above and smtpd_forbid_bare_newline_exclusions (below)
|
||||
# spare. The value is the one Debian's debconf default gives (postfix
|
||||
# 3.10.13-0+deb13u1, debian/templates:124-126, "postfix/mynetworks", written
|
||||
# by debian/postfix.postinst:249-262 on a new main.cf, where :180,
|
||||
# fset_all_changed, marks every debconf value to be written; main.cf.in:28 has 127.0.0.0/8
|
||||
# alone), so on this image it changes nothing.
|
||||
postconf -e \
|
||||
"mynetworks=127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128" \
|
||||
"smtpd_client_message_rate_limit=${RATE_MSGS}" \
|
||||
"anvil_rate_time_unit=${RATE_WINDOW}s"
|
||||
|
||||
# Milter chain. OpenDKIM signs and is treated strictly (default_action=tempfail:
|
||||
# if it is unreachable, defer rather than send unsigned). The journal-milter is
|
||||
# monitoring only and is fail-open (default_action=accept): its failure must
|
||||
# never block the relay. Per-milter settings use Postfix 3.0+ brace syntax.
|
||||
# Milter chain. The sender check comes first and is strict
|
||||
# (default_action=tempfail): it holds the From: line against the login, the
|
||||
# address a receiver holds the signature to, so a message it could not judge
|
||||
# must not be signed. While the panel does not answer on that socket, mail is
|
||||
# deferred (docs/security.md).
|
||||
#
|
||||
# The panel's DKIM signer follows, strict too: unreachable, it defers rather
|
||||
# than send unsigned (docs/plans/panel-dkim-signing.md). It signs with the
|
||||
# key of the login's domain. The journal-milter is monitoring only and is
|
||||
# fail-open (default_action=accept): its failure must never block the relay.
|
||||
# Per-milter settings use Postfix 3.0+ brace syntax.
|
||||
#
|
||||
# non_smtpd_milters is the chain for mail submitted locally with sendmail(1) -
|
||||
# the panel's own notifications, sent as postmaster@ the hostname. The
|
||||
# signer's own socket for it signs them with the key of the server's own
|
||||
# name; the journal-milter is not on this chain, since they are not an
|
||||
# application's mail. Bounces Postfix
|
||||
# generates itself do not pass it (internal_mail_filter_classes is empty).
|
||||
#
|
||||
# Postfix 3.10.13: html/MILTER_README.html:584, "Different settings for different
|
||||
# Milter applications" (the braces, Postfix 3.0 and later) and :449, "Signing
|
||||
# internally-generated bounce messages"; src/milter/milter.c:399-564,
|
||||
# milter_*_event() and milter_message(): milters are asked in list order and
|
||||
# the first non-empty reply ends the round; src/global/mail_params.h:3599,
|
||||
# DEF_INT_FILT_CLASSES INT_FILT_CLASS_NONE.
|
||||
postconf -e \
|
||||
"milter_protocol=6" \
|
||||
"milter_default_action=tempfail" \
|
||||
"smtpd_milters={ unix:${OPENDKIM_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \
|
||||
"non_smtpd_milters="
|
||||
"smtpd_milters={ unix:${SENDER_SOCK}, default_action=tempfail }, { unix:${SIGN_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \
|
||||
"non_smtpd_milters={ unix:${SIGN_LOCAL_SOCK}, default_action=tempfail }"
|
||||
|
||||
# A signed message is delivered as it was signed: Postfix's SMTP client would
|
||||
# otherwise rewrite an 8-bit message as quoted-printable for a server that
|
||||
# does not announce 8BITMIME, and every body hash over it would fail.
|
||||
# Postfix 3.10.13, src/global/mail_params.h:3088, DEF_DISABLE_MIME_OCONV 0
|
||||
# (the conversion is on by default); proto/postconf.proto:7992,
|
||||
# disable_mime_output_conversion: "Disable the conversion of 8BITMIME format
|
||||
# to 7BIT format". Not set until 2.8.0 - OpenDKIM's signatures, before the
|
||||
# panel's, were exposed to it as well.
|
||||
postconf -e "disable_mime_output_conversion=yes"
|
||||
|
||||
# What Postfix tells a milter with MAIL FROM: its defaults plus the client's
|
||||
# address. The panel's milters judge every message by that address (an
|
||||
# application's client-address list) and record it (the inbound journal), and
|
||||
# the address given once at connect is not there for a connection's later
|
||||
# messages - see MailFrom in internal/milter/milter.go. The defaults: Postfix
|
||||
# 3.10.13, src/global/mail_params.h:3532-3534, DEF_MILT_MAIL_MACROS.
|
||||
postconf -e \
|
||||
"milter_mail_macros=i {auth_type} {auth_authen} {auth_author} {mail_addr} {mail_host} {mail_mailer} {client_addr}"
|
||||
|
||||
# Bounded milter timeouts: a *hung* milter (socket accepts but never replies)
|
||||
# must fail open just like a crash, not stall mail acceptance until the Postfix
|
||||
# defaults (300s content) elapse. With default_action per milter, a
|
||||
# journal-milter hang then resolves to accept and an OpenDKIM hang to tempfail,
|
||||
# journal-milter hang then resolves to accept and a signer hang to tempfail,
|
||||
# but within seconds rather than minutes. Values are well above any healthy
|
||||
# response time (signing/DB insert are sub-second), so they never fire in normal
|
||||
# operation.
|
||||
# operation. The defaults: Postfix 3.10.13, src/global/mail_params.h:3559-3567,
|
||||
# DEF_MILT_CONN_TIME "30s", DEF_MILT_CMD_TIME "30s", DEF_MILT_MSG_TIME "300s".
|
||||
postconf -e \
|
||||
"milter_connect_timeout=${MILTER_CONNECT_TIMEOUT:-15s}" \
|
||||
"milter_command_timeout=${MILTER_COMMAND_TIMEOUT:-15s}" \
|
||||
@@ -243,8 +466,26 @@ postconf -e \
|
||||
# smtps (465, implicit/wrapper TLS) — the primary, always-on submission
|
||||
# service. chroot=n so smtpd can read the sasldb2 and sender map under /data
|
||||
# and the Cyrus config outside any chroot.
|
||||
# The names smtps, submission and smtp need no /etc/services: Postfix 3.10.13,
|
||||
# src/global/mail_params.h:4450-4451, DEF_KNOWN_TCP_PORTS "lmtp=24, smtp=25,
|
||||
# smtps=submissions=465, submission=587" (src/util/find_inet.c:94-99,
|
||||
# find_inet_port() via filter_known_tcp_port()).
|
||||
#
|
||||
# smtpd_hide_client_session=yes on both submission services (owner's
|
||||
# decision, 2026-10-15): the Received: header Postfix adds names neither the
|
||||
# application's HELO name, reverse name and address, nor its TLS session,
|
||||
# SASL login or the protocol - only "Received: by <myhostname> (Postfix) id
|
||||
# <queue id>", "for <rcpt>" when there is one recipient, and the date
|
||||
# (Postfix 3.10.13, src/smtpd/smtpd.c:3495, the client part written only
|
||||
# when the parameter is off; :3627-3645, the "by" line opening the header
|
||||
# when it is on). proto/postconf.proto:19580-19594: "may be used for the
|
||||
# port 587 and 465 MUA services"; port 25 must keep the default, RFC 5321's
|
||||
# trace, and does (smtp/inet below sets nothing). The queue id stays in the
|
||||
# header, so a complaint about a message still leads to its row in the
|
||||
# panel's log and to smtpd's "client=" line in mail.log.
|
||||
postconf -M "smtps/inet=smtps inet n - n - - smtpd"
|
||||
postconf -P \
|
||||
"smtps/inet/smtpd_hide_client_session=yes" \
|
||||
"smtps/inet/smtpd_tls_wrappermode=yes" \
|
||||
"smtps/inet/smtpd_sasl_auth_enable=yes" \
|
||||
"smtps/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"
|
||||
@@ -252,10 +493,15 @@ postconf -P \
|
||||
# submission (587, STARTTLS) — optional. Same SASL/milter/limits; the only
|
||||
# difference is TLS is negotiated via STARTTLS, so require encryption before
|
||||
# auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a restart
|
||||
# after disabling it does not leave the port listening.
|
||||
# after disabling it does not leave the port listening. (Postfix 3.10.13,
|
||||
# src/smtpd/smtpd.c:6525-6526, pre_jail_init(): smtpd_tls_security_level=encrypt
|
||||
# sets enforce_tls, which implies smtpd_tls_auth_only (:6547);
|
||||
# src/postconf/postconf_edit.c:319, pcf_edit_master(): -MX drops the matched
|
||||
# entry, continuation lines included.)
|
||||
if [ "${SUBMISSION_ENABLE}" = "true" ]; then
|
||||
postconf -M "submission/inet=submission inet n - n - - smtpd"
|
||||
postconf -P \
|
||||
"submission/inet/smtpd_hide_client_session=yes" \
|
||||
"submission/inet/smtpd_tls_security_level=encrypt" \
|
||||
"submission/inet/smtpd_sasl_auth_enable=yes" \
|
||||
"submission/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"
|
||||
@@ -264,9 +510,11 @@ else
|
||||
fi
|
||||
|
||||
# --- inbound smtpd on port 25 (optional inbound relay and/or DMARC ingest) -----
|
||||
# Debian's stock master.cf enables smtp/inet. When both flags are off, remove
|
||||
# Debian's stock master.cf enables smtp/inet (postfix 3.10.13-0+deb13u1,
|
||||
# debian/patches/40_chroot_by_default.diff:16: "smtp inet n - y - - smtpd",
|
||||
# uncommented; conf/master.cf:12). When both flags are off, remove
|
||||
# that listener so port 25 is not an inbound smtpd (outbound delivery uses
|
||||
# smtp/unix). When either is on: no SASL, no OpenDKIM; relay accepts only
|
||||
# smtp/unix). When either is on: no SASL, no signing; relay accepts only
|
||||
# configured domains/recipients; DMARC accepts only allow-listed report addresses.
|
||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||
RELAY_DOMAINS_SETTING=""
|
||||
@@ -289,13 +537,35 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
|
||||
# postconf -P refuses them ("unmatched service_name/type") and leaves a
|
||||
# bare "pipe", which dies with "fatal: missing user= command-line
|
||||
# attribute" on every report. They go into the service line itself.
|
||||
# (Seen in 1.7.0 through 1.9.5, date not recorded, on Postfix 3.7.11.
|
||||
# Postfix 3.10.13: src/postconf/postconf_master.c:1029,
|
||||
# pcf_edit_master_param(), -P writes "-o name=value" and nothing else;
|
||||
# postconf_edit.c:617 warns "unmatched service_name/type" for a service
|
||||
# master.cf does not have; src/pipe/pipe.c:867, get_service_attr(),
|
||||
# :1018: "missing user= command-line attribute".)
|
||||
# ${recipient} is expanded by pipe(8), not by the shell: the command
|
||||
# learns which report address the message came to from its argument.
|
||||
postconf -M 'dmarc-ingest/unix=dmarc-ingest unix - n n - - pipe flags=Rq user=panel argv=/usr/local/bin/dmarc-ingest ${recipient}'
|
||||
# learns which report address the message came to from its argument
|
||||
# (man/man8/pipe.8:289, ${recipient}: as many arguments as recipients).
|
||||
# One at a time (the 1 before "pipe"): reports are in no hurry, every run
|
||||
# writes to the database the panel writes to, and a hundred of them in
|
||||
# parallel - Postfix's default - is what a flood of reports would start.
|
||||
postconf -M 'dmarc-ingest/unix=dmarc-ingest unix - n n - 1 pipe flags=Rq user=panel argv=/usr/local/bin/dmarc-ingest ${recipient}'
|
||||
# One recipient per run, so ${recipient} is one argument.
|
||||
postconf -e "dmarc-ingest_destination_recipient_limit=1"
|
||||
# pipe(8) starts the command with export_environment only, so what the
|
||||
# command reads from the container's environment is named there.
|
||||
# export_environment is a list split at whitespace in which Postfix
|
||||
# expands "$": a path with either would reach the command as another
|
||||
# path, and it would quietly open a database of its own. (Postfix 3.10.13,
|
||||
# man/man8/pipe.8:417, export_environment; src/global/mail_params.h:2650,
|
||||
# DEF_EXPORT_ENVIRON "TZ MAIL_CONFIG LANG"; src/util/clean_env.c. The
|
||||
# parameter is main.cf's, so every Postfix process exports these too.)
|
||||
case "${SELFPOST_DATA_DIR:-}${SELFPOST_DB_PATH:-}" in
|
||||
*[[:space:]]* | *'$'*)
|
||||
echo "postfix-config: SELFPOST_DATA_DIR and SELFPOST_DB_PATH must not contain whitespace or \$ when DMARC_REPORTS_ENABLE=true" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
DMARC_INGEST_ENV="DMARC_REPORTS_ENABLE=true"
|
||||
[ -z "${SELFPOST_DATA_DIR:-}" ] || DMARC_INGEST_ENV="${DMARC_INGEST_ENV} SELFPOST_DATA_DIR=${SELFPOST_DATA_DIR}"
|
||||
[ -z "${SELFPOST_DB_PATH:-}" ] || DMARC_INGEST_ENV="${DMARC_INGEST_ENV} SELFPOST_DB_PATH=${SELFPOST_DB_PATH}"
|
||||
@@ -308,7 +578,8 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
|
||||
TRANSPORT_SETTING="texthash:${DMARC_TRANSPORT_MAP}"
|
||||
fi
|
||||
# Comma, not a space, between check_recipient_access and its table: see
|
||||
# master_cf_set_smtp_inet_o.
|
||||
# master_cf_set_smtp_inet_o (master.5, "-o name=value (short form)": "use
|
||||
# commas instead of spaces if the parameter supports that").
|
||||
RECIPIENT_RESTRICTIONS="check_recipient_access,texthash:${DMARC_RECIPIENTS_MAP},${RECIPIENT_RESTRICTIONS}"
|
||||
else
|
||||
postconf -MX "dmarc-ingest/unix" 2>/dev/null || true
|
||||
@@ -326,10 +597,18 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
|
||||
# relay domain too (the server's own name), so their table is a recipient
|
||||
# table as well; without it they are "User unknown in relay recipient
|
||||
# table" and no report is ever accepted while both features are on.
|
||||
# (Postfix 3.10.13, src/smtpd/smtpd_check.c, smtpd_check_rcpt(),
|
||||
# :5196-5200: after the lists, check_recipient_rcpt_maps() runs unless
|
||||
# the status is REJECT - an OK from check_recipient_access does not
|
||||
# skip it.)
|
||||
RELAY_RECIPIENT_SETTING="texthash:${RELAY_RECIPIENTS_MAP}"
|
||||
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||
RELAY_RECIPIENT_SETTING="${RELAY_RECIPIENT_SETTING} texthash:${DMARC_RECIPIENTS_MAP}"
|
||||
fi
|
||||
# The panel writes the same "[host]:port" into transport and
|
||||
# tls_policy: the policy lookup key is the verbatim next-hop, port and
|
||||
# brackets included (Postfix 3.10.13, README_FILES/TLS_README:914, "TLS
|
||||
# policy table" note: ":smtp", ":25" and no port are different keys).
|
||||
postconf -e \
|
||||
"relay_recipient_maps=${RELAY_RECIPIENT_SETTING}" \
|
||||
"smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}"
|
||||
@@ -339,16 +618,27 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
|
||||
"smtp_tls_policy_maps="
|
||||
fi
|
||||
|
||||
# Milters on port 25. main.cf's chain (OpenDKIM strict + journal-milter) is
|
||||
# for the SUBMISSION path only: OpenDKIM must not gate someone else's
|
||||
# Milters on port 25. main.cf's chain (sender check and signer strict +
|
||||
# journal-milter) is for the SUBMISSION path only: the signer must not gate someone else's
|
||||
# inbound mail (a signing outage would defer it), and the journal-milter
|
||||
# would file inbound mail into the outbound send log under the *sender's*
|
||||
# domain — polluting Deliveries and letting a forged From: consume that
|
||||
# domain's level-2 rate-limit budget. So smtpd_milters is always overridden
|
||||
# here: either the optional antispam milter, or nothing at all.
|
||||
# here: the optional antispam milter, then - with the inbound relay on - the
|
||||
# panel's inbound journal milter, or nothing at all. (Postfix 3.10.13,
|
||||
# man/man5/master.5: "-o" overrides the main.cf parameter for this service;
|
||||
# src/smtpd/smtpd.c:3475 hands its own milter list to cleanup, milter_send(),
|
||||
# and src/cleanup/cleanup_milter.c:1997, cleanup_milter_receive(), uses it.)
|
||||
INBOUND_MILTERS=""
|
||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ -n "${INBOUND_ANTISPAM_MILTER}" ]; then
|
||||
# A glob's "*" matches anything, so the shape alone would pass
|
||||
# "inet:a:1},{unix:/elsewhere": characters outside the set are refused
|
||||
# first, then the shape is checked.
|
||||
case "${INBOUND_ANTISPAM_MILTER}" in
|
||||
*[!A-Za-z0-9._:/-]*)
|
||||
echo "FATAL: INBOUND_ANTISPAM_MILTER must be inet:host:port or unix:/path, got: ${INBOUND_ANTISPAM_MILTER}" >&2
|
||||
exit 1
|
||||
;;
|
||||
inet:[A-Za-z0-9._-]*:[0-9]* | unix:/[A-Za-z0-9._/-]* ) ;;
|
||||
*)
|
||||
echo "FATAL: INBOUND_ANTISPAM_MILTER must be inet:host:port or unix:/path, got: ${INBOUND_ANTISPAM_MILTER}" >&2
|
||||
@@ -364,6 +654,32 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
|
||||
esac
|
||||
INBOUND_MILTERS="{${INBOUND_ANTISPAM_MILTER},default_action=${INBOUND_ANTISPAM_MILTER_ACTION}}"
|
||||
fi
|
||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
|
||||
# Last in the chain: Postfix hands a message to its milters one after
|
||||
# another at end-of-message, so this one reads the verdict headers the
|
||||
# filter added and sees only what the filter let through. It watches and
|
||||
# never refuses, and must never hold up someone else's mail: accept when
|
||||
# it is down. A comma, not a space, between the two - see
|
||||
# master_cf_set_smtp_inet_o.
|
||||
# Postfix 3.10.13: src/milter/milter.c:564, milter_message(), sends the
|
||||
# message to each milter in list order; src/milter/milter8.c:960,
|
||||
# milter8_event(), applies a header a milter adds (SMFIR_ADDHEADER,
|
||||
# :1424) to the queue file at once, and the next milter8_message()
|
||||
# (:2544) reads that file with rec_get() (:2612), which follows the
|
||||
# added records.
|
||||
case "${INBOUND_JOURNAL_SOCK}" in
|
||||
*[!A-Za-z0-9._/-]*)
|
||||
echo "FATAL: INBOUND_JOURNAL_MILTER_SOCKET must be an absolute path, got: ${INBOUND_JOURNAL_SOCK}" >&2
|
||||
exit 1
|
||||
;;
|
||||
/[A-Za-z0-9._/-]*) ;;
|
||||
*)
|
||||
echo "FATAL: INBOUND_JOURNAL_MILTER_SOCKET must be an absolute path, got: ${INBOUND_JOURNAL_SOCK}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
INBOUND_MILTERS="${INBOUND_MILTERS:+${INBOUND_MILTERS},}{unix:${INBOUND_JOURNAL_SOCK},default_action=accept}"
|
||||
fi
|
||||
|
||||
PORT25_RATE="${INBOUND_RATE_LIMIT_MESSAGES_PER_IP}"
|
||||
PORT25_SIZE="${INBOUND_MESSAGE_SIZE_LIMIT}"
|
||||
@@ -380,8 +696,34 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
|
||||
fi
|
||||
fi
|
||||
|
||||
# syslog_name: this listener's lines read postfix/inbound/smtpd, the
|
||||
# submission ports' postfix/smtpd. The log tailer files a refusal on port 25
|
||||
# into the inbound journal and has to tell the two apart. (Postfix 3.10.13,
|
||||
# src/global/mail_task.c:56, mail_task(): the log tag is syslog_name "/"
|
||||
# program name. Only smtpd carries it: cleanup and the rest are other
|
||||
# services and log as postfix/.)
|
||||
postconf -M "smtp/inet=smtp inet n - n - - smtpd"
|
||||
# smtpd_forbid_bare_newline: the 2023 SMTP-smuggling defence. normalize
|
||||
# takes a bare <LF> as a line end but ends DATA only at the standard
|
||||
# <CR><LF>.<CR><LF>: a "." line that a bare <LF> precedes or ends is
|
||||
# dropped, not taken as the end of the message (Postfix 3.10.13,
|
||||
# src/smtpd/smtpd.c:1693-1694, bare_lf_mask_table, normalize =
|
||||
# BARE_LF_FLAG_WANT_STD_EOD; :3712-3716, the DATA loop; RELEASE_NOTES-3.9:
|
||||
# 166-167, "the recommended setting "smtpd_forbid_bare_newline
|
||||
# = normalize""). Since 3.9 normalize is also the default (src/smtpd/
|
||||
# smtpd.c:830, "Postfix >= 3.9: normalize"; src/global/mail_params.h:4313,
|
||||
# DEF_SMTPD_FORBID_BARE_LF "normalize"); the line stays so that port 25
|
||||
# does not depend on a default (it came in 2.6.1, while the image's
|
||||
# Postfix 3.7.11 defaulted to "no").
|
||||
# Its exclusions default to $mynetworks (mail_params.h:4316), loopback
|
||||
# only here (above), so no client is spared. The submission ports take
|
||||
# the default, normalize as well since this Postfix: an application whose
|
||||
# SMTP library ends DATA with a bare <LF>.<LF> is no longer read to its
|
||||
# end there (own-mta-builds, § 5: their smuggled transaction would still
|
||||
# be the authenticated session's, so they were left to the default).
|
||||
postconf -P \
|
||||
"smtp/inet/smtpd_forbid_bare_newline=normalize" \
|
||||
"smtp/inet/syslog_name=postfix/inbound" \
|
||||
"smtp/inet/smtpd_sasl_auth_enable=no" \
|
||||
"smtp/inet/smtpd_tls_auth_only=no" \
|
||||
"smtp/inet/smtpd_sender_login_maps=" \
|
||||
@@ -392,6 +734,18 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
|
||||
"smtp/inet/message_size_limit=${PORT25_SIZE}"
|
||||
master_cf_set_smtp_inet_o smtpd_recipient_restrictions "${RECIPIENT_RESTRICTIONS}"
|
||||
master_cf_set_smtp_inet_o smtpd_milters "${INBOUND_MILTERS}"
|
||||
|
||||
# smtpd's -o limit is not the whole of it: cleanup writes the queue file
|
||||
# under its own message_size_limit, main.cf's, which is never set here and
|
||||
# so is 10240000 (Postfix 3.10.13, src/cleanup/cleanup_init.c:444,
|
||||
# cleanup_post_jail(), :455, set_file_limit(); src/global/mail_params.h:2013,
|
||||
# DEF_MESSAGE_LIMIT), and a larger message is refused 552 after DATA
|
||||
# (assumptions C-P31). cleanup gets port 25's limit when it is larger;
|
||||
# smtpd on 465 and 587 still holds its own, main.cf's (src/smtpd/smtpd.c:3721,
|
||||
# :3964, :4136, :4250, the DATA and BDAT size checks).
|
||||
if [ "${PORT25_SIZE}" -gt "$(postconf -h message_size_limit)" ]; then
|
||||
postconf -P "cleanup/unix/message_size_limit=${PORT25_SIZE}"
|
||||
fi
|
||||
else
|
||||
postconf -MX "smtp/inet" 2>/dev/null || true
|
||||
postconf -MX "dmarc-ingest/unix" 2>/dev/null || true
|
||||
@@ -409,13 +763,44 @@ fi
|
||||
# Inside a container the chroot buys little (the container is the isolation
|
||||
# boundary) and breaks DNS/TLS trust-store access, so turn it off uniformly. Our
|
||||
# own smtps/submission services are already n; this covers the delivery agents
|
||||
# and the rest.
|
||||
# and the rest. (postfix 3.10.13-0+deb13u1: debian/patches/40_chroot_by_default.diff
|
||||
# sets chroot y. chroot(2) also needs CAP_SYS_CHROOT, which
|
||||
# deploy/docker-compose.yml drops with cap_drop: ALL - capabilities(7).)
|
||||
#
|
||||
# Debian's chroot setup now runs on every "postfix check" and "postfix
|
||||
# start-fg": conf/post-install:587 (debian/patches/
|
||||
# debian-run-configure-instance-from-create-missing.patch) calls
|
||||
# /usr/lib/postfix/configure-instance.sh at the end of create-missing, and
|
||||
# that copies etc/localtime, etc/resolv.conf, etc/hosts and the like into
|
||||
# $queue_directory/etc - here /data/postfix/queue/etc - whether a service is
|
||||
# chrooted or not (debian/configure-instance.in:80-91, before its
|
||||
# need_chroot test at :93). With nothing chrooted those copies are read by
|
||||
# nobody, so the image turns the sync off with SYNC_CHROOT= in
|
||||
# /etc/default/postfix (build/Dockerfile; configure-instance.in:22-24, :44,
|
||||
# "[ -n "$SYNC_CHROOT" ] || exit 0"; debian/README.Debian:136-138).
|
||||
postconf -F "*/*/chroot=n"
|
||||
|
||||
# --- Cyrus SASL app config for smtpd -----------------------------------------
|
||||
# Tells the Cyrus library (invoked by smtpd via smtpd_sasl_path=smtpd) to verify
|
||||
# passwords straight from the panel-maintained sasldb2. PLAIN/LOGIN
|
||||
# only — both are safe because TLS is mandatory before auth on every port.
|
||||
# The directory is said here, cyrus_sasl_config_path, and not left to the
|
||||
# package's main.cf. Postfix 3.10.13, src/xsasl/xsasl_cyrus_server.c:227-232,
|
||||
# xsasl_cyrus_server_init(): a non-empty cyrus_sasl_config_path goes to Cyrus
|
||||
# with sasl_set_path(SASL_PATH_TYPE_CONFIG, ...), and :246,
|
||||
# sasl_server_init(callbacks, path_info) with smtpd_sasl_path=smtpd
|
||||
# (src/smtpd/smtpd_sasl_glue.c:188-189) names smtpd.conf there; the default
|
||||
# is empty (src/global/mail_params.h:1690, DEF_CYRUS_CONF_PATH ""), and then
|
||||
# Cyrus looks in its own path, /etc/sasl2:/etc/sasl:/usr/lib/<triplet>/sasl2:
|
||||
# /usr/lib/sasl2 (cyrus-sasl2 2.1.28+dfsg1, debian/rules:96, --with-configdir;
|
||||
# lib/common.c:210, sasl_set_path()), which this file is not in. Bookworm's
|
||||
# package found it with a patch (07_sasl_config.diff, gone in 3.10.13); trixie's
|
||||
# writes cyrus_sasl_config_path = /etc/postfix/sasl into a new main.cf
|
||||
# (postfix 3.10.13-0+deb13u1, debian/main.cf.in:71) and into an upgraded one
|
||||
# only when it is empty (debian/postfix.postinst:88-97). The keys: Cyrus SASL
|
||||
# 2.1.28, doc/legacy/options.html (pwcheck_method, auxprop_plugin,
|
||||
# sasldb_path, mech_list).
|
||||
postconf -e "cyrus_sasl_config_path=/etc/postfix/sasl"
|
||||
mkdir -p /etc/postfix/sasl
|
||||
cat > /etc/postfix/sasl/smtpd.conf <<EOF
|
||||
pwcheck_method: auxprop
|
||||
@@ -425,7 +810,11 @@ mech_list: PLAIN LOGIN
|
||||
EOF
|
||||
|
||||
# Validate the generated configuration; fail loudly if postconf produced
|
||||
# anything Postfix rejects, before the wrapper tries to start it.
|
||||
# anything Postfix rejects, before the wrapper tries to start it. (Postfix
|
||||
# 3.10.13, conf/postfix-script:292, "check": "check-fatal || exit 1"; check-fatal
|
||||
# also runs "post-install create-missing" (:314), which builds the queue tree
|
||||
# and, in Debian's package, runs configure-instance.sh - see the chroot note
|
||||
# above.)
|
||||
set +e
|
||||
check_out=$(postfix check 2>&1)
|
||||
ec=$?
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/bin/sh
|
||||
# The reload the panel asks supervisord for (program postfix-reload,
|
||||
# build/supervisord.conf; internal/supervisor RunOnce reads its exit status).
|
||||
#
|
||||
# Exit 75 says no Postfix master runs - it is stopped, or still in its start
|
||||
# behind the milter wait (build/postfix-wrapper.sh) - and the master that
|
||||
# starts next reads the files as they are now, so the panel takes that as
|
||||
# done. Anything else is "postfix reload" itself: 0, or its failure (Postfix
|
||||
# 3.10.13, conf/postfix-script:266-279, "reload": exit 1 when not running or
|
||||
# when postsuper fails; src/postfix/postfix.c, a main.cf it cannot read). "master
|
||||
# -t" exits 0, silently, while no master holds the lock (src/master/master.c:495-499,
|
||||
# test_lock). A master that stops between the two is a failure the panel
|
||||
# reports - the rarer mistake (review of stage 2c, F5). Since Postfix 3.8 the
|
||||
# reload also writes "refreshing the Postfix mail system" to stderr, which
|
||||
# goes to the container's log (postfix-wrapper.sh, the note on 3.8's stderr
|
||||
# logging); the panel reads only the exit status.
|
||||
d=$(postconf -h daemon_directory) || exit 1
|
||||
if "$d/master" -t 2>/dev/null; then
|
||||
echo "postfix-reload: Postfix is not running; its start reads the files" >&2
|
||||
exit 75
|
||||
fi
|
||||
exec /usr/sbin/postfix reload
|
||||
+89
-10
@@ -2,22 +2,38 @@
|
||||
# Postfix start wrapper: solves the cold-start race where Postfix would try to
|
||||
# reach the milter sockets before they are listening.
|
||||
#
|
||||
# It blocks until BOTH milter sockets — OpenDKIM and the panel's journal-milter
|
||||
# — are present, then execs `postfix start-fg`. If they are not ready within the
|
||||
# timeout it exits non-zero WITHOUT starting Postfix, so supervisord/Docker see
|
||||
# an explicit startup failure instead of a relay running blind.
|
||||
# It blocks until the milter sockets — the panel's sender check, DKIM signer
|
||||
# (two) and journal-milter and, with the inbound relay on, its
|
||||
# inbound journal milter —
|
||||
# are present, then runs `postfix start-fg` and stays its parent. If they are
|
||||
# not ready within the timeout it exits non-zero WITHOUT starting Postfix.
|
||||
# supervisord counts that exit as one that came too quickly - the program's
|
||||
# startsecs is the timeout plus ten seconds (build/entrypoint.sh) - so after
|
||||
# startretries it is FATAL and crashexit takes the container down, instead of
|
||||
# a relay running blind (supervisor 4.2.5, supervisor/process.py,
|
||||
# Subprocess.finish(): an exit before startsecs is "Exited too quickly" and
|
||||
# goes to BACKOFF; transition(): BACKOFF past startretries is FATAL;
|
||||
# assumptions C-V6).
|
||||
#
|
||||
# This handles cold start only. Runtime milter failures after a successful start
|
||||
# are governed by fail-open (milter_default_action).
|
||||
# This handles cold start only. Once Postfix runs, a milter that does not
|
||||
# answer costs what its default_action says (build/postfix-config.sh): mail is
|
||||
# deferred for the signer and the sender check, and passes the journal-milter.
|
||||
set -eu
|
||||
|
||||
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}"
|
||||
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
|
||||
SENDER_SOCK="${SENDER_CHECK_SOCKET:-/run/selfpost/sender.sock}"
|
||||
SIGN_SOCK="${DKIM_SIGN_SOCKET:-/run/selfpost/sign.sock}"
|
||||
SIGN_LOCAL_SOCK="${DKIM_SIGN_LOCAL_SOCKET:-/run/selfpost/sign-local.sock}"
|
||||
TIMEOUT="${MILTER_WAIT_TIMEOUT:-30}"
|
||||
INTERVAL=1
|
||||
|
||||
SOCKS="$JOURNAL_SOCK $SENDER_SOCK $SIGN_SOCK $SIGN_LOCAL_SOCK"
|
||||
if [ "${INBOUND_RELAY_ENABLE:-false}" = "true" ]; then
|
||||
SOCKS="$SOCKS ${INBOUND_JOURNAL_MILTER_SOCKET:-/run/selfpost/inbound-journal.sock}"
|
||||
fi
|
||||
|
||||
elapsed=0
|
||||
for sock in "$OPENDKIM_SOCK" "$JOURNAL_SOCK"; do
|
||||
for sock in $SOCKS; do
|
||||
while [ ! -S "$sock" ]; do
|
||||
if [ "$elapsed" -ge "$TIMEOUT" ]; then
|
||||
echo "postfix-wrapper: timed out after ${TIMEOUT}s waiting for milter socket $sock" >&2
|
||||
@@ -29,5 +45,68 @@ for sock in "$OPENDKIM_SOCK" "$JOURNAL_SOCK"; do
|
||||
echo "postfix-wrapper: milter socket ready: $sock"
|
||||
done
|
||||
|
||||
echo "postfix-wrapper: both milter sockets ready, starting postfix"
|
||||
exec postfix start-fg
|
||||
echo "postfix-wrapper: milter sockets ready, starting postfix"
|
||||
# Postfix 3.10.13: postfix(1) execs conf/postfix-script (src/postfix/postfix.c:669),
|
||||
# whose "start-fg" (:200-210) execs master only as PID 1 and otherwise runs
|
||||
# "master -s" as its child; master then makes itself a session leader
|
||||
# (src/master/master.c:441, setsid()). A signal supervisord sends this
|
||||
# program never reaches master
|
||||
# (supervisor 4.2.5, supervisor/process.py, Subprocess.kill(): to the pid
|
||||
# alone without stopasgroup, and master is not in its group anyway): before,
|
||||
# a stop left master running - a restart of the program then found Postfix
|
||||
# "already running", and a stop of the container cut Postfix off with
|
||||
# SIGKILL (assumptions C-P42). So this shell stays, and on TERM or INT runs
|
||||
# "postfix stop" (conf/postfix-script:237-253, "stop": TERM to master, then
|
||||
# waits up to five seconds and kills its group; src/master/master_sig.c:205,
|
||||
# master_sigdeath(): "terminating on signal 15"). Debian's redirect of
|
||||
# "postfix start" to systemd (debian/patches/
|
||||
# debian-re-run-startup-through-systemd.patch, conf/postfix-script:170-189)
|
||||
# leaves start-fg alone and needs /run/systemd/system, which this container
|
||||
# has not.
|
||||
#
|
||||
# Since Postfix 3.8 postfix(1) and postlog(1) - postfix-script's $INFO, $WARN
|
||||
# and $FATAL - write their messages to stderr even when it is not a terminal
|
||||
# (src/postfix/postfix.c:546, msg_vstream_init() no longer under isatty();
|
||||
# RELEASE_NOTES-3.8:114-115, [Incompat 20230308]), besides maillog_file. So
|
||||
# "starting the Postfix mail system", "stopping ..." and check-warn's
|
||||
# warnings now reach the container's log through this program's stderr
|
||||
# (build/supervisord.conf) as well as /data/log/mail.log; nothing reads them
|
||||
# there but a person.
|
||||
#
|
||||
# "postfix start-fg" spends its first seconds in its checks before master
|
||||
# runs; a stop asked then would find nothing to stop, so it is asked again
|
||||
# until the child is gone. "master -t" exits 0, silently, while no master
|
||||
# holds the lock (src/master/master.c:495-499, test_lock).
|
||||
daemon_directory=$(postconf -h daemon_directory)
|
||||
child=
|
||||
stop_asked=
|
||||
stop_postfix() {
|
||||
if [ -z "$child" ]; then
|
||||
# Between "&" and "child=$!": stopped right after the pid is known.
|
||||
stop_asked=1
|
||||
return
|
||||
fi
|
||||
trap '' TERM INT
|
||||
while kill -0 "$child" 2>/dev/null; do
|
||||
if "$daemon_directory/master" -t 2>/dev/null; then
|
||||
sleep 1
|
||||
else
|
||||
postfix stop || true
|
||||
fi
|
||||
done
|
||||
exit 0
|
||||
}
|
||||
trap stop_postfix TERM INT
|
||||
postfix start-fg &
|
||||
child=$!
|
||||
if [ -n "$stop_asked" ]; then
|
||||
stop_postfix
|
||||
fi
|
||||
# A trapped signal makes wait return at once, the trap is taken right after
|
||||
# (POSIX, Shell Command Language, 2.11 Signals and Error Handling) and exits.
|
||||
# Otherwise wait returns when Postfix ended on its own: its status is this
|
||||
# program's, and supervisord restarts it.
|
||||
status=0
|
||||
wait "$child" || status=$?
|
||||
echo "postfix-wrapper: postfix exited with status $status" >&2
|
||||
exit "$status"
|
||||
|
||||
+72
-38
@@ -1,12 +1,24 @@
|
||||
; SelfPost process supervision.
|
||||
;
|
||||
; Start ORDER is enforced by priority=: OpenDKIM, then the panel (which opens
|
||||
; the journal-milter socket), then the Postfix wrapper — which additionally
|
||||
; blocks until both milter sockets are ready before starting Postfix.
|
||||
; Start ORDER is enforced by priority=: the panel (which opens the sender
|
||||
; check's, the DKIM signer's and the journal-milter's sockets), then the
|
||||
; Postfix wrapper — which additionally blocks until the milter sockets are ready
|
||||
; before starting Postfix. (supervisor 4.2.5, docs/configuration.rst,
|
||||
; [program:x] "priority": "the start and shutdown ordering"; supervisor/
|
||||
; supervisord.py, runforever(), transitions every group in that order in one
|
||||
; pass, with no wait for a program to be ready.)
|
||||
;
|
||||
; If any managed process exhausts its restart retries (FATAL), the crashexit
|
||||
; event listener brings the whole container down so Docker's restart policy can
|
||||
; recreate it cleanly, rather than lingering with a dead component.
|
||||
; If a managed process of the mail path exhausts its restart retries (FATAL),
|
||||
; or exits unexpectedly five times in ten minutes, the crashexit event listener
|
||||
; brings the whole container down so Docker's restart policy starts it again
|
||||
; (the same container: docs.docker.com, "Start containers automatically",
|
||||
; restart policies), rather than lingering with a dead component. A program
|
||||
; reaches FATAL only through BACKOFF, by exits that come before its startsecs
|
||||
; (supervisor 4.2.5, supervisor/process.py, Subprocess.finish() and
|
||||
; transition()): an exit after it is EXITED and simply restarted, for ever -
|
||||
; which the count of exits covers (audit 2026-10-12, L32). logrotate's,
|
||||
; postfix-reload's and cert-reload's failures are left on Server > Health
|
||||
; (build/crashexit.py).
|
||||
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
@@ -16,15 +28,19 @@ logfile_maxbytes=0
|
||||
pidfile=/run/supervisord.pid
|
||||
loglevel=info
|
||||
|
||||
; The control socket is reachable by the shared `selfpost` group so the panel
|
||||
; (running as the unprivileged `panel` user) can ask supervisord to send
|
||||
; OpenDKIM a reload signal after it rewrites the DKIM tables. It is still
|
||||
; unreachable by world; only root and the two SelfPost services are in the
|
||||
; group. No HTTP auth is configured because access is gated by socket
|
||||
; permissions, not the network.
|
||||
; The control socket is reachable by the `supervisor` group, whose one member
|
||||
; is the panel (the unprivileged `panel` user): it asks supervisord to run the
|
||||
; Postfix reload, and for the processes' state. Whoever reaches the socket can
|
||||
; stop, start or restart any program, so it is not the shared `selfpost`
|
||||
; group's: Postfix is in that one for the files under /data, and a fault in
|
||||
; it must not reach every other program (audit 2026-10-12, stage 5). Not
|
||||
; reachable by world. No HTTP auth is configured because access is gated by
|
||||
; socket permissions, not the network. (supervisor 4.2.5,
|
||||
; docs/configuration.rst, [unix_http_server]: chmod and chown set the socket's
|
||||
; mode and owner; without username and password no authentication is asked.)
|
||||
[unix_http_server]
|
||||
file=/run/supervisor.sock
|
||||
chown=root:selfpost
|
||||
chown=root:supervisor
|
||||
chmod=0770
|
||||
|
||||
[rpcinterface:supervisor]
|
||||
@@ -33,17 +49,10 @@ supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||
[supervisorctl]
|
||||
serverurl=unix:///run/supervisor.sock
|
||||
|
||||
[program:opendkim]
|
||||
command=/usr/sbin/opendkim -f -x /etc/opendkim.conf
|
||||
priority=100
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startretries=3
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; user=panel also gives the panel its supplementary groups, selfpost and
|
||||
; postdrop (build/Dockerfile): supervisor 4.2.5, supervisor/options.py,
|
||||
; drop_privileges(), os.setgroups() from the user's memberships in
|
||||
; grp.getgrall().
|
||||
[program:panel]
|
||||
command=/usr/local/bin/panel
|
||||
user=panel
|
||||
@@ -56,12 +65,22 @@ stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; startsecs is MILTER_WAIT_TIMEOUT plus ten seconds (build/entrypoint.sh): the
|
||||
; wrapper's exit when the milter sockets never come counts as too quick, and
|
||||
; so does a Postfix that will not start. The price: for that long after each
|
||||
; start the program is STARTING, not RUNNING (supervisor 4.2.5,
|
||||
; supervisor/options.py, processes_from_section(): startsecs is expanded like
|
||||
; any value, %(ENV_x)s from supervisord's environment; assumptions C-V6).
|
||||
; stopwaitsecs covers "postfix stop", which waits five seconds for master
|
||||
; before it kills (build/postfix-wrapper.sh).
|
||||
[program:postfix]
|
||||
command=/usr/local/bin/postfix-wrapper.sh
|
||||
priority=300
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=%(ENV_POSTFIX_STARTSECS)s
|
||||
startretries=3
|
||||
stopwaitsecs=15
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
@@ -70,14 +89,20 @@ stderr_logfile_maxbytes=0
|
||||
; One-shot reload helper. The panel runs unprivileged and cannot run `postfix
|
||||
; reload` itself, and it cannot signal the Postfix master directly: `postfix
|
||||
; start-fg` forks a separate master process, so a signal to the supervised
|
||||
; foreground process never reaches it (unlike OpenDKIM, which runs in the
|
||||
; foreground as the supervised process itself). Instead the panel asks
|
||||
; foreground process never reaches it. Instead the panel asks
|
||||
; supervisord — over the group-accessible control socket — to run this program,
|
||||
; which executes the canonical `postfix reload` as root. autostart is off (it
|
||||
; only runs on demand) and a fast, clean exit is expected, so it never trips the
|
||||
; crashexit listener.
|
||||
; which runs the canonical `postfix reload` as root - or exits 75 when no
|
||||
; master runs (build/postfix-reload.sh). autostart is off (it only runs on
|
||||
; demand). A program that cannot even be spawned twice in a row ends FATAL
|
||||
; (startretries=1); crashexit leaves that to the page that asked for the
|
||||
; reload, which reports it (build/crashexit.py; review of stage 2c, F6). (start-fg: see build/postfix-wrapper.sh. supervisor
|
||||
; 4.2.5, supervisor/process.py, Subprocess.finish(): with startsecs=0 no exit
|
||||
; counts as "too quickly", so even a failed run ends EXITED - never BACKOFF,
|
||||
; never FATAL - with its exit status kept, which the panel reads
|
||||
; (internal/supervisor, RunOnce). Its resting state is STOPPED until the first
|
||||
; run, EXITED after it; assumptions C-V10.)
|
||||
[program:postfix-reload]
|
||||
command=/usr/sbin/postfix reload
|
||||
command=/usr/local/bin/postfix-reload.sh
|
||||
autostart=false
|
||||
autorestart=false
|
||||
startsecs=0
|
||||
@@ -88,8 +113,9 @@ stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; Periodic TLS-certificate refresh. Runs a daily `postfix reload`
|
||||
; so certificates the reverse-proxy renews in the read-only mount are picked up.
|
||||
; TLS-certificate refresh. Once a day it compares the certificate the
|
||||
; reverse-proxy renews in the read-only mount with the copy Postfix reads, and
|
||||
; when they differ copies it again and runs `postfix reload`.
|
||||
; Long-running (it loops), runs as root so it can reload Postfix, and never exits
|
||||
; non-zero, so it neither trips the crashexit listener nor needs restarting.
|
||||
[program:cert-reload]
|
||||
@@ -103,23 +129,31 @@ stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; Periodic logrotate for /data/log/mail.log (daily, 7-14 files kept in the
|
||||
; image). Runs as root so logrotate can read/rotate the log. Exits non-zero when
|
||||
; the config is group/other-writable (logrotate would ignore it silently);
|
||||
; autorestart surfaces BACKOFF on the Status page.
|
||||
; Periodic logrotate for /data/log/mail.log (daily, 14 files kept). Runs as
|
||||
; root so logrotate can read/rotate the log. Exits non-zero at once when the
|
||||
; config is group/other-writable (logrotate would ignore it silently): with
|
||||
; startsecs=1 that exit is too quick; once startretries are spent the program
|
||||
; is FATAL, and Server > Health shows it. With startsecs=0 it was restarted
|
||||
; every second for ever, RUNNING and EXITED in turn (supervisor 4.2.5,
|
||||
; supervisor/process.py, Subprocess.finish(); assumptions C-V5). crashexit
|
||||
; leaves this FATAL alone: logrotate is not on the mail path.
|
||||
[program:logrotate]
|
||||
command=/usr/local/bin/logrotate-loop.sh
|
||||
priority=400
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=0
|
||||
startsecs=1
|
||||
startretries=3
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; supervisor 4.2.5, supervisor/process.py: a program enters FATAL only from
|
||||
; BACKOFF, once startretries are spent; a stop asked of supervisord ends in
|
||||
; STOPPED (Subprocess.finish(), self.killing), which this listener never sees.
|
||||
[eventlistener:crashexit]
|
||||
command=/usr/local/bin/crashexit.py
|
||||
events=PROCESS_STATE_FATAL
|
||||
events=PROCESS_STATE_FATAL,PROCESS_STATE_EXITED
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -13,8 +13,6 @@ import (
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
const maxIngestStdin = 10 << 20 // 10 MiB
|
||||
|
||||
func runDMARCIngestMode() error {
|
||||
cfg := loadConfig()
|
||||
if !cfg.dmarcEnabled {
|
||||
@@ -27,18 +25,69 @@ func runDMARCIngestMode() error {
|
||||
defer st.Close()
|
||||
|
||||
recipient := ingestRecipient(os.Args[1:])
|
||||
if err := dmarc.IngestMessage(st, io.LimitReader(os.Stdin, maxIngestStdin), recipient, time.Now().UTC()); err != nil {
|
||||
if err := dmarc.IngestMessage(st, os.Stdin, recipient, time.Now().UTC()); err != nil {
|
||||
if !errors.Is(err, dmarc.ErrRejected) {
|
||||
return err
|
||||
}
|
||||
// Not a report this relay keeps. It is dropped: deferring would have
|
||||
// Postfix try it again for days, and a bounce would go to an envelope
|
||||
// sender anyone can write - the report addresses are public. Postfix
|
||||
// 3.10.13: exit 0 is PIPE_STAT_OK, logged "status=sent (delivered via
|
||||
// ... service ...)" (src/global/pipe_command.c, pipe_command();
|
||||
// src/pipe/pipe.c, eval_command_status()); a 4.x.x status defers until
|
||||
// maximal_queue_lifetime and a 5.x.x one bounces, either way a notice
|
||||
// to the sender (src/qmgr/qmgr_active.c, src/global/bounce.c).
|
||||
if incrErr := st.IncrDMARCParseFailures(); incrErr != nil {
|
||||
log.Printf("dmarc ingest: record failure: %v", incrErr)
|
||||
}
|
||||
return err
|
||||
logRejected(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// logRejected leaves the reason in the mail log. Postfix puts the start of
|
||||
// what a delivery command wrote - about a hundred bytes - into its own line
|
||||
// for the message ("status=sent (delivered via dmarc-ingest service (...))"),
|
||||
// so the reason goes out first and bare: no prefix, no date, no recipient,
|
||||
// all of which that line already has. Postfix 3.10.13, src/pipe/pipe.c,
|
||||
// eval_command_status(): " (%.100s)" of the output, which
|
||||
// src/global/pipe_command.c takes from stdout and stderr together. postlog is
|
||||
// no use here: pipe(8) starts the command without the group that may write to
|
||||
// Postfix's log socket (src/util/set_ugid.c, set_ugid(): setgroups() to the
|
||||
// user's one group). Nor does postlog bring the group with it: upstream
|
||||
// installs it set-gid, Debian's package does not - measured in the image,
|
||||
// 2026-10-15: /usr/sbin/postlog is -rwxr-xr-x root:root (postfix
|
||||
// 3.7.11-0+deb12u1), and dpkg-statoverride lists only postdrop and
|
||||
// postqueue (assumptions A-43). postfix 3.10.13-0+deb13u1 is the same by its
|
||||
// source: debian/postfix.postinst adds statoverrides for /usr/sbin/postdrop
|
||||
// and /usr/sbin/postqueue alone, and nothing in debian/ makes postlog set-gid.
|
||||
func logRejected(reason error) {
|
||||
fmt.Fprintln(os.Stderr, "dropped: "+logSafe(strings.TrimPrefix(reason.Error(), dmarc.ErrRejected.Error()+": ")))
|
||||
}
|
||||
|
||||
// logSafe makes a reason fit for a log line. Parts of it are the sender's own
|
||||
// bytes - a header line, a file name inside an archive, a piece of XML - so it
|
||||
// is cut short and anything that is not printable ASCII becomes "?", whatever
|
||||
// Postfix would have done with it.
|
||||
func logSafe(s string) string {
|
||||
const max = 200
|
||||
if len(s) > max {
|
||||
s = s[:max]
|
||||
}
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r > 0x7e {
|
||||
return '?'
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
}
|
||||
|
||||
// ingestRecipient is the report address the message was delivered to. Postfix's
|
||||
// pipe(8) hands it over as an argument (${recipient} in the service's argv,
|
||||
// build/postfix-config.sh) - it sets no RECIPIENT variable, that is local(8).
|
||||
// Postfix 3.10.13, src/pipe/pipe.c, deliver_message(): pipe_command() gets only
|
||||
// CA_PIPE_CMD_EXPORT(export_environment), no extra environment;
|
||||
// src/local/command.c sets "RECIPIENT".
|
||||
// Without it the check that a report came to its own domain's address is
|
||||
// skipped.
|
||||
func ingestRecipient(args []string) string {
|
||||
|
||||
@@ -18,3 +18,17 @@ func TestIngestRecipient(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogSafe(t *testing.T) {
|
||||
got := logSafe("zip entry \"a\nstatus=bounced\tx\" \x1b[31m é")
|
||||
if want := "zip entry \"a?status=bounced?x\" ?[31m ?"; got != want {
|
||||
t.Errorf("logSafe = %q, want %q", got, want)
|
||||
}
|
||||
long := make([]byte, 1000)
|
||||
for i := range long {
|
||||
long[i] = 'a'
|
||||
}
|
||||
if got := logSafe(string(long)); len(got) != 200 {
|
||||
t.Errorf("logSafe kept %d bytes of 1000, want 200", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
+118
-152
@@ -1,172 +1,138 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// documentedPublic matches docs/guide.md "Environment variables" table.
|
||||
var documentedPublic = []string{
|
||||
"SELFPOST_HOSTNAME",
|
||||
"SUBMISSION_ENABLE",
|
||||
"RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"RATE_LIMIT_WINDOW_SECONDS",
|
||||
"SEND_LOG_RETENTION_DAYS",
|
||||
"PANEL_SESSION_IDLE_DAYS",
|
||||
"SELFPOST_DNS_RESOLVERS",
|
||||
"TRUSTED_PROXY_CIDR",
|
||||
"INBOUND_RELAY_ENABLE",
|
||||
"INBOUND_ANTISPAM_MILTER",
|
||||
"INBOUND_ANTISPAM_MILTER_ACTION",
|
||||
"INBOUND_RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"INBOUND_MESSAGE_SIZE_LIMIT",
|
||||
"DMARC_REPORTS_ENABLE",
|
||||
"DMARC_RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"DMARC_MESSAGE_SIZE_LIMIT",
|
||||
// The environment the image reads and the environment the documents describe
|
||||
// are compared as they are, not as lists kept by hand beside them (audit
|
||||
// 2026-10-12, stage 5 - the three lists this file held were compared with each
|
||||
// other and with nothing else). Read: what the panel's code asks of the
|
||||
// environment (cmd/panel and internal, tests aside) and what the start-up
|
||||
// scripts take with a default (build/*.sh, "${VAR:-…}"). Documented: the
|
||||
// operator's table in guide.md § Environment variables, with the paragraph
|
||||
// under it, and the internal list in architecture.md § Configuration.
|
||||
|
||||
var (
|
||||
goEnvRead = regexp.MustCompile(`(?:envDefault|envInt|envBool|envSeconds|envDuration|os\.Getenv|os\.LookupEnv)\("([A-Z][A-Z0-9_]+)"`)
|
||||
scriptEnvRead = regexp.MustCompile(`\$\{([A-Z][A-Z0-9_]+):[-?=]`)
|
||||
docEnvName = regexp.MustCompile("`([A-Z][A-Z0-9_]{2,})`")
|
||||
)
|
||||
|
||||
func readFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// documentedInternal matches architecture.md § Configuration "Internal env vars".
|
||||
var documentedInternal = []string{
|
||||
"SELFPOST_DATA_DIR",
|
||||
"SELFPOST_DB_PATH",
|
||||
"SELFPOST_SETUP_TOKEN_FILE",
|
||||
"PANEL_HTTP_ADDR",
|
||||
"JOURNAL_MILTER_SOCKET",
|
||||
"MAIL_LOG",
|
||||
"PANEL_COOKIE_SECURE",
|
||||
"OPENDKIM_SOCKET",
|
||||
"OPENDKIM_DIR",
|
||||
"DKIM_SELECTOR_DEFAULT",
|
||||
"SASL_DB_PATH",
|
||||
"SASL_REALM",
|
||||
"POSTFIX_DIR",
|
||||
"POSTFIX_SENDER_LOGIN_MAPS",
|
||||
"POSTFIX_QUEUE_DIR",
|
||||
"POSTFIX_RELAY_DOMAINS",
|
||||
"POSTFIX_TRANSPORT_MAPS",
|
||||
"POSTFIX_RELAY_RECIPIENTS",
|
||||
"POSTFIX_TLS_POLICY_MAPS",
|
||||
"POSTFIX_DMARC_RECIPIENTS",
|
||||
"POSTFIX_DMARC_TRANSPORT",
|
||||
"POSTFIX_DMARC_RELAY_DOMAINS",
|
||||
"SELFPOST_DEPLOY_ROOT",
|
||||
"MILTER_CONNECT_TIMEOUT",
|
||||
"MILTER_COMMAND_TIMEOUT",
|
||||
"MILTER_CONTENT_TIMEOUT",
|
||||
"MILTER_WAIT_TIMEOUT",
|
||||
"TLS_RELOAD_INTERVAL_SECONDS",
|
||||
"LOGROTATE_INTERVAL_SECONDS",
|
||||
}
|
||||
|
||||
// documentedComposeFixed are env keys documented outside the table (TLS paths fixed in compose).
|
||||
var documentedComposeFixed = []string{
|
||||
"TLS_CERT_FILE",
|
||||
"TLS_KEY_FILE",
|
||||
}
|
||||
|
||||
// loadConfigKeys is every environment variable read by loadConfig() in main.go.
|
||||
// Update together with loadConfig when adding a new key.
|
||||
var loadConfigKeys = []string{
|
||||
"SELFPOST_DATA_DIR",
|
||||
"PANEL_HTTP_ADDR",
|
||||
"JOURNAL_MILTER_SOCKET",
|
||||
"MAIL_LOG",
|
||||
"SEND_LOG_RETENTION_DAYS",
|
||||
"SELFPOST_DB_PATH",
|
||||
"SELFPOST_SETUP_TOKEN_FILE",
|
||||
"SELFPOST_HOSTNAME",
|
||||
"PANEL_COOKIE_SECURE",
|
||||
"SUBMISSION_ENABLE",
|
||||
"TRUSTED_PROXY_CIDR",
|
||||
"PANEL_SESSION_IDLE_DAYS",
|
||||
"SELFPOST_DNS_RESOLVERS",
|
||||
"TLS_CERT_FILE",
|
||||
"OPENDKIM_SOCKET",
|
||||
"OPENDKIM_DIR",
|
||||
"DKIM_SELECTOR_DEFAULT",
|
||||
"SASL_DB_PATH",
|
||||
"SASL_REALM",
|
||||
"POSTFIX_DIR",
|
||||
"SELFPOST_DEPLOY_ROOT",
|
||||
"INBOUND_RELAY_ENABLE",
|
||||
"INBOUND_ANTISPAM_MILTER",
|
||||
"INBOUND_ANTISPAM_MILTER_ACTION",
|
||||
"DMARC_REPORTS_ENABLE",
|
||||
}
|
||||
|
||||
// buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh
|
||||
// but not necessarily in loadConfig. Update when startup scripts gain a new knob.
|
||||
var buildScriptKeys = []string{
|
||||
"SELFPOST_HOSTNAME",
|
||||
"TLS_CERT_FILE",
|
||||
"TLS_KEY_FILE",
|
||||
"RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"RATE_LIMIT_WINDOW_SECONDS",
|
||||
"OPENDKIM_SOCKET",
|
||||
"JOURNAL_MILTER_SOCKET",
|
||||
"MAIL_LOG",
|
||||
"POSTFIX_SENDER_LOGIN_MAPS",
|
||||
"POSTFIX_QUEUE_DIR",
|
||||
"SASL_DB_PATH",
|
||||
"SUBMISSION_ENABLE",
|
||||
"INBOUND_RELAY_ENABLE",
|
||||
"INBOUND_ANTISPAM_MILTER",
|
||||
"INBOUND_ANTISPAM_MILTER_ACTION",
|
||||
"INBOUND_RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"INBOUND_MESSAGE_SIZE_LIMIT",
|
||||
"DMARC_REPORTS_ENABLE",
|
||||
"DMARC_RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"DMARC_MESSAGE_SIZE_LIMIT",
|
||||
"POSTFIX_RELAY_DOMAINS",
|
||||
"POSTFIX_TRANSPORT_MAPS",
|
||||
"POSTFIX_RELAY_RECIPIENTS",
|
||||
"POSTFIX_TLS_POLICY_MAPS",
|
||||
"POSTFIX_DMARC_RECIPIENTS",
|
||||
"POSTFIX_DMARC_TRANSPORT",
|
||||
"POSTFIX_DMARC_RELAY_DOMAINS",
|
||||
"MILTER_CONNECT_TIMEOUT",
|
||||
"MILTER_COMMAND_TIMEOUT",
|
||||
"MILTER_CONTENT_TIMEOUT",
|
||||
"MILTER_WAIT_TIMEOUT",
|
||||
"TLS_RELOAD_INTERVAL_SECONDS",
|
||||
"LOGROTATE_INTERVAL_SECONDS",
|
||||
}
|
||||
|
||||
func documentedKeys() []string {
|
||||
keys := append([]string{}, documentedPublic...)
|
||||
keys = append(keys, documentedInternal...)
|
||||
keys = append(keys, documentedComposeFixed...)
|
||||
slices.Sort(keys)
|
||||
return slices.Compact(keys)
|
||||
}
|
||||
|
||||
func TestLoadConfigKeysDocumented(t *testing.T) {
|
||||
doc := documentedKeys()
|
||||
for _, key := range loadConfigKeys {
|
||||
if !slices.Contains(doc, key) {
|
||||
t.Errorf("loadConfig reads %s but it is not listed in guide.md public, internal, or compose-fixed env docs", key)
|
||||
// envRead is every variable the code and the scripts read.
|
||||
func envRead(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
keys := map[string]bool{}
|
||||
for _, root := range []string{".", filepath.Join("..", "..", "internal")} {
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return err
|
||||
}
|
||||
for _, m := range goEnvRead.FindAllStringSubmatch(readFile(t, path), -1) {
|
||||
keys[m[1]] = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
scripts, err := filepath.Glob(filepath.Join("..", "..", "build", "*.sh"))
|
||||
if err != nil || len(scripts) == 0 {
|
||||
t.Fatalf("no build scripts: %v", err)
|
||||
}
|
||||
for _, path := range scripts {
|
||||
for _, m := range scriptEnvRead.FindAllStringSubmatch(readFile(t, path), -1) {
|
||||
keys[m[1]] = true
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// section is the text of doc from the first line that starts with start up
|
||||
// to the next that starts with any of ends.
|
||||
func section(t *testing.T, doc, start string, ends ...string) string {
|
||||
t.Helper()
|
||||
i := strings.Index(doc, "\n"+start)
|
||||
if i < 0 {
|
||||
t.Fatalf("no %q", start)
|
||||
}
|
||||
rest := doc[i+1+len(start):]
|
||||
end := len(rest)
|
||||
for _, e := range ends {
|
||||
if j := strings.Index(rest, "\n"+e); j >= 0 && j < end {
|
||||
end = j
|
||||
}
|
||||
}
|
||||
return rest[:end]
|
||||
}
|
||||
|
||||
// envDocumented is every variable the documents name, and where.
|
||||
func envDocumented(t *testing.T) map[string]string {
|
||||
t.Helper()
|
||||
keys := map[string]string{}
|
||||
guide := section(t, readFile(t, filepath.Join("..", "..", "docs", "guide.md")), "### Environment variables", "### ")
|
||||
for _, m := range docEnvName.FindAllStringSubmatch(guide, -1) {
|
||||
keys[m[1]] = "guide.md § Environment variables"
|
||||
}
|
||||
arch := section(t, readFile(t, filepath.Join("..", "..", "docs", "architecture.md")), "**Internal env vars.**", "### ", "## ", "**")
|
||||
for _, m := range docEnvName.FindAllStringSubmatch(arch, -1) {
|
||||
if _, ok := keys[m[1]]; !ok {
|
||||
keys[m[1]] = "architecture.md § Configuration"
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func TestEnvironmentReadIsDocumented(t *testing.T) {
|
||||
doc := envDocumented(t)
|
||||
var missing []string
|
||||
for key := range envRead(t) {
|
||||
if _, ok := doc[key]; !ok {
|
||||
missing = append(missing, key)
|
||||
}
|
||||
}
|
||||
slices.Sort(missing)
|
||||
for _, key := range missing {
|
||||
t.Errorf("%s is read but documented nowhere: guide.md § Environment variables for an operator's setting, architecture.md § Configuration for an internal one", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildScriptKeysDocumented(t *testing.T) {
|
||||
doc := documentedKeys()
|
||||
for _, key := range buildScriptKeys {
|
||||
if !slices.Contains(doc, key) {
|
||||
t.Errorf("build scripts read %s but it is not listed in guide.md env documentation", key)
|
||||
func TestEnvironmentDocumentedIsRead(t *testing.T) {
|
||||
read := envRead(t)
|
||||
var stale []string
|
||||
for key, where := range envDocumented(t) {
|
||||
if !read[key] {
|
||||
stale = append(stale, key+" ("+where+")")
|
||||
}
|
||||
}
|
||||
slices.Sort(stale)
|
||||
for _, key := range stale {
|
||||
t.Errorf("%s is documented but nothing reads it", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentedKeysAreRead(t *testing.T) {
|
||||
read := append([]string{}, loadConfigKeys...)
|
||||
read = append(read, buildScriptKeys...)
|
||||
slices.Sort(read)
|
||||
read = slices.Compact(read)
|
||||
|
||||
for _, key := range documentedKeys() {
|
||||
if !slices.Contains(read, key) {
|
||||
t.Errorf("guide.md documents %s but no code in loadConfig or build scripts reads it", key)
|
||||
// The parsers find what they are for: a key of each kind is among the read.
|
||||
func TestEnvironmentParsersFindKeys(t *testing.T) {
|
||||
read := envRead(t)
|
||||
for _, key := range []string{"SELFPOST_HOSTNAME", "PANEL_HTTP_ADDR", "MILTER_WAIT_TIMEOUT", "POSTFIX_QUEUE_DIR"} {
|
||||
if !read[key] {
|
||||
t.Errorf("%s not found among the keys read", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+47
-16
@@ -24,31 +24,34 @@ type mailStack struct {
|
||||
Domains *domain.Service
|
||||
Apps *app.Service
|
||||
Inbound *inbound.Service
|
||||
Lists *inbound.Lists
|
||||
DMARC *dmarc.Service
|
||||
pf *postfix.Postfix
|
||||
odk *domain.OpenDKIM
|
||||
inboundEnabled bool
|
||||
dmarcEnabled bool
|
||||
}
|
||||
|
||||
func newMailStack(cfg config, st *store.Store) *mailStack {
|
||||
pf := postfix.New(cfg.postfixDir)
|
||||
odk := domain.NewOpenDKIM(cfg.opendkimDir)
|
||||
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
|
||||
domains := domain.NewService(st, odk, apps, cfg.dkimSelectorDef)
|
||||
domains := domain.NewService(st, domain.NewKeys(cfg.dkimDir), apps, cfg.dkimSelectorDef)
|
||||
// The panel's notifications are sent as postmaster@ the server's own name,
|
||||
// and signed as it.
|
||||
domains.SetServerName(cfg.hostname)
|
||||
inb := inbound.NewService(st, pf)
|
||||
dmarcSvc := dmarc.NewService(st, pf, cfg.hostname, cfg.dmarcEnabled)
|
||||
return &mailStack{
|
||||
Domains: domains, Apps: apps, Inbound: inb, DMARC: dmarcSvc,
|
||||
pf: pf, odk: odk, inboundEnabled: cfg.inboundEnabled, dmarcEnabled: cfg.dmarcEnabled,
|
||||
Lists: inbound.NewLists(st, cfg.antispamDir),
|
||||
pf: pf, inboundEnabled: cfg.inboundEnabled, dmarcEnabled: cfg.dmarcEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
// Resync rebuilds OpenDKIM's tables and Postfix's sender map from SQLite and
|
||||
// reloads both daemons — the same work as the Status page's Reload button.
|
||||
// Resync rebuilds the selector table and Postfix's maps from SQLite and
|
||||
// reloads Postfix — the same work as the Health page's Reload button.
|
||||
func (m *mailStack) Resync() error {
|
||||
if err := m.Domains.Resync(); err != nil {
|
||||
return fmt.Errorf("opendkim resync: %w", err)
|
||||
return fmt.Errorf("dkim resync: %w", err)
|
||||
}
|
||||
if err := m.Apps.Resync(); err != nil {
|
||||
return fmt.Errorf("postfix resync: %w", err)
|
||||
@@ -57,6 +60,11 @@ func (m *mailStack) Resync() error {
|
||||
if err := m.Inbound.Resync(); err != nil {
|
||||
return fmt.Errorf("inbound maps resync: %w", err)
|
||||
}
|
||||
// The filter's configuration names all six list files; they exist from
|
||||
// the first start, empty until an entry is added.
|
||||
if err := m.Lists.Sync(); err != nil {
|
||||
return fmt.Errorf("filter lists resync: %w", err)
|
||||
}
|
||||
}
|
||||
if m.dmarcEnabled {
|
||||
if err := m.DMARC.Resync(); err != nil {
|
||||
@@ -68,7 +76,6 @@ func (m *mailStack) Resync() error {
|
||||
|
||||
func (m *mailStack) skipReloadForTest() {
|
||||
m.pf.SetReloadHook(func() error { return nil })
|
||||
m.odk.SetReloadHook(func() error { return nil })
|
||||
}
|
||||
|
||||
// resyncAfterRestore runs one mail-path Resync on the first boot after a
|
||||
@@ -89,9 +96,11 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
|
||||
ms := newMailStack(cfg, st)
|
||||
// postfix-config.sh has already run (entrypoint). postconf -h is the
|
||||
// effective config, including a manual override; the panel keeps this
|
||||
// snapshot for the process lifetime (architecture.md).
|
||||
// snapshot for the process lifetime (architecture.md). Postfix 3.10.13,
|
||||
// postconf(1): without -d or -n it shows main.cf's value or the default,
|
||||
// and -h drops the "name = " label; values are not $-expanded without -x.
|
||||
retryPolicy := postfix.LoadRetryPolicy()
|
||||
return web.New(st, ms.Domains, ms.Apps, ms.Inbound, ms.DMARC, web.Config{
|
||||
return web.New(st, ms.Domains, ms.Apps, ms.Inbound, ms.Lists, ms.DMARC, web.Config{
|
||||
Hostname: cfg.hostname,
|
||||
CookieSecure: cfg.cookieSecure,
|
||||
SubmissionEnabled: cfg.submissionEnabled,
|
||||
@@ -102,8 +111,12 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
|
||||
Version: buildinfo.Version,
|
||||
TrustedProxyCIDRs: cfg.trustedProxies,
|
||||
TLSCertFile: cfg.tlsCertFile,
|
||||
OpenDKIMSocket: cfg.opendkimSocket,
|
||||
TLSServedCertFile: cfg.tlsServedCertFile,
|
||||
TLSReloadInterval: cfg.tlsReloadInterval,
|
||||
SignSocket: cfg.signSocket,
|
||||
SignLocalSocket: cfg.signLocalSocket,
|
||||
JournalSocket: cfg.journalSocket,
|
||||
SenderSocket: cfg.senderSocket,
|
||||
SessionIdleDays: cfg.sessionIdleDays,
|
||||
DNSResolvers: cfg.dnsResolvers,
|
||||
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
|
||||
@@ -114,9 +127,31 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
|
||||
InboundAntispamAction: cfg.inboundAntispamAction,
|
||||
DMARCEnabled: cfg.dmarcEnabled,
|
||||
SendLogRetentionEnvDefault: cfg.retentionDays,
|
||||
|
||||
InboundSpamLogRetentionEnvDefault: cfg.inboundSpamLogRetentionDays,
|
||||
}, cfg.setupTokenPath)
|
||||
}
|
||||
|
||||
// newHTTPServer bounds what a client may hold the panel's server for (audit
|
||||
// 2026-10-12, L14): the request with its body - a form, or a domain export
|
||||
// of 1 MiB at most - in 30 s; the answer in 60 s, the slowest page being one
|
||||
// that asks DNS; an idle kept-alive connection 2 min. The full backup moves
|
||||
// its own write deadline to two hours: it streams for as long as the archive
|
||||
// takes (handlers.HandleBackup). net/http, Go 1.26: ReadTimeout covers the
|
||||
// header and the body, WriteTimeout runs from the end of the header's read
|
||||
// to the end of the response's write, IdleTimeout is the wait for the next
|
||||
// request on a kept-alive connection.
|
||||
func newHTTPServer(addr string, h http.Handler) *http.Server {
|
||||
return &http.Server{
|
||||
Addr: addr,
|
||||
Handler: h,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 2 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// serveHTTP runs the control-panel HTTP server until ctx is cancelled, using
|
||||
// the database handle shared by all roles: setup, login and the authenticated
|
||||
// panel surface (security.md).
|
||||
@@ -129,11 +164,7 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
|
||||
return err
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.httpAddr,
|
||||
Handler: srvApp.Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
srv := newHTTPServer(cfg.httpAddr, srvApp.Handler())
|
||||
|
||||
// Shut the server down cleanly when the process is asked to stop.
|
||||
go func() {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The panel's server bounds every phase a client could hold it in (audit
|
||||
// 2026-10-12, L14); before, only the header's read was.
|
||||
func TestHTTPServerHasItsTimeouts(t *testing.T) {
|
||||
s := newHTTPServer(":0", nil)
|
||||
for name, d := range map[string]time.Duration{
|
||||
"ReadHeaderTimeout": s.ReadHeaderTimeout,
|
||||
"ReadTimeout": s.ReadTimeout,
|
||||
"WriteTimeout": s.WriteTimeout,
|
||||
"IdleTimeout": s.IdleTimeout,
|
||||
} {
|
||||
if d <= 0 {
|
||||
t.Errorf("%s is %v", name, d)
|
||||
}
|
||||
}
|
||||
if s.ReadTimeout < s.ReadHeaderTimeout || s.WriteTimeout < 30*time.Second {
|
||||
t.Errorf("read %v (header %v), write %v: too short for a form or a DNS page", s.ReadTimeout, s.ReadHeaderTimeout, s.WriteTimeout)
|
||||
}
|
||||
}
|
||||
+46
-11
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/inbound"
|
||||
"github.com/mixeme/selfpost/internal/milter"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
@@ -15,31 +16,65 @@ import (
|
||||
// serveJournal opens the journal-milter Unix socket and runs the real milter
|
||||
// (architecture.md § Mail path), recording accepted messages into the send
|
||||
// log. Socket lifecycle (creation, stale cleanup, group permissions) lives
|
||||
// here; the protocol handler lives in internal/milter.
|
||||
// in listenMilter; the protocol handler lives in internal/milter.
|
||||
func serveJournal(ctx context.Context, cfg config, st *store.Store) error {
|
||||
socketPath := cfg.journalSocket
|
||||
if err := os.MkdirAll(filepath.Dir(socketPath), 0o755); err != nil {
|
||||
ln, err := listenMilter(cfg.journalSocket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Clear a stale socket left behind by an unclean shutdown, otherwise the
|
||||
// listen below fails with "address already in use".
|
||||
if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
log.Printf("journal-milter listening on %s", cfg.journalSocket)
|
||||
return milter.Serve(ctx, ln, st)
|
||||
}
|
||||
|
||||
// serveInboundJournal runs the inbound journal milter, the last milter on
|
||||
// port 25 (internal/milter/inbound.go). It exists only while the inbound relay
|
||||
// is on: build/postfix-config.sh attaches the socket under the same condition.
|
||||
func serveInboundJournal(ctx context.Context, cfg config, st *store.Store) error {
|
||||
ln, err := listenMilter(cfg.inboundJournalSocket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// With a filter attached, the key it signs with. Without the key no
|
||||
// verdict can be told from a sender's own, so none is read: the journal
|
||||
// still records the mail, and the log says why it shows no verdicts.
|
||||
key := ""
|
||||
if cfg.inboundAntispamMilter != "" {
|
||||
if key, err = inbound.FilterKey(cfg.antispamDir); err != nil {
|
||||
log.Printf("inbound-journal-milter: no key for the filter's signature, verdicts will not be recorded: %v", err)
|
||||
key = ""
|
||||
}
|
||||
}
|
||||
log.Printf("inbound-journal-milter listening on %s", cfg.inboundJournalSocket)
|
||||
return milter.ServeInbound(ctx, ln, st, key)
|
||||
}
|
||||
|
||||
// listenMilter opens a milter's Unix socket where Postfix can reach it.
|
||||
func listenMilter(socketPath string) (net.Listener, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(socketPath), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Clear a stale socket left behind by an unclean shutdown, otherwise the
|
||||
// listen below fails with "address already in use" (unix(7): the socket
|
||||
// file "must be deleted by the caller when it is no longer needed"; bind(2)
|
||||
// EADDRINUSE).
|
||||
if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ln, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
// Postfix (user `postfix`) connects to this socket as a milter and needs
|
||||
// group access. The socket inherits group `selfpost` from the setgid parent
|
||||
// dir entrypoint.sh prepares; make it group read/write so postfix can reach
|
||||
// it (connecting to a Unix socket needs write permission on the node).
|
||||
// unix(7): "connecting to a stream socket object requires write
|
||||
// permission on that socket"; inode(7): a file made in a set-group-ID
|
||||
// directory takes the directory's group.
|
||||
if err := os.Chmod(socketPath, 0o660); err != nil {
|
||||
ln.Close()
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Printf("journal-milter listening on %s", socketPath)
|
||||
return milter.Serve(ctx, ln, st)
|
||||
return ln, nil
|
||||
}
|
||||
|
||||
+153
-40
@@ -19,10 +19,13 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/backup"
|
||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/inbound"
|
||||
"github.com/mixeme/selfpost/internal/logtail"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
@@ -33,7 +36,12 @@ func main() {
|
||||
log.SetPrefix("dmarc-ingest: ")
|
||||
if err := runDMARCIngestMode(); err != nil {
|
||||
log.Printf("ingest failed: %v", err)
|
||||
os.Exit(75) // EX_TEMPFAIL — ask Postfix to defer
|
||||
// EX_TEMPFAIL — ask Postfix to defer. Postfix 3.10.13,
|
||||
// src/global/pipe_command.c, pipe_command(): a sysexits code maps
|
||||
// to its DSN (src/global/sys_exits.c: 75 is "4.3.0") and a 4 defers,
|
||||
// unless the output starts with a "D.S.N text" of its own - the
|
||||
// log prefix above makes sure it does not.
|
||||
os.Exit(75)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -59,8 +67,13 @@ func main() {
|
||||
type config struct {
|
||||
httpAddr string
|
||||
journalSocket string
|
||||
mailLog string
|
||||
retentionDays int
|
||||
// The sender check's socket; the default mirrors build/postfix-config.sh.
|
||||
senderSocket string
|
||||
// The DKIM signer's sockets: applications' mail and the panel's own
|
||||
// (cmd/panel/signer.go).
|
||||
signSocket, signLocalSocket string
|
||||
mailLog string
|
||||
retentionDays int
|
||||
|
||||
dataDir string
|
||||
dbPath string
|
||||
@@ -75,20 +88,26 @@ type config struct {
|
||||
rateLimitMessagesPerIP int
|
||||
rateLimitWindowSeconds int
|
||||
|
||||
// Read-only inputs to the panel's status page: the certificate Postfix
|
||||
// serves and the two milter sockets it connects to. The defaults mirror
|
||||
// build/postfix-config.sh, so the status page checks exactly what Postfix
|
||||
// was configured with.
|
||||
tlsCertFile string
|
||||
opendkimSocket string
|
||||
// Read-only input to the panel's status page: the certificate Postfix
|
||||
// serves. The default mirrors build/postfix-config.sh, so the status
|
||||
// page checks exactly what Postfix was configured with.
|
||||
tlsCertFile string
|
||||
// tlsServedCertFile is the certificate Postfix serves (its copy of
|
||||
// tlsCertFile), read from postconf at the start; tlsReloadInterval is
|
||||
// how often build/postfix-cert-reload.sh looks for a renewal.
|
||||
tlsServedCertFile string
|
||||
tlsReloadInterval time.Duration
|
||||
|
||||
opendkimDir string
|
||||
dkimDir string
|
||||
dkimSelectorDef string
|
||||
|
||||
saslDBPath string
|
||||
saslRealm string
|
||||
postfixDir string
|
||||
deployRoot string
|
||||
// Where the filter lists are written as map files for the external spam
|
||||
// filter; the antispam compose fragment mounts it into the sidecar.
|
||||
antispamDir string
|
||||
|
||||
inboundEnabled bool
|
||||
// The optional milter on the inbound listener (INBOUND_ANTISPAM_MILTER) and
|
||||
@@ -96,7 +115,12 @@ type config struct {
|
||||
// what attaches it; the panel only says so. See antispamAction.
|
||||
inboundAntispamMilter string
|
||||
inboundAntispamAction string
|
||||
dmarcEnabled bool
|
||||
// The inbound journal milter's socket; the default mirrors
|
||||
// build/postfix-config.sh. Served only while the inbound relay is on.
|
||||
inboundJournalSocket string
|
||||
// Retention of the inbound journal until Server > Settings says otherwise.
|
||||
inboundSpamLogRetentionDays int
|
||||
dmarcEnabled bool
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
@@ -104,6 +128,11 @@ func loadConfig() config {
|
||||
return config{
|
||||
httpAddr: envDefault("PANEL_HTTP_ADDR", ":8080"),
|
||||
journalSocket: envDefault("JOURNAL_MILTER_SOCKET", "/run/selfpost/journal.sock"),
|
||||
senderSocket: envDefault("SENDER_CHECK_SOCKET", "/run/selfpost/sender.sock"),
|
||||
// The DKIM signer's two sockets; the defaults must match
|
||||
// build/postfix-config.sh and build/postfix-wrapper.sh.
|
||||
signSocket: envDefault("DKIM_SIGN_SOCKET", "/run/selfpost/sign.sock"),
|
||||
signLocalSocket: envDefault("DKIM_SIGN_LOCAL_SOCKET", "/run/selfpost/sign-local.sock"),
|
||||
// Postfix's delivery log, under /data so the lines that resolve a
|
||||
// "queued" send-log row outlive the container. The default must match
|
||||
// maillog_file in build/postfix-config.sh.
|
||||
@@ -141,12 +170,12 @@ func loadConfig() config {
|
||||
rateLimitMessagesPerIP: envInt("RATE_LIMIT_MESSAGES_PER_IP", 100),
|
||||
rateLimitWindowSeconds: envInt("RATE_LIMIT_WINDOW_SECONDS", 3600),
|
||||
|
||||
tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
|
||||
opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
|
||||
tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
|
||||
tlsReloadInterval: time.Duration(envInt("TLS_RELOAD_INTERVAL_SECONDS", 86400)) * time.Second,
|
||||
|
||||
// Per-domain DKIM state (architecture.md § OpenDKIM). The directory layout
|
||||
// matches what entrypoint.sh prepares (setgid, shared `selfpost` group).
|
||||
opendkimDir: envDefault("OPENDKIM_DIR", filepath.Join(dataDir, "opendkim")),
|
||||
// Per-domain DKIM state (architecture.md § DKIM signing): the keys and
|
||||
// the selector table, the layout entrypoint.sh prepares.
|
||||
dkimDir: envDefault("DKIM_DIR", filepath.Join(dataDir, "dkim")),
|
||||
dkimSelectorDef: envDefault("DKIM_SELECTOR_DEFAULT", "selfpost"),
|
||||
|
||||
// Application SASL accounts and the Postfix sender map (architecture.md §
|
||||
@@ -157,11 +186,16 @@ func loadConfig() config {
|
||||
saslDBPath: envDefault("SASL_DB_PATH", filepath.Join(dataDir, "sasl", "sasldb2")),
|
||||
saslRealm: saslRealm(),
|
||||
postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")),
|
||||
deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
|
||||
|
||||
antispamDir: envDefault("ANTISPAM_DIR", filepath.Join(dataDir, "antispam")),
|
||||
deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
|
||||
// Optional inbound relay (backup-MX / forwarder). Off unless exactly "true".
|
||||
inboundEnabled: os.Getenv("INBOUND_RELAY_ENABLE") == "true",
|
||||
inboundAntispamMilter: os.Getenv("INBOUND_ANTISPAM_MILTER"),
|
||||
inboundAntispamAction: antispamAction(os.Getenv("INBOUND_ANTISPAM_MILTER_ACTION")),
|
||||
inboundJournalSocket: envDefault("INBOUND_JOURNAL_MILTER_SOCKET", "/run/selfpost/inbound-journal.sock"),
|
||||
|
||||
inboundSpamLogRetentionDays: envInt("INBOUND_SPAM_LOG_RETENTION_DAYS", store.InboundSpamLogRetentionDaysDefault),
|
||||
// Optional DMARC aggregate ingest on port 25. Off unless exactly "true".
|
||||
dmarcEnabled: os.Getenv("DMARC_REPORTS_ENABLE") == "true",
|
||||
}
|
||||
@@ -183,7 +217,14 @@ func antispamAction(raw string) string {
|
||||
|
||||
// saslRealm chooses the realm new SASL accounts live under. It mirrors the
|
||||
// hostname Postfix's SASL layer uses so a client authenticating with a bare
|
||||
// login resolves to the right account.
|
||||
// login resolves to the right account. Postfix 3.10.13,
|
||||
// src/xsasl/xsasl_cyrus_server.c, xsasl_cyrus_server_create(): serverFQDN is
|
||||
// $myhostname (= SELFPOST_HOSTNAME, build/postfix-config.sh) and user_realm
|
||||
// NULL while smtpd_sasl_local_domain is empty; Cyrus SASL 2.1.28,
|
||||
// plugins/sasldb.c, sasldb_auxprop_lookup(), and common/plugin_common.c,
|
||||
// _plug_parseuser(): a login
|
||||
// without "@" is then looked up in the realm serverFQDN. A SASL_REALM other
|
||||
// than SELFPOST_HOSTNAME does not follow that - assumptions A-48.
|
||||
func saslRealm() string {
|
||||
if r := os.Getenv("SASL_REALM"); r != "" {
|
||||
return r
|
||||
@@ -228,10 +269,17 @@ func parseTrustedProxies(raw string) []*net.IPNet {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(cidr, "/") {
|
||||
if ip := net.ParseIP(cidr); ip != nil && ip.To4() != nil {
|
||||
cidr += "/32"
|
||||
} else {
|
||||
// The prefix length goes with how the address is written: an
|
||||
// IPv4-mapped address written as IPv6 (::ffff:10.0.0.1) is a
|
||||
// 128-bit address, and "/32" on it is ::/32 - every address
|
||||
// whose first 32 bits are zero (::1 among them) trusted, and the
|
||||
// host meant not at all (audit 2026-10-12, L4). Go 1.26
|
||||
// net.IPNet.Contains compares an IPv4 client with the mapped
|
||||
// /128 as with 10.0.0.1/32 (net/ip.go, networkNumberAndMask).
|
||||
if strings.Contains(cidr, ":") {
|
||||
cidr += "/128"
|
||||
} else {
|
||||
cidr += "/32"
|
||||
}
|
||||
}
|
||||
_, n, err := net.ParseCIDR(cidr)
|
||||
@@ -244,6 +292,37 @@ func parseTrustedProxies(raw string) []*net.IPNet {
|
||||
return nets
|
||||
}
|
||||
|
||||
// openData opens the database the panel runs on. When a backup was unpacked
|
||||
// into /data, the restore guard comes first (architecture.md § Persistence):
|
||||
// the manifest's version must match this binary and the archive must have
|
||||
// been unpacked whole before the database is touched, so neither schema skew
|
||||
// nor half an archive can corrupt the restored state. That first start then
|
||||
// runs resync, and only after it the manifest is consumed: a start that fails
|
||||
// on the way leaves it, and the next start is a restore again (audit
|
||||
// 2026-10-12, M1). Without a manifest it is an ordinary start.
|
||||
func openData(cfg config, resync func(*store.Store) error) (st *store.Store, restored bool, err error) {
|
||||
restored, err = backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
st, err = store.Open(cfg.dbPath)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if restored {
|
||||
log.Printf("restore manifest accepted; regenerating mail-path maps from SQLite")
|
||||
if err := resync(st); err != nil {
|
||||
_ = st.Close()
|
||||
return nil, false, err
|
||||
}
|
||||
if err := backup.ConsumeRestore(cfg.manifestPath); err != nil {
|
||||
_ = st.Close()
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
return st, restored, nil
|
||||
}
|
||||
|
||||
// run starts the panel's three roles and blocks until a shutdown signal or the
|
||||
// first fatal error from any role. A signal triggers a clean stop of all
|
||||
// roles; a role error cancels the others and is returned so the process exits
|
||||
@@ -251,37 +330,27 @@ func parseTrustedProxies(raw string) []*net.IPNet {
|
||||
// Image and processes).
|
||||
func run() error {
|
||||
cfg := loadConfig()
|
||||
// postfix-config.sh has written main.cf before supervisord started the
|
||||
// panel (build/entrypoint.sh), and nothing rewrites it after.
|
||||
cfg.tlsServedCertFile = health.ServedCertPath()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
log.Printf("starting selfpost panel %s", buildinfo.Version)
|
||||
|
||||
// Restore version guard (architecture.md § Persistence): if a backup was
|
||||
// extracted into /data, its manifest version must match this binary before we
|
||||
// touch the database, so schema/format skew between versions cannot corrupt
|
||||
// the restored state. A match consumes the manifest; its absence is the
|
||||
// normal (non-restore) case.
|
||||
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// One database handle shared by every role. The store serialises writes
|
||||
// (MaxOpenConns(1)), so the HTTP panel, the journal-milter and the tailer
|
||||
// can all use it without stepping on each other under WAL.
|
||||
st, err := store.Open(cfg.dbPath)
|
||||
st, restored, err := openData(cfg, func(st *store.Store) error {
|
||||
return resyncAfterRestore(cfg, st, false)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
if restored {
|
||||
log.Printf("restore manifest accepted; regenerating mail-path maps from SQLite")
|
||||
if err := resyncAfterRestore(cfg, st, false); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if cfg.dmarcEnabled {
|
||||
if !restored && cfg.dmarcEnabled {
|
||||
ms := newMailStack(cfg, st)
|
||||
ms.pf.SetReloadHook(func() error { return nil })
|
||||
if err := ms.DMARC.Resync(); err != nil {
|
||||
@@ -289,12 +358,46 @@ func run() error {
|
||||
}
|
||||
}
|
||||
|
||||
// The filter lists as files, on every start: the spam filter's
|
||||
// configuration names all six, and a data directory from before the lists
|
||||
// existed has none. Not fatal - the lists are the filter's business, and
|
||||
// the panel has mail to relay either way.
|
||||
if cfg.inboundEnabled && !restored {
|
||||
if err := inbound.NewLists(st, cfg.antispamDir).Sync(); err != nil {
|
||||
log.Printf("filter lists bootstrap: %v", err)
|
||||
}
|
||||
}
|
||||
// The key the filter signs with has to be in the shared directory before
|
||||
// the filter reads it, whether or not a filter is attached yet.
|
||||
if cfg.inboundEnabled {
|
||||
if _, err := inbound.FilterKey(cfg.antispamDir); err != nil {
|
||||
log.Printf("filter key bootstrap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := st.EnsureSendLogRetentionDays(cfg.retentionDays); err != nil {
|
||||
return fmt.Errorf("bootstrap send-log retention: %w", err)
|
||||
}
|
||||
// With the inbound relay on, the tailer also writes the inbound journal's
|
||||
// rows for refused mail and prunes that journal.
|
||||
var inboundTail *logtail.Inbound
|
||||
if cfg.inboundEnabled {
|
||||
if err := st.EnsureInboundSpamLogRetentionDays(cfg.inboundSpamLogRetentionDays); err != nil {
|
||||
return fmt.Errorf("bootstrap inbound journal retention: %w", err)
|
||||
}
|
||||
inboundTail = &logtail.Inbound{Store: st, Retention: func() int {
|
||||
days, err := st.GetInboundSpamLogRetentionDays(cfg.inboundSpamLogRetentionDays)
|
||||
if err != nil {
|
||||
log.Printf("inbound journal retention: %v", err)
|
||||
return cfg.inboundSpamLogRetentionDays
|
||||
}
|
||||
return days
|
||||
}}
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errc := make(chan error, 4)
|
||||
// Room for every role to report a failure without blocking: eight at most.
|
||||
errc := make(chan error, 8)
|
||||
|
||||
roles := []struct {
|
||||
name string
|
||||
@@ -302,6 +405,8 @@ func run() error {
|
||||
}{
|
||||
{"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg, st) }},
|
||||
{"journal-milter", func(ctx context.Context) error { return serveJournal(ctx, cfg, st) }},
|
||||
{"sender-check", func(ctx context.Context) error { return serveSenderCheck(ctx, cfg, st) }},
|
||||
{"dkim-signer", func(ctx context.Context) error { return serveSigner(ctx, cfg, st) }},
|
||||
{"log-tailer", func(ctx context.Context) error {
|
||||
return logtail.Run(ctx, cfg.mailLog, st, func() int {
|
||||
days, err := st.GetSendLogRetentionDays(cfg.retentionDays)
|
||||
@@ -310,12 +415,20 @@ func run() error {
|
||||
return cfg.retentionDays
|
||||
}
|
||||
return days
|
||||
})
|
||||
}, inboundTail, &logtail.SenderCheck{Store: st, Socket: cfg.senderSocket})
|
||||
}},
|
||||
{"rate-limit-recalc", func(ctx context.Context) error {
|
||||
runAutoRateLimitRecalc(ctx, cfg, st)
|
||||
return nil
|
||||
}},
|
||||
{"notifier", func(ctx context.Context) error { return runNotifier(ctx, cfg, st) }},
|
||||
}
|
||||
|
||||
if cfg.inboundEnabled {
|
||||
roles = append(roles, struct {
|
||||
name string
|
||||
fn func(context.Context) error
|
||||
}{"inbound-journal-milter", func(ctx context.Context) error { return serveInboundJournal(ctx, cfg, st) }})
|
||||
}
|
||||
|
||||
for _, r := range roles {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/notify"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// runNotifier writes to the panel's users when something needs them
|
||||
// (architecture.md § Notifications), until ctx is cancelled. It reads what the
|
||||
// pages read - a domain's published records against the key this server signs
|
||||
// with, the certificate file, Postfix's queue - with a DNS checker of its own,
|
||||
// so that its hourly look is never a page's cached one.
|
||||
func runNotifier(ctx context.Context, cfg config, st *store.Store) error {
|
||||
ms := newMailStack(cfg, st)
|
||||
dns := dnscheck.New(cfg.dnsResolvers)
|
||||
|
||||
n := notify.New(st, notify.Checks{
|
||||
Domain: func(d store.Domain) (dnscheck.Domain, error) {
|
||||
rec, err := ms.Domains.DKIMRecord(d)
|
||||
if err != nil {
|
||||
return dnscheck.Domain{}, err
|
||||
}
|
||||
// The server's own addresses are cached for a minute: one lookup
|
||||
// for a pass over all the domains.
|
||||
srv := dns.Server(cfg.hostname, false)
|
||||
return dns.Domain(dnscheck.Query{
|
||||
Name: d.Name,
|
||||
Selector: d.DKIMSelector,
|
||||
ExpectedDKIM: rec.Value,
|
||||
Hostname: srv.Hostname,
|
||||
ServerIPs: srv.IPs,
|
||||
DMARCReportEmail: d.DMARCRua,
|
||||
}, true), nil
|
||||
},
|
||||
Certificate: func() health.Certificate {
|
||||
return health.CheckServedCertificate(cfg.tlsServedCertFile, cfg.tlsCertFile, cfg.tlsReloadInterval, time.Now())
|
||||
},
|
||||
Queue: postfix.QueueMessages,
|
||||
}, notify.NewMailer(cfg.hostname), cfg.hostname)
|
||||
return n.Run(ctx)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A bare address is one host however it is written; an IPv4-mapped one in
|
||||
// IPv6 notation used to become ::/32 (audit 2026-10-12, L4).
|
||||
func TestParseTrustedProxiesTakesABareAddressAsOneHost(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
entry string
|
||||
trusts []string
|
||||
refuses []string
|
||||
}{
|
||||
{"10.0.0.1", []string{"10.0.0.1"}, []string{"10.0.0.2"}},
|
||||
{"::ffff:10.0.0.1", []string{"10.0.0.1", "::ffff:10.0.0.1"}, []string{"10.0.0.2", "::1", "0:0:1::1"}},
|
||||
{"2001:db8::1", []string{"2001:db8::1"}, []string{"2001:db8::2"}},
|
||||
{"10.0.0.0/8", []string{"10.1.2.3"}, []string{"11.0.0.1"}},
|
||||
} {
|
||||
nets := parseTrustedProxies(c.entry)
|
||||
if len(nets) != 1 {
|
||||
t.Fatalf("%q parsed as %v", c.entry, nets)
|
||||
}
|
||||
for _, ip := range c.trusts {
|
||||
if !nets[0].Contains(net.ParseIP(ip)) {
|
||||
t.Errorf("%q (%v) does not trust %s", c.entry, nets[0], ip)
|
||||
}
|
||||
}
|
||||
for _, ip := range c.refuses {
|
||||
if nets[0].Contains(net.ParseIP(ip)) {
|
||||
t.Errorf("%q (%v) trusts %s", c.entry, nets[0], ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/backup"
|
||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// A restore that goes wrong half way must leave the operator where they
|
||||
// started: the archive is refused before the database is opened, and a start
|
||||
// that fails after the guard is a restore again the next time (audit
|
||||
// 2026-10-12, M1 and stage 3).
|
||||
|
||||
// A start that fails after the guard - here its Resync - leaves the manifest,
|
||||
// so the next start is a restore again and runs the Resync the failed one did
|
||||
// not finish. The failed start had the database open, so its files are no
|
||||
// longer the archive's; the next start does not compare them again.
|
||||
func TestARestoreStartThatFailsIsARestoreAgain(t *testing.T) {
|
||||
target := t.TempDir()
|
||||
extract(t, archiveOf(t, seedPanelProject(t)), target)
|
||||
cfg := panelConfig(t, target)
|
||||
|
||||
broken := errors.New("postfix reload failed")
|
||||
if _, _, err := openData(cfg, func(*store.Store) error { return broken }); !errors.Is(err, broken) {
|
||||
t.Fatalf("a start whose Resync failed = %v, want that failure", err)
|
||||
}
|
||||
if _, err := os.Stat(cfg.manifestPath); err != nil {
|
||||
t.Fatalf("the failed start consumed the manifest: %v", err)
|
||||
}
|
||||
|
||||
mapPath := filepath.Join(cfg.dataDir, "postfix", "sender_login_maps")
|
||||
if err := os.WriteFile(mapPath, []byte("stale map\n"), 0o640); err != nil {
|
||||
t.Fatalf("write stale map: %v", err)
|
||||
}
|
||||
st, restored, err := openData(cfg, testResync(cfg))
|
||||
if err != nil {
|
||||
t.Fatalf("the start after a failed one: %v", err)
|
||||
}
|
||||
st.Close()
|
||||
if !restored {
|
||||
t.Fatal("the start after a failed one did not take the data directory for a restore")
|
||||
}
|
||||
if got, _ := os.ReadFile(mapPath); string(got) != "@"+restoreDomain+" shop\n" {
|
||||
t.Errorf("sender map = %q: the second start did not run the Resync", got)
|
||||
}
|
||||
for _, name := range []string{backup.ManifestName, backup.ContentsName, "contents.checked.json"} {
|
||||
if _, err := os.Stat(filepath.Join(cfg.dataDir, name)); !os.IsNotExist(err) {
|
||||
t.Errorf("%s is still there after the restore start succeeded (stat err = %v)", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
st, restored, err = openData(cfg, func(*store.Store) error {
|
||||
t.Error("an ordinary start ran the restore Resync")
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("the ordinary start after the restore: %v", err)
|
||||
}
|
||||
st.Close()
|
||||
if restored {
|
||||
t.Error("the start after a finished restore took it for a restore again")
|
||||
}
|
||||
}
|
||||
|
||||
// An archive unpacked in part - a download cut short, a tar stopped by a
|
||||
// damaged block - is refused before the database is opened, with a message
|
||||
// that says so, and nothing in the directory is changed: the database the
|
||||
// archive left is as it left it, and the manifest is there for the next try.
|
||||
func TestAnArchiveUnpackedInPartIsRefused(t *testing.T) {
|
||||
archive := archiveOf(t, seedPanelProject(t))
|
||||
dbSize := entrySize(t, archive, backup.DataArchivePrefix+"selfpost.db")
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
cut string
|
||||
at int64
|
||||
}{
|
||||
{"in the database", backup.DataArchivePrefix + "selfpost.db", dbSize / 2},
|
||||
{"before the deploy files", backup.ComposeFileName, 0},
|
||||
{"in its last entry", backup.DataArchivePrefix + backup.ContentsName, 10},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
target := t.TempDir()
|
||||
extractCut(t, archive, target, c.cut, c.at)
|
||||
cfg := panelConfig(t, target)
|
||||
before, err := os.ReadFile(cfg.dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("the cut archive left no database: %v", err)
|
||||
}
|
||||
|
||||
_, _, err = openData(cfg, func(*store.Store) error {
|
||||
t.Error("a refused restore ran its Resync")
|
||||
return nil
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "not unpacked whole") {
|
||||
t.Fatalf("the start on an archive cut %s = %v, want it refused as unpacked in part", c.name, err)
|
||||
}
|
||||
|
||||
if after, _ := os.ReadFile(cfg.dbPath); !bytes.Equal(before, after) {
|
||||
t.Error("the refused start changed the database")
|
||||
}
|
||||
for _, side := range []string{"-wal", "-shm", "-journal"} {
|
||||
if _, err := os.Stat(cfg.dbPath + side); !os.IsNotExist(err) {
|
||||
t.Errorf("selfpost.db%s exists: the refused start opened the database", side)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(cfg.manifestPath); err != nil {
|
||||
t.Errorf("the refused start removed the manifest: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A file the archive holds and the data directory holds otherwise - damaged
|
||||
// on the way, or unpacked from another archive - is refused by its name.
|
||||
func TestADamagedRestoredFileIsRefusedByName(t *testing.T) {
|
||||
target := t.TempDir()
|
||||
extract(t, archiveOf(t, seedPanelProject(t)), target)
|
||||
cfg := panelConfig(t, target)
|
||||
|
||||
key := filepath.Join(cfg.dataDir, "dkim", "keys", restoreDomain, "selfpost.private")
|
||||
f, err := os.OpenFile(key, os.O_WRONLY|os.O_APPEND, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("open key: %v", err)
|
||||
}
|
||||
f.WriteString("x")
|
||||
f.Close()
|
||||
|
||||
_, _, err = openData(cfg, func(*store.Store) error {
|
||||
t.Error("a refused restore ran its Resync")
|
||||
return nil
|
||||
})
|
||||
want := "data/dkim/keys/" + restoreDomain + "/selfpost.private"
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("the start on a damaged key = %v, want a refusal naming %s", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
// archiveOf takes a full backup of a seeded project for this binary's version.
|
||||
func archiveOf(t *testing.T, deployRoot string) []byte {
|
||||
t.Helper()
|
||||
dataDir := filepath.Join(deployRoot, "data")
|
||||
var archive bytes.Buffer
|
||||
if err := backup.Create(&archive, backup.Params{
|
||||
DataDir: dataDir,
|
||||
DBPath: filepath.Join(dataDir, "selfpost.db"),
|
||||
Version: buildinfo.Version,
|
||||
DeployRoot: deployRoot,
|
||||
}); err != nil {
|
||||
t.Fatalf("create backup: %v", err)
|
||||
}
|
||||
return archive.Bytes()
|
||||
}
|
||||
|
||||
// entrySize is the size of the archive's entry name.
|
||||
func entrySize(t *testing.T, archive []byte, name string) int64 {
|
||||
t.Helper()
|
||||
gz, err := gzip.NewReader(bytes.NewReader(archive))
|
||||
if err != nil {
|
||||
t.Fatalf("gzip: %v", err)
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
t.Fatalf("the archive has no %s", name)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read the archive: %v", err)
|
||||
}
|
||||
if hdr.Name == name {
|
||||
return hdr.Size
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
-34
@@ -27,7 +27,8 @@ import (
|
||||
// below stubs that story out — the archive is downloaded from a running panel
|
||||
// through /backup, unpacked the way `tar -xzf` unpacks it, and a second panel
|
||||
// is started on the result through the same startup sequence run() uses:
|
||||
// CheckRestore, store.Open, newPanel, Start.
|
||||
// openData (CheckRestore, store.Open, Resync, ConsumeRestore), newPanel,
|
||||
// Start.
|
||||
|
||||
const (
|
||||
restorePassword = "correct-horse-battery"
|
||||
@@ -84,9 +85,9 @@ func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
|
||||
// The archive carries the daemon files, and the first boot after restore
|
||||
// re-derives the maps from SQLite so they stay aligned with the database.
|
||||
for path, want := range map[string]string{
|
||||
filepath.Join("opendkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
|
||||
filepath.Join("sasl", "sasldb2"): "SASLDB",
|
||||
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
|
||||
filepath.Join("dkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
|
||||
filepath.Join("sasl", "sasldb2"): "SASLDB",
|
||||
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
|
||||
} {
|
||||
got, err := os.ReadFile(filepath.Join(r.dataDir, path))
|
||||
if err != nil {
|
||||
@@ -171,22 +172,17 @@ func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) {
|
||||
t.Fatalf("write manifest: %v", err)
|
||||
}
|
||||
|
||||
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckRestore: %v", err)
|
||||
}
|
||||
if !restored {
|
||||
t.Fatal("CheckRestore did not report a restore")
|
||||
}
|
||||
// A record of no files: this test is about the maps, and the files it
|
||||
// seeded were never in an archive.
|
||||
writeContents(t, dataDir, backup.Contents{Format: backup.FormatContents})
|
||||
|
||||
st, err := store.Open(cfg.dbPath)
|
||||
st, restored, err := openData(cfg, testResync(cfg))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
t.Fatalf("open the restored data: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
if err := resyncAfterRestore(cfg, st, true); err != nil {
|
||||
t.Fatalf("resync after restore: %v", err)
|
||||
if !restored {
|
||||
t.Fatal("the start did not take the data directory for a restore")
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(mapPath)
|
||||
@@ -272,10 +268,10 @@ func seedPanelProject(t *testing.T) string {
|
||||
}
|
||||
|
||||
for path, content := range map[string]string{
|
||||
filepath.Join("opendkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
|
||||
filepath.Join("sasl", "sasldb2"): "SASLDB",
|
||||
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
|
||||
filepath.Join("log", "mail.log"): "postfix/smtp[1]: 4A1B2C3D: status=sent",
|
||||
filepath.Join("dkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
|
||||
filepath.Join("sasl", "sasldb2"): "SASLDB",
|
||||
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
|
||||
filepath.Join("log", "mail.log"): "postfix/smtp[1]: 4A1B2C3D: status=sent",
|
||||
} {
|
||||
full := filepath.Join(dataDir, path)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil {
|
||||
@@ -326,28 +322,17 @@ func bootPanel(t *testing.T, deployRoot string) http.Handler {
|
||||
t.Helper()
|
||||
cfg := panelConfig(t, deployRoot)
|
||||
|
||||
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
|
||||
st, restored, err := openData(cfg, testResync(cfg))
|
||||
if err != nil {
|
||||
t.Fatalf("the panel refused to start on %s: %v", deployRoot, err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
if restored {
|
||||
if _, err := os.Stat(cfg.manifestPath); err == nil {
|
||||
t.Errorf("the restore manifest was not consumed, so the next start is gated by it too")
|
||||
}
|
||||
}
|
||||
|
||||
st, err := store.Open(cfg.dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open the restored database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
if restored {
|
||||
if err := resyncAfterRestore(cfg, st, true); err != nil {
|
||||
t.Fatalf("resync after restore: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
panel, err := newPanel(cfg, st)
|
||||
if err != nil {
|
||||
t.Fatalf("build the panel: %v", err)
|
||||
@@ -417,6 +402,15 @@ func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password
|
||||
// extract unpacks a backup archive into dir, as `tar -xzf` does onto the project
|
||||
// directory before the image is started.
|
||||
func extract(t *testing.T, archive []byte, dir string) {
|
||||
t.Helper()
|
||||
extractCut(t, archive, dir, "", 0)
|
||||
}
|
||||
|
||||
// extractCut unpacks the archive as extract does, except that the stream ends
|
||||
// cutAt bytes into the entry named cut: that entry is written as far as the
|
||||
// stream went and nothing after it is, which is what tar leaves of a download
|
||||
// cut short or a damaged block. An empty cut unpacks the whole archive.
|
||||
func extractCut(t *testing.T, archive []byte, dir, cut string, cutAt int64) {
|
||||
t.Helper()
|
||||
gz, err := gzip.NewReader(bytes.NewReader(archive))
|
||||
if err != nil {
|
||||
@@ -426,6 +420,9 @@ func extract(t *testing.T, archive []byte, dir string) {
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
if cut != "" {
|
||||
t.Fatalf("the archive has no entry %s to cut", cut)
|
||||
}
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
@@ -436,6 +433,10 @@ func extract(t *testing.T, archive []byte, dir string) {
|
||||
t.Fatalf("the archive escapes the directory it is extracted into: %q", hdr.Name)
|
||||
}
|
||||
path := filepath.Join(dir, name)
|
||||
var body io.Reader = tr
|
||||
if hdr.Name == cut {
|
||||
body = io.LimitReader(tr, cutAt)
|
||||
}
|
||||
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
@@ -450,7 +451,7 @@ func extract(t *testing.T, archive []byte, dir string) {
|
||||
if err != nil {
|
||||
t.Fatalf("create %s: %v", path, err)
|
||||
}
|
||||
if _, err := io.Copy(f, tr); err != nil {
|
||||
if _, err := io.Copy(f, body); err != nil {
|
||||
f.Close()
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
@@ -458,6 +459,28 @@ func extract(t *testing.T, archive []byte, dir string) {
|
||||
t.Fatalf("close %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
if hdr.Name == cut {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// testResync is the Resync a restore start runs, without reloading daemons
|
||||
// the test does not have.
|
||||
func testResync(cfg config) func(*store.Store) error {
|
||||
return func(st *store.Store) error { return resyncAfterRestore(cfg, st, true) }
|
||||
}
|
||||
|
||||
// writeContents writes the record of an archive's files into dataDir, as the
|
||||
// archive's last entry would have.
|
||||
func writeContents(t *testing.T, dataDir string, c backup.Contents) {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal contents: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dataDir, backup.ContentsName), body, 0o600); err != nil {
|
||||
t.Fatalf("write contents: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/milter"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// aliveEvery is how often the sender check marks itself alive. The gap a
|
||||
// restart finds since the last mark is the check's downtime, so the mark's
|
||||
// age is the error of that figure.
|
||||
const aliveEvery = 2 * time.Second
|
||||
|
||||
// serveSenderCheck runs the sender check (internal/milter/sender.go) on a
|
||||
// socket of its own. Postfix attaches that socket strictly
|
||||
// (default_action=tempfail in build/postfix-config.sh; Postfix 3.10.13,
|
||||
// src/milter/milter8.c, milter8_comm_error(): "451 4.7.1 Service unavailable -
|
||||
// try again later"), so while this role is not serving, mail is deferred:
|
||||
// what that costs is counted here and
|
||||
// by the log tailer, and shown on Server > Health
|
||||
// (docs/plans/from-header-binding.md § Statistics).
|
||||
func serveSenderCheck(ctx context.Context, cfg config, st *store.Store) error {
|
||||
ln, err := listenMilter(cfg.senderSocket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("sender-check listening on %s", cfg.senderSocket)
|
||||
|
||||
// Statistics are the database's business and supervisord's, and the check
|
||||
// is neither: they are kept beside it, and nothing of them is waited for
|
||||
// before it serves.
|
||||
now := time.Now()
|
||||
mark := cfg.senderSocket + ".alive"
|
||||
// The mark's age is read before the mark is kept again: asking supervisord
|
||||
// may take half a minute, and the mark must not go stale meanwhile, or a
|
||||
// restart in that half minute would count it as downtime.
|
||||
var markAt time.Time
|
||||
if fi, err := os.Stat(mark); err == nil {
|
||||
markAt = fi.ModTime()
|
||||
}
|
||||
go keepAlive(ctx, mark, st)
|
||||
go func() {
|
||||
if err := st.StartSenderCheckStats(now); err != nil {
|
||||
log.Printf("sender-check: statistics: %v", err)
|
||||
}
|
||||
if down, estimated := senderCheckDowntime(markAt, now, postfixStarted); down > 0 {
|
||||
if estimated {
|
||||
log.Printf("sender-check: was not answering for at most %s (supervisord did not say since when Postfix ran)", down.Round(time.Second))
|
||||
} else {
|
||||
log.Printf("sender-check: was not answering for %s while Postfix was running", down.Round(time.Second))
|
||||
}
|
||||
for _, part := range byDay(now.Add(-down), now) {
|
||||
if err := st.AddSenderCheckDown(part.day, part.down, estimated); err != nil {
|
||||
log.Printf("sender-check: statistics: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// The table the mail path itself goes by: whom Postfix lets send as what.
|
||||
bind := milter.NewBindings(postfix.New(cfg.postfixDir).SenderLoginMapsPath())
|
||||
return milter.ServeSender(ctx, ln, bind, st)
|
||||
}
|
||||
|
||||
// postfixState is what supervisord said about Postfix: since when it runs, if
|
||||
// it does; known is false when supervisord did not answer.
|
||||
type postfixState struct {
|
||||
started time.Time
|
||||
running, known bool
|
||||
}
|
||||
|
||||
// senderCheckDowntime is for how long the check was not serving before now
|
||||
// while Postfix was: the time since the mark was last set (markAt; zero
|
||||
// when there is none), or since
|
||||
// Postfix started if that was later. No mark means the container is new, and
|
||||
// a Postfix that is not running means mail was stopped for another reason -
|
||||
// a rollout, a reboot - and neither is the check's downtime. When supervisord
|
||||
// could not say, the time since the mark is taken and estimated is true: an
|
||||
// upper bound, since Postfix may not have been running all of it (audit
|
||||
// 2026-10-12, stage 2b - until then such a start counted nothing).
|
||||
func senderCheckDowntime(markAt, now time.Time, ask func() postfixState) (down time.Duration, estimated bool) {
|
||||
if markAt.IsZero() {
|
||||
return 0, false
|
||||
}
|
||||
from := markAt
|
||||
p := ask()
|
||||
switch {
|
||||
case !p.known:
|
||||
estimated = true
|
||||
case !p.running:
|
||||
return 0, false
|
||||
case p.started.After(from):
|
||||
from = p.started
|
||||
}
|
||||
if d := now.Sub(from); d > 0 {
|
||||
return d, estimated
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// dayPart is the part of a stretch of downtime that fell on one UTC day.
|
||||
type dayPart struct {
|
||||
day time.Time
|
||||
down time.Duration
|
||||
}
|
||||
|
||||
// byDay cuts the time from 'from' to 'to' at the UTC midnights between them,
|
||||
// so that a night's outage is not all booked on the morning it ended.
|
||||
func byDay(from, to time.Time) []dayPart {
|
||||
from, to = from.UTC(), to.UTC()
|
||||
var parts []dayPart
|
||||
for from.Before(to) {
|
||||
next := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, 1)
|
||||
if next.After(to) {
|
||||
next = to
|
||||
}
|
||||
parts = append(parts, dayPart{day: from, down: next.Sub(from)})
|
||||
from = next
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// postfixAskFor is how long postfixStarted keeps asking a supervisord that
|
||||
// does not answer - at the container's start it may not be listening yet.
|
||||
var postfixAskFor, postfixAskEvery = 30 * time.Second, 2 * time.Second
|
||||
|
||||
// postfixStarted asks supervisord since when Postfix has been running, again
|
||||
// and again for postfixAskFor while it does not answer. supervisor 4.2.5,
|
||||
// supervisor/rpcinterface.py, _interpretProcessInfo(): the uptime runs from
|
||||
// the spawn of program "postfix", which is build/postfix-wrapper.sh; it waits
|
||||
// for the milter sockets (up to MILTER_WAIT_TIMEOUT) before it execs
|
||||
// "postfix start-fg", so the time given is up to that much early
|
||||
// (assumptions A-46). For startsecs - that wait plus ten seconds
|
||||
// (build/supervisord.conf) - after its start the program is STARTING, not
|
||||
// RUNNING, and is taken here as not running: a panel restart within that
|
||||
// time after Postfix's own counts no downtime.
|
||||
func postfixStarted() postfixState {
|
||||
deadline := time.Now().Add(postfixAskFor)
|
||||
for {
|
||||
procs, err := health.Processes()
|
||||
if err == nil {
|
||||
for _, p := range procs {
|
||||
if p.Name != "postfix" || p.State != "RUNNING" {
|
||||
continue
|
||||
}
|
||||
if up, ok := health.Uptime(p.Detail); ok {
|
||||
return postfixState{started: time.Now().Add(-up), running: true, known: true}
|
||||
}
|
||||
}
|
||||
return postfixState{known: true}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
// Said aloud: this start's downtime is then an estimate.
|
||||
log.Printf("sender-check: statistics: when Postfix started is not known: %v", err)
|
||||
return postfixState{}
|
||||
}
|
||||
time.Sleep(postfixAskEvery)
|
||||
}
|
||||
}
|
||||
|
||||
// keepAlive sets the mark until ctx ends, and once a day drops the days of
|
||||
// the statistics that are older than a year.
|
||||
func keepAlive(ctx context.Context, path string, st *store.Store) {
|
||||
touch := func() {
|
||||
now := time.Now()
|
||||
if err := os.Chtimes(path, now, now); err != nil {
|
||||
if err := os.WriteFile(path, nil, 0o644); err != nil {
|
||||
log.Printf("sender-check: alive mark: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
touch()
|
||||
tick := time.NewTicker(aliveEvery)
|
||||
defer tick.Stop()
|
||||
prune := time.NewTicker(24 * time.Hour)
|
||||
defer prune.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
touch()
|
||||
case <-prune.C:
|
||||
if err := st.PruneSenderCheckDays(time.Now()); err != nil {
|
||||
log.Printf("sender-check: statistics: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The check's downtime is the time since its last mark, under a Postfix that
|
||||
// was running: a new container has no mark, and a Postfix that is not up, or
|
||||
// came up later than the mark, was not kept waiting by the check for all of
|
||||
// that time.
|
||||
func TestSenderCheckDowntime(t *testing.T) {
|
||||
now := time.Date(2026, 10, 12, 18, 0, 0, 0, time.UTC)
|
||||
var mark time.Time // the alive mark's time; zero is no mark
|
||||
set := func(at time.Time) { mark = at }
|
||||
postfix := func(started time.Time, running bool) func() postfixState {
|
||||
return func() postfixState { return postfixState{started: started, running: running, known: true} }
|
||||
}
|
||||
silent := func() postfixState { return postfixState{} }
|
||||
down := func(ask func() postfixState) (time.Duration, bool) { return senderCheckDowntime(mark, now, ask) }
|
||||
|
||||
if d, _ := down(postfix(now.Add(-time.Hour), true)); d != 0 {
|
||||
t.Errorf("no mark: %s", d)
|
||||
}
|
||||
set(now.Add(-40 * time.Second))
|
||||
if d, est := down(postfix(now.Add(-time.Hour), true)); d != 40*time.Second || est {
|
||||
t.Errorf("Postfix up throughout: %s %v, want 40s, not estimated", d, est)
|
||||
}
|
||||
if d, _ := down(postfix(now.Add(-10*time.Second), true)); d != 10*time.Second {
|
||||
t.Errorf("Postfix started within the gap: %s, want 10s", d)
|
||||
}
|
||||
if d, _ := down(postfix(time.Time{}, false)); d != 0 {
|
||||
t.Errorf("Postfix not running: %s", d)
|
||||
}
|
||||
// supervisord did not answer: the time since the mark, an estimate.
|
||||
if d, est := down(silent); d != 40*time.Second || !est {
|
||||
t.Errorf("supervisord silent: %s %v, want 40s, estimated", d, est)
|
||||
}
|
||||
set(now.Add(time.Minute)) // a clock set back
|
||||
if d, _ := down(postfix(now.Add(-time.Hour), true)); d != 0 {
|
||||
t.Errorf("a mark in the future: %s", d)
|
||||
}
|
||||
}
|
||||
|
||||
// Downtime is booked on the days it fell on.
|
||||
func TestByDay(t *testing.T) {
|
||||
at := func(d, h, m int) time.Time { return time.Date(2026, 10, d, h, m, 0, 0, time.UTC) }
|
||||
got := byDay(at(12, 23, 50), at(14, 0, 10))
|
||||
want := []dayPart{{at(12, 23, 50), 10 * time.Minute}, {at(13, 0, 0), 24 * time.Hour}, {at(14, 0, 0), 10 * time.Minute}}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("parts = %+v", got)
|
||||
}
|
||||
for i := range want {
|
||||
if !got[i].day.Equal(want[i].day) || got[i].down != want[i].down {
|
||||
t.Errorf("part %d = %+v, want %+v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
if one := byDay(at(12, 10, 0), at(12, 10, 0).Add(40*time.Second)); len(one) != 1 || one[0].down != 40*time.Second {
|
||||
t.Errorf("within a day: %+v", one)
|
||||
}
|
||||
if none := byDay(at(12, 10, 0), at(12, 10, 0)); len(none) != 0 {
|
||||
t.Errorf("no time: %+v", none)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/milter"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// serveSigner runs the DKIM signer (internal/milter/sign.go) on its two
|
||||
// sockets: signSocket for what applications send on 465/587, signed with the
|
||||
// key of the domain the login belongs to - the sender_login_maps Postfix and
|
||||
// the sender check read (Bindings.DomainOf); signLocalSocket for what the
|
||||
// panel itself submits with sendmail(1), signed with the key of the server's
|
||||
// name. Postfix attaches both strictly (default_action=tempfail,
|
||||
// build/postfix-config.sh), so while this role is not serving, mail waits.
|
||||
//
|
||||
// The keys and selectors come from the key tree and the selector table under
|
||||
// dkimDir (internal/domain, Keys). Before it serves, the table is made to
|
||||
// hold the server's own name - and written at all on the first start of
|
||||
// 2.8.0, whose table replaced OpenDKIM's: Postfix waits for these sockets
|
||||
// (build/postfix-wrapper.sh), so no message meets the table before it.
|
||||
func serveSigner(ctx context.Context, cfg config, st *store.Store) error {
|
||||
if rebuilt, err := newMailStack(cfg, st).Domains.EnsureServerSigned(); err != nil {
|
||||
// Mail waits: what the signer cannot find a key for it defers.
|
||||
log.Printf("dkim-signer: the selector table: %v", err)
|
||||
} else if rebuilt {
|
||||
log.Printf("dkim-signer: the selector table was rewritten from the registry")
|
||||
}
|
||||
keys := milter.NewSignKeys(filepath.Join(cfg.dkimDir, "selectors"), filepath.Join(cfg.dkimDir, "keys"))
|
||||
bind := milter.NewBindings(postfix.New(cfg.postfixDir).SenderLoginMapsPath())
|
||||
server := strings.ToLower(cfg.hostname)
|
||||
|
||||
apps, err := listenMilter(cfg.signSocket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
local, err := listenMilter(cfg.signLocalSocket)
|
||||
if err != nil {
|
||||
apps.Close()
|
||||
return err
|
||||
}
|
||||
log.Printf("dkim-signer listening on %s (applications) and %s (the panel's own mail, as %s)", cfg.signSocket, cfg.signLocalSocket, server)
|
||||
|
||||
errc := make(chan error, 2)
|
||||
go func() { errc <- milter.ServeSign(ctx, apps, keys, bind.DomainOf) }()
|
||||
go func() {
|
||||
errc <- milter.ServeSign(ctx, local, keys, func(string) string { return server })
|
||||
}()
|
||||
// The first to stop decides: an error stops the panel (run()), and on a
|
||||
// cancelled ctx both return nil.
|
||||
if err := <-errc; err != nil {
|
||||
return err
|
||||
}
|
||||
return <-errc
|
||||
}
|
||||
+83
-13
@@ -21,16 +21,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/atomicfile"
|
||||
"github.com/mixeme/selfpost/internal/backup"
|
||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||
"github.com/mixeme/selfpost/internal/secretfile"
|
||||
"github.com/mixeme/selfpost/internal/web/validate"
|
||||
)
|
||||
|
||||
// passwordEnv names the environment variable holding the encryption password.
|
||||
@@ -70,13 +75,20 @@ func run(outPath, password string) error {
|
||||
dataDir := envDefault("SELFPOST_DATA_DIR", "/data")
|
||||
dbPath := envDefault("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db"))
|
||||
|
||||
w, closeOut, err := openOutput(outPath)
|
||||
// The floor the panel holds the same files to: an encrypted backup is
|
||||
// attacked offline, at leisure (security.md; audit 2026-10-12, M5). An
|
||||
// empty password is no encryption, by design.
|
||||
if password != "" && utf8.RuneCountInString(password) < validate.MinSecretFilePasswordLen {
|
||||
return fmt.Errorf("the encryption password must be at least %d characters", validate.MinSecretFilePasswordLen)
|
||||
}
|
||||
|
||||
w, err := openOutput(outPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeOut()
|
||||
defer w.abort()
|
||||
|
||||
sink := w
|
||||
var sink io.Writer = w
|
||||
var env *secretfile.Writer
|
||||
if password != "" {
|
||||
env, err = secretfile.NewWriter(w, secretfile.TypeFullBackup, password)
|
||||
@@ -102,6 +114,9 @@ func run(outPath, password string) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := w.commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
if outPath != "" {
|
||||
kind := "plain"
|
||||
if password != "" {
|
||||
@@ -133,17 +148,28 @@ func runDecrypt(inPath, outPath, password string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The first chunk is opened before anything is written: a wrong password
|
||||
// is told before the output is touched, and the envelope's type is
|
||||
// authenticated only once a chunk opens (secretfile.Reader.Type; audit
|
||||
// 2026-10-12, L1).
|
||||
plain := bufio.NewReaderSize(env, 64<<10)
|
||||
if _, err := plain.Peek(1); err != nil && !errors.Is(err, io.EOF) {
|
||||
return err
|
||||
}
|
||||
if env.Type() != secretfile.TypeFullBackup {
|
||||
return fmt.Errorf("that file is a %s, not a full backup", env.Type())
|
||||
}
|
||||
|
||||
w, closeOut, err := openOutput(outPath)
|
||||
w, err := openOutput(outPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeOut()
|
||||
defer w.abort()
|
||||
|
||||
if _, err := io.Copy(w, env); err != nil {
|
||||
if _, err := io.Copy(w, plain); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
if outPath != "" {
|
||||
@@ -152,17 +178,61 @@ func runDecrypt(inPath, outPath, password string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// openOutput returns stdout, or a freshly created owner-only file: both plain
|
||||
// and encrypted backups are secret enough to keep off other users' eyes.
|
||||
func openOutput(outPath string) (io.Writer, func(), error) {
|
||||
// output is where the CLI writes: stdout, or a file that takes its name only
|
||||
// once it is whole. A backup or a decryption that fails half way - a wrong
|
||||
// password, an envelope cut short - leaves whatever had the name before, and
|
||||
// no half-written file under it (audit 2026-10-12, L1 and stage 3). The file
|
||||
// is the owner's alone (os.CreateTemp makes it 0600): plain and encrypted
|
||||
// backups are secret enough to keep off other users' eyes.
|
||||
type output struct {
|
||||
io.Writer
|
||||
tmp *os.File // nil for stdout
|
||||
path string
|
||||
}
|
||||
|
||||
func openOutput(outPath string) (*output, error) {
|
||||
if outPath == "" {
|
||||
return os.Stdout, func() {}, nil
|
||||
return &output{Writer: os.Stdout}, nil
|
||||
}
|
||||
f, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
f, err := os.CreateTemp(filepath.Dir(outPath), "."+filepath.Base(outPath)+".tmp-*")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
return f, func() { _ = f.Close() }, nil
|
||||
return &output{Writer: f, tmp: f, path: outPath}, nil
|
||||
}
|
||||
|
||||
// commit puts the whole file under its name.
|
||||
func (o *output) commit() error {
|
||||
if o.tmp == nil {
|
||||
return nil
|
||||
}
|
||||
f := o.tmp
|
||||
o.tmp = nil
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
os.Remove(f.Name())
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
os.Remove(f.Name())
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(f.Name(), o.path); err != nil {
|
||||
os.Remove(f.Name())
|
||||
return err
|
||||
}
|
||||
_ = atomicfile.SyncDir(filepath.Dir(o.path))
|
||||
return nil
|
||||
}
|
||||
|
||||
// abort removes a file that was not committed; after commit it does nothing.
|
||||
func (o *output) abort() {
|
||||
if o.tmp == nil {
|
||||
return
|
||||
}
|
||||
o.tmp.Close()
|
||||
os.Remove(o.tmp.Name())
|
||||
o.tmp = nil
|
||||
}
|
||||
|
||||
// readPassword takes the password from the given file (first line) or, when no
|
||||
|
||||
@@ -162,3 +162,76 @@ func TestReadPassword(t *testing.T) {
|
||||
t.Error("-decrypt without a password was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// An encrypted backup is held to the panel's password floor (audit
|
||||
// 2026-10-12, M5); a refused one writes nothing.
|
||||
func TestEncryptionPasswordFloor(t *testing.T) {
|
||||
seedProject(t)
|
||||
out := filepath.Join(t.TempDir(), "backup.spbk")
|
||||
err := run(out, "eleven-char")
|
||||
if err == nil || !strings.Contains(err.Error(), "at least 12 characters") {
|
||||
t.Fatalf("an 11-character password = %v, want refused", err)
|
||||
}
|
||||
if _, statErr := os.Stat(out); !os.IsNotExist(statErr) {
|
||||
t.Errorf("the refused backup wrote %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A wrong SELFPOST_DB_PATH makes no backup and no database (audit
|
||||
// 2026-10-12, M3).
|
||||
func TestBackupOfAMissingDatabaseIsRefused(t *testing.T) {
|
||||
_, dataDir := seedProject(t)
|
||||
missing := filepath.Join(dataDir, "typo.db")
|
||||
t.Setenv("SELFPOST_DB_PATH", missing)
|
||||
out := filepath.Join(t.TempDir(), "backup.tar.gz")
|
||||
if err := run(out, ""); err == nil {
|
||||
t.Fatal("a backup of a database that is not there was made")
|
||||
}
|
||||
for _, p := range []string{missing, out} {
|
||||
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
||||
t.Errorf("the refused backup left %s (stat err = %v)", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -decrypt -o writes its file whole or not at all: a wrong password or an
|
||||
// envelope cut short leaves the file that had the name before, and nothing
|
||||
// else in its directory (audit 2026-10-12, L1 and stage 3).
|
||||
func TestDecryptLeavesTheOutputAloneOnFailure(t *testing.T) {
|
||||
seedProject(t)
|
||||
dir := t.TempDir()
|
||||
encrypted := filepath.Join(dir, "backup.spbk")
|
||||
const password = "a long enough password"
|
||||
if err := run(encrypted, password); err != nil {
|
||||
t.Fatalf("create encrypted backup: %v", err)
|
||||
}
|
||||
sealed, err := os.ReadFile(encrypted)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cut := filepath.Join(dir, "cut.spbk")
|
||||
writeFile(t, cut, string(sealed[:len(sealed)-100]))
|
||||
|
||||
outDir := t.TempDir()
|
||||
out := filepath.Join(outDir, "backup.tar.gz")
|
||||
const before = "the archive that was here"
|
||||
for _, c := range []struct{ name, in, password, want string }{
|
||||
{"wrong password", encrypted, "the wrong password", "wrong password"},
|
||||
{"cut short", cut, password, "corrupted"},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
writeFile(t, out, before)
|
||||
err := runDecrypt(c.in, out, c.password)
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Fatalf("runDecrypt = %v, want %q", err, c.want)
|
||||
}
|
||||
if got, _ := os.ReadFile(out); string(got) != before {
|
||||
t.Errorf("the failed decryption changed %s to %d bytes", out, len(got))
|
||||
}
|
||||
entries, _ := os.ReadDir(outDir)
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("the failed decryption left files beside its output: %v", entries)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ DMARC_REPORTS_ENABLE=false
|
||||
# INBOUND_ANTISPAM_MILTER=inet:antispam:11332
|
||||
# INBOUND_ANTISPAM_MILTER_ACTION=accept
|
||||
|
||||
# Days the inbound journal keeps a row (7-365) until the panel says otherwise;
|
||||
# it never holds more than 10 000 rows.
|
||||
# INBOUND_SPAM_LOG_RETENTION_DAYS=90
|
||||
|
||||
# Coarse per-client-IP limit and message size on inbound smtpd (port 25).
|
||||
# INBOUND_RATE_LIMIT_MESSAGES_PER_IP=20
|
||||
# INBOUND_MESSAGE_SIZE_LIMIT=26214400
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
# Requires INBOUND_RELAY_ENABLE=true in .env. The milter sees the real client
|
||||
# IP, HELO and PTR — unlike the upstream, which only sees SelfPost's address.
|
||||
#
|
||||
# Pin the rspamd tag the same way the SelfPost image is pinned; bump it
|
||||
# deliberately. Default milter action is fail-open (accept) so a down sidecar
|
||||
# The rspamd image is pinned to one build, 3.11.1-3 - "3.11" and "3.11.1"
|
||||
# move with each rebuild (Docker Hub rspamd/rspamd tags 3.11.1-0 ... -3) -
|
||||
# and it is the build the comments in antispam/rspamd/ were read against;
|
||||
# bump it deliberately, and read them again. Default milter action is fail-open (accept) so a down sidecar
|
||||
# does not block backup-MX. Set INBOUND_ANTISPAM_MILTER_ACTION=tempfail to
|
||||
# defer instead.
|
||||
|
||||
@@ -24,6 +26,17 @@ services:
|
||||
- antispam
|
||||
|
||||
antispam:
|
||||
image: rspamd/rspamd:3.11
|
||||
# The milter: rspamd 3.11.1, conf/worker-proxy.inc, "milter = yes" for the
|
||||
# rspamd_proxy worker, which the image binds to *:11332 (rspamd-docker
|
||||
# v3.11.1+3, patches/rspamd-docker-config.diff), and runs as uid 11333
|
||||
# (its Dockerfile, USER 11333:11333).
|
||||
image: rspamd/rspamd:3.11.1-3
|
||||
restart: unless-stopped
|
||||
# No host ports: only SelfPost talks to the milter on the compose network.
|
||||
volumes:
|
||||
# The verdict headers SelfPost's inbound journal reads.
|
||||
- ./antispam/rspamd/milter_headers.conf:/etc/rspamd/local.d/milter_headers.conf:ro
|
||||
# The allow and deny lists kept in the panel: the rules, and the files
|
||||
# SelfPost writes them to.
|
||||
- ./antispam/rspamd/multimap.conf:/etc/rspamd/local.d/multimap.conf:ro
|
||||
- ./data/antispam:/etc/rspamd/selfpost:ro
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Mounted into the sidecar as /etc/rspamd/local.d/milter_headers.conf.
|
||||
#
|
||||
# rspamd writes its verdict into two headers of every message it lets through:
|
||||
# the action it chose and the score with the symbols behind it. SelfPost's
|
||||
# inbound journal reads them (Inbound > Log shows them next to the message),
|
||||
# and the upstream mailbox server receives them with the mail.
|
||||
#
|
||||
# rspamd removes headers of these names that a sender wrote himself before it
|
||||
# adds its own - for every client, because of the two lines below. Without
|
||||
# them it would do neither for a client it counts as local or one that
|
||||
# authenticated: rspamd 3.11.1, src/plugins/lua/milter_headers.lua,
|
||||
# skip_wanted() - skip_local and skip_authenticated are on by default, and
|
||||
# "local" is loopback, a unix socket, or an address in local_addrs, the
|
||||
# private ranges (src/lua/lua_ip.c, lua_ip_is_local(); conf/options.inc). The
|
||||
# sender's own verdict would then pass untouched while the signature below,
|
||||
# a custom routine that skip_wanted() never consults, signed it (assumptions
|
||||
# C-R7). A private client address is ordinary here: a LAN sender, or every
|
||||
# sender when Docker's proxy relays port 25 from the bridge gateway.
|
||||
# Tried on 2026-10-14 in a throwaway rspamd/rspamd:3.11.1-3 (rspamc --ucl, a
|
||||
# message carrying its own X-Spamd-Result and X-Rspamd-Action): without the
|
||||
# two lines, from 10.0.0.5 or as an authenticated user, the milter reply
|
||||
# removed neither header, added neither, and added the signature; with
|
||||
# them, both removed and rspamd's own added, as from a public address.
|
||||
#
|
||||
# Without this file the journal still lists accepted mail, with no verdict
|
||||
# next to it.
|
||||
#
|
||||
# The third header is the proof that the other two are rspamd's. The filter is
|
||||
# attached so that mail still flows when it is down, and then nothing removes
|
||||
# what a sender wrote: he could send his own verdict. So rspamd signs each
|
||||
# message - an HMAC-SHA256 of the message's queue id under a key SelfPost
|
||||
# generates and leaves in the directory it shares with this container - and
|
||||
# SelfPost reads a verdict only next to a signature it can recompute. The
|
||||
# signature travels on with the mail and is of no use to whoever sees it: it
|
||||
# fits that one queue id.
|
||||
#
|
||||
# rspamd 3.11.1, src/plugins/lua/milter_headers.lua: a custom routine is called
|
||||
# as function(task, common_meta) and returns error, headers to add, headers to
|
||||
# remove, metadata; a remove value of 0 removes every header of that name
|
||||
# (docs.rspamd.com/modules/milter_headers, "Custom routines").
|
||||
# src/lua/lua_cryptobox.c, rspamd_lua_hash_create(): "sha256" with a key is
|
||||
# HMAC-SHA256 over EVP_sha256(), the key being the bytes given - here the hex
|
||||
# text. task:get_queue_id() is the Queue-Id the proxy took from the milter
|
||||
# macro "{i}" or "i" (src/libserver/milter.c, rspamd_milter_macro_http(); read
|
||||
# back in src/libserver/protocol.c), which Postfix sends at
|
||||
# end-of-message (Postfix 3.10.13, src/global/mail_params.h:3555,
|
||||
# DEF_MILT_EOD_MACROS "i").
|
||||
use = ["x-spamd-result", "x-rspamd-action", "selfpost-signature"];
|
||||
skip_local = false;
|
||||
skip_authenticated = false;
|
||||
|
||||
custom {
|
||||
selfpost-signature = <<EOD
|
||||
return function(task, common_meta)
|
||||
local remove = { ['X-SelfPost-Filter'] = 0 }
|
||||
-- The key is read for every message and kept nowhere: SelfPost may make a
|
||||
-- new one (the file was lost, a backup was restored), and a key remembered
|
||||
-- here would go on signing what SelfPost no longer accepts until someone
|
||||
-- restarted this container. The file is one short line.
|
||||
local key
|
||||
local f = io.open('/etc/rspamd/selfpost/filter.key', 'r')
|
||||
if f then
|
||||
key = f:read('*l')
|
||||
f:close()
|
||||
end
|
||||
local qid = task:get_queue_id()
|
||||
if not key or key == '' or not qid or qid == '' then
|
||||
return nil, {}, remove, {}
|
||||
end
|
||||
local hash = require "rspamd_cryptobox_hash"
|
||||
local sig = hash.create_specific_keyed(key, 'sha256', qid):hex()
|
||||
return nil, { ['X-SelfPost-Filter'] = sig }, remove, {}
|
||||
end
|
||||
EOD;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
# Mounted into the sidecar as /etc/rspamd/local.d/multimap.conf.
|
||||
#
|
||||
# The allow and deny lists kept in the panel (Inbound > Filter lists). SelfPost
|
||||
# writes them as six files under data/antispam/, which the fragment mounts at
|
||||
# /etc/rspamd/selfpost; rspamd rereads a file when it changes, no restart.
|
||||
# (rspamd 3.11.1, src/libserver/maps/map.c: a file map is watched with libev's
|
||||
# ev_stat - inotify where the kernel has it, else a stat poll every
|
||||
# map_watch_interval x map_file_watch_multiplier, 5min x 0.1, conf/options.inc;
|
||||
# docs.rspamd.com/configuration/maps asks for an atomic rename, which
|
||||
# internal/inbound writes with.)
|
||||
#
|
||||
# Deny wins over allow, whichever entries match. A denied sender is refused
|
||||
# whatever else is found (the rule's action is a pre-result, below). An
|
||||
# allowed one is not accepted outright - rspamd would then let whichever of
|
||||
# the two rules ran first decide - but given a weight no spam score
|
||||
# outweighs, which a refusal by the deny list still overrides. (rspamd
|
||||
# 3.11.1, src/plugins/lua/multimap.lua: every rule is registered as a
|
||||
# "normal" symbol, one with an action with the "passthrough" augmentation;
|
||||
# no "prefilter" setting is read. Measured with this file in a throwaway
|
||||
# rspamd/rspamd:3.11.1-3, 2026-10-15: every mix of an allow and a deny entry
|
||||
# matching one message - address, domain, IP - came out reject, and a
|
||||
# changed map file was in use about ten seconds later.)
|
||||
#
|
||||
# A sender rule looks at the envelope sender only, extract_from = "smtp":
|
||||
# by default multimap.lua takes the header From: when the envelope sender is
|
||||
# empty (the "from" callback, has_from('smtp') then has_from('mime')), so a
|
||||
# bounce would be judged by an address the panel never counts it under
|
||||
# (assumptions C-R4). Tried on 2026-10-14 in rspamd/rspamd:3.11.1-3: a
|
||||
# bounce whose From: domain is on the deny list was refused by default and
|
||||
# is not with this setting; a sender on the list is refused either way.
|
||||
#
|
||||
# A domain entry is that domain, not its subdomains. (rspamd 3.11.1,
|
||||
# src/libserver/maps/map_helpers.c: a hash map is looked up by the whole key,
|
||||
# case-insensitively - rspamd_icase_hash, rspamd_lc_cmp; "email:domain" gives
|
||||
# the address's domain part, src/plugins/lua/multimap.lua, apply_addr_filter().)
|
||||
#
|
||||
# A refusal is rspamd's "pre-result": multimap.lua calls task:set_pre_result()
|
||||
# with the rule's action and message, and that action stands whatever the score
|
||||
# (docs.rspamd.com/modules/multimap, "action"). The message reaches the client
|
||||
# as "554 5.7.1 <message>": src/libserver/protocol.c, rspamd_scan_result_ucl(),
|
||||
# puts it into messages.smtp_message, src/libserver/milter.c,
|
||||
# rspamd_milter_send_task_results(), replies with it.
|
||||
#
|
||||
# SelfPost counts how often an entry decided a message by what these rules
|
||||
# leave behind: the deny message below and the names of the allow symbols.
|
||||
# Change either and Inbound > Filter lists shows "never" for every entry.
|
||||
|
||||
SELFPOST_DENY_IP {
|
||||
type = "ip";
|
||||
map = "/etc/rspamd/selfpost/deny_ip.map";
|
||||
action = "reject";
|
||||
message = "Refused by this server's deny list";
|
||||
description = "Client address on the deny list";
|
||||
}
|
||||
SELFPOST_DENY_FROM {
|
||||
type = "from";
|
||||
extract_from = "smtp";
|
||||
map = "/etc/rspamd/selfpost/deny_from.map";
|
||||
action = "reject";
|
||||
message = "Refused by this server's deny list";
|
||||
description = "Sender address on the deny list";
|
||||
}
|
||||
SELFPOST_DENY_DOMAIN {
|
||||
type = "from";
|
||||
extract_from = "smtp";
|
||||
filter = "email:domain";
|
||||
map = "/etc/rspamd/selfpost/deny_domain.map";
|
||||
action = "reject";
|
||||
message = "Refused by this server's deny list";
|
||||
description = "Sender domain on the deny list";
|
||||
}
|
||||
|
||||
SELFPOST_ALLOW_IP {
|
||||
type = "ip";
|
||||
map = "/etc/rspamd/selfpost/allow_ip.map";
|
||||
score = -100.0;
|
||||
description = "Client address on the allow list";
|
||||
}
|
||||
SELFPOST_ALLOW_FROM {
|
||||
type = "from";
|
||||
extract_from = "smtp";
|
||||
map = "/etc/rspamd/selfpost/allow_from.map";
|
||||
score = -100.0;
|
||||
description = "Sender address on the allow list";
|
||||
}
|
||||
SELFPOST_ALLOW_DOMAIN {
|
||||
type = "from";
|
||||
extract_from = "smtp";
|
||||
filter = "email:domain";
|
||||
map = "/etc/rspamd/selfpost/allow_domain.map";
|
||||
score = -100.0;
|
||||
description = "Sender domain on the allow list";
|
||||
}
|
||||
@@ -7,11 +7,13 @@
|
||||
# add the SSLCertificateFile/SSLCertificateKeyFile directives and a :80 -> :443
|
||||
# redirect vhost, and renews in the background via its own timer. The resulting
|
||||
# PEM files land at
|
||||
# /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host —
|
||||
# bind-mount that directory read-only into the SelfPost container (see
|
||||
# ../docker-compose.yml, the `./certs:/etc/postfix/tls:ro` volume) so Postfix
|
||||
# can present the very same certificate on 465/587. One certificate, two
|
||||
# consumers — no separate cert just for the panel.
|
||||
# /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host,
|
||||
# as links into ../../archive/. Link certs/fullchain.pem and certs/privkey.pem
|
||||
# to them and mount /etc/letsencrypt read-only at the same path (see
|
||||
# ../docker-compose.yml and docs/guide.md, "Reverse proxy") so Postfix can
|
||||
# present the very same certificate on 465/587. Mounting live/mail.example.com
|
||||
# alone gives the container links into a directory it does not have. One
|
||||
# certificate, two consumers — no separate cert just for the panel.
|
||||
|
||||
<VirtualHost *:443>
|
||||
ServerName mail.example.com
|
||||
|
||||
@@ -11,10 +11,18 @@
|
||||
# storage detail, not a documented public API — VERIFY the exact path against
|
||||
# the Caddy version you deploy (`docker compose exec caddy find /data/caddy/certificates -name '*.crt'`)
|
||||
# before wiring it into the volume mount below.
|
||||
# Read in the source (caddyserver/certmagic, storage.go, KeyBuilder.SiteCert():
|
||||
# certificates/<issuer key>/<domain>/<domain>.crt; acmeissuer.go, issuerKey():
|
||||
# the CA URL's host and path, "acme-v02.api.letsencrypt.org-directory" for Let's
|
||||
# Encrypt). caddy/modules/caddytls/automation.go, DefaultIssuers(): ZeroSSL is a
|
||||
# second issuer only when an email is configured, and the Caddyfile below sets
|
||||
# none. Read at the projects' master of 2026-10-14: `caddy:2` is a moving tag.
|
||||
#
|
||||
# Merge with the base file — run this from the deploy/ directory (Compose
|
||||
# resolves every relative path in both files against the directory of the
|
||||
# FIRST -f file, i.e. deploy/, which is why paths below are ./caddy/...):
|
||||
# FIRST -f file, i.e. deploy/, which is why paths below are ./caddy/... -
|
||||
# docs.docker.com/compose/how-tos/multiple-compose-files/merge/: "Paths are
|
||||
# evaluated relative to the base file"):
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f caddy/docker-compose.caddy.yml up -d
|
||||
|
||||
|
||||
+34
-12
@@ -5,7 +5,7 @@
|
||||
# on the HOST (the target audience for this project typically runs Apache there
|
||||
# already) and reverse-proxies HTTPS for the panel; it is not containerised
|
||||
# here. See ../apache/selfpost-vhost.conf for a ready vhost fragment, and
|
||||
# docs/guide.md "Reverse proxy" for the certbot steps that produce the PEM files
|
||||
# docs/guide.md "Reverse proxy (mandatory)" for the certbot steps that produce the PEM files
|
||||
# this compose file mounts.
|
||||
#
|
||||
# Usage:
|
||||
@@ -18,10 +18,12 @@
|
||||
# The image tag below is FIXED on purpose: backup restore compares the manifest
|
||||
# version against the running binary's version, so ":latest" would make that
|
||||
# check meaningless. Bump the tag deliberately when you want to upgrade.
|
||||
# The 2.x line is not on ghcr.io yet: build it locally under this exact name
|
||||
# (README, Quick start) and Compose will use it without pulling.
|
||||
|
||||
services:
|
||||
selfpost:
|
||||
image: ghcr.io/mixeme/selfpost:2.0.0
|
||||
image: ghcr.io/mixeme/selfpost:2.7.0
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
||||
@@ -47,6 +49,7 @@ services:
|
||||
# How long the send log keeps rows before the background sweep deletes
|
||||
# them — the main driver of /data growth over time.
|
||||
SEND_LOG_RETENTION_DAYS: "${SEND_LOG_RETENTION_DAYS:-90}"
|
||||
INBOUND_SPAM_LOG_RETENTION_DAYS: "${INBOUND_SPAM_LOG_RETENTION_DAYS:-90}"
|
||||
# Sliding idle timeout for the panel login session, in days.
|
||||
PANEL_SESSION_IDLE_DAYS: "${PANEL_SESSION_IDLE_DAYS:-7}"
|
||||
# Resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly, so they
|
||||
@@ -69,9 +72,15 @@ services:
|
||||
# certs/ are archived alongside /data (architecture.md § Persistence).
|
||||
- .:/selfpost-deploy:ro
|
||||
# Read-only: SelfPost only ever reads certificates, never manages them.
|
||||
# Point this at wherever your reverse-proxy/certbot writes PEM files, e.g.
|
||||
# /etc/letsencrypt on the host.
|
||||
# ./certs holds fullchain.pem and privkey.pem: the files themselves, or
|
||||
# links to where your reverse proxy's certbot keeps them.
|
||||
- ./certs:/etc/postfix/tls:ro
|
||||
# With certbot on the host (docs/guide.md, "Reverse proxy (mandatory)", Apache):
|
||||
# certs/ holds links into /etc/letsencrypt/live/, and certbot's live/
|
||||
# files are themselves relative links into archive/ - so the whole
|
||||
# directory is mounted, at the same path, for the links to lead
|
||||
# somewhere inside the container. Uncomment it then.
|
||||
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
||||
ports:
|
||||
# 465 (smtps, primary) and optionally 587 (submission/STARTTLS) are
|
||||
# published directly — mail traffic bypasses Apache entirely, it only ever
|
||||
@@ -81,7 +90,7 @@ services:
|
||||
# reachable from the internet without TLS.
|
||||
- "465:465"
|
||||
# 587 is mapped even when SUBMISSION_ENABLE=false; Postfix listens only
|
||||
# when the variable is true — see docs/guide.md "Published ports".
|
||||
# when the variable is true — see docs/guide.md "Ports".
|
||||
- "587:587"
|
||||
# 25 is mapped even when INBOUND_RELAY_ENABLE=false; Postfix listens only
|
||||
# when the variable is true.
|
||||
@@ -94,7 +103,7 @@ services:
|
||||
# `user: panel` or a fully read-only rootfs without breaking that startup
|
||||
# self-healing. What IS applied: no privilege escalation past what the
|
||||
# image already grants, and every Linux capability dropped except the
|
||||
# small set the root startup phase and Postfix/OpenDKIM genuinely need:
|
||||
# small set the root startup phase and Postfix genuinely need:
|
||||
# - NET_BIND_SERVICE — bind 465/587 and inbound 25 below 1024;
|
||||
# - CHOWN — entrypoint re-owns /data (bind mount) to `panel`;
|
||||
# - FOWNER — entrypoint then chmods those now panel-owned /data
|
||||
@@ -104,15 +113,28 @@ services:
|
||||
# - SETUID/SETGID — supervisord drops the panel to the unprivileged
|
||||
# `panel` user; Postfix switches to its own users;
|
||||
# - DAC_OVERRIDE — cross-user file access within the `selfpost` group.
|
||||
# - KILL — supervisord (root) sends SIGUSR1 to opendkim (a
|
||||
# different uid) on domain add/remove to reload its
|
||||
# KeyTable/SigningTable; without it os.kill() fails
|
||||
# with EPERM even though the caller is root, because
|
||||
# the kernel's signal permission check for
|
||||
# cross-uid kill() still consults CAP_KILL.
|
||||
# - KILL — root signals processes of other uids: supervisord
|
||||
# stops and restarts the panel (supervisor 4.2.5,
|
||||
# supervisor/process.py:467, options.kill()), and
|
||||
# Postfix's master ends its children, which run as
|
||||
# `postfix` (Postfix 3.10.13, src/master/master_sig.c:196,
|
||||
# master_sigdeath(); master_spawn.c:365,
|
||||
# master_delete_children()). Without it kill() fails
|
||||
# with EPERM although the caller is root (kill(2),
|
||||
# capabilities(7); measured on the test server
|
||||
# 2026-10-16: root with SETUID/SETGID and no KILL
|
||||
# cannot kill a process of uid 65534). Until 2.8.0
|
||||
# it also carried OpenDKIM's USR1 reload.
|
||||
# FOWNER/FSETID are required by build/entrypoint.sh's permission
|
||||
# self-healing; without them chmod fails with EPERM and the container
|
||||
# crash-loops on start.
|
||||
# Each capability above as capabilities(7) defines it (man7.org,
|
||||
# "Capabilities list"); SETUID/SETGID are also what pipe(8) uses to become
|
||||
# user=panel for dmarc-ingest. Not listed, so dropped: SYS_CHROOT, which
|
||||
# chroot(2) needs - build/postfix-config.sh turns Postfix's chroot off.
|
||||
# no-new-privileges only changes execve() (Linux,
|
||||
# Documentation/userspace-api/no_new_privs.rst): root may still call
|
||||
# setuid()/setgid() with these capabilities.
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
|
||||
@@ -8,14 +8,21 @@
|
||||
# takes over 80/443 and proxies to the panel over the compose network
|
||||
# instead). Merge it with the base file — run this from the deploy/ directory
|
||||
# (Compose resolves every relative path in both files against the directory of
|
||||
# the FIRST -f file, i.e. deploy/, which is why paths below are ./nginx/...):
|
||||
# the FIRST -f file, i.e. deploy/, which is why paths below are ./nginx/... -
|
||||
# docs.docker.com/compose/how-tos/multiple-compose-files/merge/: "Paths are
|
||||
# evaluated relative to the base file"):
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d
|
||||
#
|
||||
# First-run certificate issuance (webroot method, before nginx has a cert to
|
||||
# serve — run once):
|
||||
# First-run certificate issuance, before nginx has a certificate to serve -
|
||||
# run once, with port 80 free (certbot answers the challenge itself;
|
||||
# --webroot would need nginx already serving /.well-known). --entrypoint is
|
||||
# needed: the service's own entrypoint is the renewal loop below, an
|
||||
# `sh -c` that ignores any arguments given to `run`
|
||||
# (docs.docker.com/reference/compose-file/services/#entrypoint; assumptions
|
||||
# D-guide:301):
|
||||
# docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml \
|
||||
# run --rm certbot certonly --webroot -w /var/www/certbot \
|
||||
# run --rm -p 80:80 --entrypoint certbot certbot certonly --standalone \
|
||||
# -d mail.example.com --email you@example.com --agree-tos --no-eff-email
|
||||
|
||||
services:
|
||||
@@ -29,10 +36,13 @@ services:
|
||||
volumes: !override
|
||||
- ./data:/data
|
||||
- .:/selfpost-deploy:ro
|
||||
# Same host directory certbot below writes into — plain bind mount, no
|
||||
# named volume, so the PEM files are as directly inspectable as in the
|
||||
# Apache scenario.
|
||||
- ./nginx/certbot-etc/live/mail.example.com:/etc/postfix/tls:ro
|
||||
# certs/ holds two links, fullchain.pem and privkey.pem, to
|
||||
# /etc/letsencrypt/live/mail.example.com/ - a path inside the
|
||||
# container: certbot's directory below is mounted there whole, because
|
||||
# its live/ files are relative links into archive/ (mounting live/ alone
|
||||
# left them pointing at nothing). docs/guide.md, "Reverse proxy", nginx.
|
||||
- ./certs:/etc/postfix/tls:ro
|
||||
- ./nginx/certbot-etc:/etc/letsencrypt:ro
|
||||
|
||||
nginx:
|
||||
image: nginx:1.27
|
||||
@@ -54,4 +64,11 @@ services:
|
||||
- ./nginx/certbot-www:/var/www/certbot
|
||||
# Renewal twice a day is certbot's own recommended cadence; it no-ops
|
||||
# until a certificate is within its renewal window.
|
||||
entrypoint: sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done'
|
||||
# (eff-certbot.readthedocs.io, User Guide, "Automated Renewals": the
|
||||
# sample cron runs "certbot renew" at 0 and 12 h; renew acts only on a
|
||||
# certificate near expiry.) The first certificate was issued --standalone
|
||||
# (see the top of this file), and certbot renews with the plugin it was
|
||||
# issued with "unless you specify other plugins or options" (same page,
|
||||
# "Renewing certificates") - by then nginx holds port 80 and serves the
|
||||
# webroot, so renewal names it; a renewal that succeeds with it saves it.
|
||||
entrypoint: sh -c 'trap exit TERM; while :; do certbot renew --webroot -w /var/www/certbot; sleep 12h & wait $${!}; done'
|
||||
|
||||
@@ -17,6 +17,8 @@ server {
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
# nginx.org/en/docs/http/ngx_http_v2_module.html, "http2": "This directive
|
||||
# appeared in version 1.25.1" (the fragment runs nginx:1.27).
|
||||
http2 on;
|
||||
server_name mail.example.com;
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
#
|
||||
# Merge with the base file — run this from the deploy/ directory (Compose
|
||||
# resolves every relative path in both files against the directory of the
|
||||
# FIRST -f file, i.e. deploy/, which is why paths below are ./traefik/...):
|
||||
# FIRST -f file, i.e. deploy/, which is why paths below are ./traefik/... -
|
||||
# docs.docker.com/compose/how-tos/multiple-compose-files/merge/: "Paths are
|
||||
# evaluated relative to the base file"):
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f traefik/docker-compose.traefik.yml up -d
|
||||
|
||||
@@ -25,7 +27,10 @@ services:
|
||||
# Traefik only proxies the panel; it never sees the mail ports.
|
||||
# These live on the selfpost service (not traefik's) because the
|
||||
# docker provider reads routing labels off the container being routed
|
||||
# to, not off traefik itself.
|
||||
# to, not off traefik itself. (doc.traefik.io/traefik/v3.1/providers/
|
||||
# docker/, "Routing Configuration": "Traefik uses container labels";
|
||||
# with exposedByDefault=false only containers labelled
|
||||
# traefik.enable=true.)
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.selfpost.rule=Host(`mail.example.com`)
|
||||
- traefik.http.routers.selfpost.entrypoints=websecure
|
||||
|
||||
Regular → Executable
+59
-12
@@ -1,28 +1,75 @@
|
||||
#!/bin/sh
|
||||
#!/bin/bash
|
||||
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json —
|
||||
# Traefik keeps its certificates there, so a PEM extraction step is required.
|
||||
# Run this on the host, after Traefik has issued or renewed the certificate, and
|
||||
# again on a schedule (cron/systemd timer) since acme.json is not itself watched
|
||||
# by SelfPost/Postfix.
|
||||
#
|
||||
# Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
|
||||
set -eu
|
||||
# Requires bash and jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
|
||||
#
|
||||
# The layout is not documented (doc.traefik.io/traefik/v3.1/https/acme/ says
|
||||
# only "JSON file"); read in Traefik v3.1.0: pkg/provider/acme/local_store.go
|
||||
# keeps map[resolver name]*StoredData, store.go StoredData{Account,
|
||||
# Certificates}, provider.go Certificate{domain, certificate, key} with the PEM
|
||||
# as []byte - base64 in JSON (Go encoding/json). "le" is the resolver
|
||||
# docker-compose.traefik.yml names.
|
||||
#
|
||||
# Nothing is replaced unless both new files are PEM: a domain acme.json does
|
||||
# not hold, an empty field or a broken file stops the script with an error and
|
||||
# leaves the files that were there (audit 2026-10-12, M21 - under sh without
|
||||
# pipefail, jq finding nothing still exited 0 and two empty files replaced the
|
||||
# certificate; Postfix's TLS broke at its next restart). The new files are
|
||||
# written beside the old ones, the owner's alone until they are whole, then
|
||||
# renamed into place.
|
||||
set -euo pipefail
|
||||
|
||||
ACME_JSON="${1:?path to acme.json}"
|
||||
DOMAIN="${2:?domain name, e.g. mail.example.com}"
|
||||
OUT_DIR="${3:?output directory, e.g. ./extracted-certs}"
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
umask 077
|
||||
|
||||
jq -r --arg domain "$DOMAIN" '
|
||||
.le.Certificates[]
|
||||
| select(.domain.main == $domain)
|
||||
| .certificate' "$ACME_JSON" | base64 -d > "$OUT_DIR/fullchain.pem"
|
||||
die() {
|
||||
echo "extract-cert: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
jq -r --arg domain "$DOMAIN" '
|
||||
.le.Certificates[]
|
||||
| select(.domain.main == $domain)
|
||||
| .key' "$ACME_JSON" | base64 -d > "$OUT_DIR/privkey.pem"
|
||||
# field prints the base64 of one field of the domain's first certificate, or
|
||||
# nothing.
|
||||
field() {
|
||||
jq -r --arg domain "$DOMAIN" --arg field "$1" '
|
||||
first(.le.Certificates[]? | select(.domain.main == $domain) | .[$field]) // empty
|
||||
' "$ACME_JSON"
|
||||
}
|
||||
|
||||
chmod 0640 "$OUT_DIR/fullchain.pem" "$OUT_DIR/privkey.pem"
|
||||
cert_b64=$(field certificate)
|
||||
key_b64=$(field key)
|
||||
[ -n "$cert_b64" ] || die "$ACME_JSON holds no certificate for $DOMAIN"
|
||||
[ -n "$key_b64" ] || die "$ACME_JSON holds no key for $DOMAIN"
|
||||
|
||||
tmp_cert="" tmp_key=""
|
||||
trap 'rm -f "$tmp_cert" "$tmp_key"' EXIT
|
||||
tmp_cert=$(mktemp "$OUT_DIR/.fullchain.pem.XXXXXX")
|
||||
tmp_key=$(mktemp "$OUT_DIR/.privkey.pem.XXXXXX")
|
||||
|
||||
printf '%s' "$cert_b64" | base64 -d >"$tmp_cert" || die "the certificate of $DOMAIN is not base64"
|
||||
printf '%s' "$key_b64" | base64 -d >"$tmp_key" || die "the key of $DOMAIN is not base64"
|
||||
grep -q -- '-----BEGIN CERTIFICATE-----' "$tmp_cert" || die "the certificate of $DOMAIN is not PEM"
|
||||
grep -q -- '-----BEGIN .*PRIVATE KEY-----' "$tmp_key" || die "the key of $DOMAIN is not PEM"
|
||||
|
||||
chmod 0640 "$tmp_cert" "$tmp_key"
|
||||
# The group the files had stays theirs: the guide gives the key to group 10998
|
||||
# so the panel can read it for a full backup, and a run from root's cron would
|
||||
# otherwise hand it to root's group (review of audit stage 5).
|
||||
for f in fullchain privkey; do
|
||||
if [ -e "$OUT_DIR/$f.pem" ]; then
|
||||
tmp=$tmp_cert
|
||||
[ "$f" = privkey ] && tmp=$tmp_key
|
||||
chgrp --reference="$OUT_DIR/$f.pem" "$tmp" || die "cannot give the new $f.pem the group of the old one"
|
||||
fi
|
||||
done
|
||||
mv -f "$tmp_cert" "$OUT_DIR/fullchain.pem"
|
||||
mv -f "$tmp_key" "$OUT_DIR/privkey.pem"
|
||||
trap - EXIT
|
||||
echo "extracted $DOMAIN to $OUT_DIR/{fullchain,privkey}.pem"
|
||||
|
||||
+603
-81
@@ -26,22 +26,44 @@ Managed programs ([build/supervisord.conf](../build/supervisord.conf)):
|
||||
|
||||
| Program | User | Priority | Role |
|
||||
|---|---|---|---|
|
||||
| `syslog-relay` | panel | 50 | The container's `/dev/log`: passes what OpenDKIM logs through syslog(3) to the container's log (`panel -syslog-relay`) |
|
||||
| `opendkim` | root → `opendkim` (`UserID` in opendkim.conf) | 100 | DKIM signing milter |
|
||||
| `panel` | panel | 200 | HTTP UI + journal-milter + log-tailer + rate-limit recalc |
|
||||
| `postfix` | root (wrapper) | 300 | MTA — started only after both milter sockets exist |
|
||||
| `panel` | panel | 200 | HTTP UI + sender check + DKIM signer + journal-milter + log-tailer + rate-limit recalc; with the inbound relay on also the inbound journal milter |
|
||||
| `postfix` | root (wrapper) | 300 | MTA — started only after the milter sockets exist (OpenDKIM's, the sender check's, the DKIM signer's two, the journal milter's and, with the inbound relay on, the inbound journal milter's) |
|
||||
| `postfix-reload` | root | — | On-demand `postfix reload` (autostart off) |
|
||||
| `cert-reload` | root | 400 | Daily `postfix reload` for renewed TLS certs |
|
||||
| `cert-reload` | root | 400 | Daily: re-copies a renewed TLS certificate for Postfix and reloads it |
|
||||
| `logrotate` | root | 400 | Periodic `mail.log` rotation |
|
||||
|
||||
Start order: OpenDKIM → panel (opens journal-milter socket) → Postfix wrapper
|
||||
polls unix sockets (timeout `MILTER_WAIT_TIMEOUT`, default 30s) then
|
||||
`postfix start-fg`.
|
||||
Start order: syslog-relay → OpenDKIM → panel (opens the sender check's, the DKIM signer's and the journal-milter's sockets) → Postfix wrapper
|
||||
polls unix sockets (timeout `MILTER_WAIT_TIMEOUT`, default 30s) then runs
|
||||
`postfix start-fg` and stays its parent: on TERM it runs `postfix stop`, so a
|
||||
stop of the program or of the container stops Postfix as Postfix means it to
|
||||
be stopped (master runs in a session of its own, which supervisord's signal
|
||||
never reaches). Program `postfix` has `startsecs` = `MILTER_WAIT_TIMEOUT` + 10
|
||||
(set by the entrypoint), so the wrapper giving up on the sockets, or a Postfix
|
||||
that will not start, is an exit supervisord counts as too quick: once
|
||||
`startretries` (3) are spent, the program is FATAL. For those seconds after
|
||||
each start it is STARTING.
|
||||
|
||||
`crashexit` event listener exits the container on any managed process FATAL so
|
||||
Docker `restart: unless-stopped` recreates a broken instance.
|
||||
`crashexit` event listener exits the container when a program of the mail path
|
||||
is FATAL - it crashed before its `startsecs` four times running (the start
|
||||
and `startretries=3`) - or has
|
||||
exited unexpectedly five times in ten minutes, however long each run lasted
|
||||
(an exit after `startsecs` is EXITED and restarted, never FATAL; `2.6.0`), so
|
||||
Docker `restart: unless-stopped` starts it again - the same container, not a
|
||||
new one: the entrypoint clears what the last run left in `/run` (the milter
|
||||
sockets, the sender check's alive mark). Besides a stop, supervisord itself
|
||||
dying, or the entrypoint refusing a setting, nothing else exits the
|
||||
container. `logrotate`'s FATAL (a config logrotate would ignore),
|
||||
`postfix-reload`'s (reported to the page that asked), `cert-reload`'s and
|
||||
`syslog-relay`'s stay on Server › Health instead.
|
||||
The panel runs `postfix-reload` through supervisord's XML-RPC interface and
|
||||
waits for its exit status (`internal/supervisor`, `RunOnce`); the program
|
||||
rests EXITED after a run.
|
||||
|
||||
**Liveness:** `GET /healthz` (unauthenticated) returns 200 when opendkim,
|
||||
panel, and postfix are RUNNING; Docker `HEALTHCHECK` uses the same probe.
|
||||
panel, and postfix are RUNNING; Docker `HEALTHCHECK` uses the same probe (its
|
||||
90-second start period covers postfix's STARTING).
|
||||
|
||||
---
|
||||
|
||||
@@ -50,6 +72,7 @@ panel, and postfix are RUNNING; Docker `HEALTHCHECK` uses the same probe.
|
||||
```
|
||||
Client ──TLS+SASL──► Postfix (465 smtps, optional 587 submission)
|
||||
│
|
||||
├─► sender check (From: against the login, tempfail on failure)
|
||||
├─► OpenDKIM milter (sign, tempfail on failure)
|
||||
├─► journal-milter (send log + L2 rate limits, fail-open)
|
||||
└─► outbound MX delivery (port 25 client)
|
||||
@@ -74,10 +97,76 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
|
||||
`smtpd_tls_security_level=encrypt`.
|
||||
- **25/smtp inet** — when `INBOUND_RELAY_ENABLE=true` and/or
|
||||
`DMARC_REPORTS_ENABLE=true`. No SASL, no OpenDKIM, no journal-milter —
|
||||
`smtpd_milters` is always overridden on this service (to the antispam milter
|
||||
or to nothing), because main.cf's submission chain would otherwise let a
|
||||
signing outage defer relayed mail and would file inbound mail into the
|
||||
outbound send log under the sender's domain.
|
||||
`smtpd_milters` is always overridden on this service, because main.cf's
|
||||
submission chain would otherwise let a signing outage defer relayed mail and
|
||||
would file inbound mail into the outbound send log under the sender's
|
||||
domain. With the inbound relay on the chain is the optional antispam milter,
|
||||
then the panel's **inbound journal milter** (unix socket
|
||||
`INBOUND_JOURNAL_MILTER_SOCKET`, default
|
||||
`/run/selfpost/inbound-journal.sock`, `default_action=accept`); with DMARC
|
||||
ingest alone it is empty. Postfix hands a message to its milters one after
|
||||
another at end-of-message, each seeing what the one before added, so the
|
||||
journal milter sees only mail the filter let through and reads the filter's
|
||||
verdict headers (`X-Rspamd-Action`, `X-Spamd-Result`) off it. It writes one
|
||||
`inbound_spam_log` row per recipient at an inbound domain - a DMARC report
|
||||
address is not one - and never refuses anything
|
||||
([internal/milter/inbound.go](../internal/milter/inbound.go)). The headers
|
||||
are read only while a filter is attached, and only next to the filter's
|
||||
signature: a third header, `X-SelfPost-Filter`, the HMAC-SHA256 of the
|
||||
message's queue id under the key in `/data/antispam/filter.key`
|
||||
(`inbound.FilterKey` makes it on first start; rspamd reads it through the
|
||||
mounted directory and signs in a custom routine of `milter_headers.conf`).
|
||||
rspamd removes a sender's own copies of the three headers and writes and
|
||||
signs its own for every message it lets through: `skip_local` and
|
||||
`skip_authenticated` are off in that file, so this holds whatever the
|
||||
sender's address (rspamd 3.11.1, `src/plugins/lua/milter_headers.lua`,
|
||||
`skip_wanted()`).
|
||||
With the filter down - it is attached fail-open - a sender's own verdict
|
||||
headers carry no signature and the row is that of mail nobody judged.
|
||||
Refused mail never reaches this milter.
|
||||
Refusals come from `mail.log`: this listener logs as `postfix/inbound`
|
||||
(`syslog_name`), and the log tailer turns a `reject:` line of
|
||||
`postfix/inbound/smtpd` (Postfix's own policy) and a `milter-reject` /
|
||||
`milter-hold` / `milter-discard` line of `cleanup` (the filter's verdict,
|
||||
counted only for a queue id that listener opened) into a row, dated by the
|
||||
line, when the recipient is at an inbound domain
|
||||
([internal/logtail/inbound.go](../internal/logtail/inbound.go)). A line
|
||||
names one recipient (the last), so a message refused at end-of-message is
|
||||
one row whatever its recipient count. The tailer saves its place every five seconds,
|
||||
so after an unclean stop it reads some lines again; the lines it reads on
|
||||
start are written only if the same row is not there already. The
|
||||
journal is pruned every six hours: rows
|
||||
older than the `inbound_spam_log_retention_days` setting, then down to
|
||||
10 000.
|
||||
**Filter lists** (`inbound_spam_list`): allow and deny entries for the
|
||||
external filter - sender address, sender domain, client address or network -
|
||||
validated and normalised by `internal/inbound` (`lists.go`) and written as
|
||||
six map files under `/data/antispam/` (`ANTISPAM_DIR`), each atomically, on
|
||||
every change and on every start with the relay on. SelfPost never consults
|
||||
them. The sidecar mounts the directory and rspamd's multimap rules
|
||||
([deploy/antispam/rspamd/multimap.conf](../deploy/antispam/rspamd/multimap.conf))
|
||||
read them: a deny entry's rule carries the action `reject`, which rspamd
|
||||
sets as a pre-result (a passthrough result: the remaining filters are not
|
||||
run and the action stands whatever the score), an allow entry is a symbol
|
||||
weighing -100. Allow is
|
||||
deliberately not an `accept` action: with two pre-results rspamd keeps the
|
||||
one set first, and deny then won or lost by rule order (seen on rspamd
|
||||
3.11, date not recorded). A refusal by the deny list overrides any score,
|
||||
so deny wins whichever entries match - measured in a throwaway
|
||||
`rspamd/rspamd:3.11.1-3` with this file, 2026-10-15: the same address or
|
||||
IP on both lists, an allowed address with a denied domain or IP, and an
|
||||
allowed IP with a denied address were all `reject` (assumptions
|
||||
D-arch:121). A sender entry is matched against
|
||||
the envelope sender only (`extract_from = "smtp"`; by default rspamd takes
|
||||
a bounce's `From:` header instead - rspamd 3.11.1,
|
||||
`src/plugins/lua/multimap.lua`, the `from` callback).
|
||||
How often an entry decided a message is not stored: `Lists.Usage` reads it
|
||||
off the journal on each view of Filter lists - the rows a list decided (the
|
||||
deny rule's reply, the allow rule's symbol, which the journal milter keeps
|
||||
ahead of its six-symbol cap) matched against the entries as rspamd matches
|
||||
them. When that stops being good enough is written in
|
||||
[plans/inbound-antispam-panel.md](plans/inbound-antispam-panel.md) § Filter
|
||||
list usage.
|
||||
**Inbound relay** (`INBOUND_RELAY_ENABLE=true`): accepts only
|
||||
`relay_domains` + `relay_recipient_maps` (`reject_unauth_destination`,
|
||||
`reject_unlisted_recipient`). Maps under `/data/postfix/` (`relay_domains`,
|
||||
@@ -85,40 +174,97 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
|
||||
[internal/postfix/inbound.go](../internal/postfix/inbound.go). Domains with
|
||||
an empty upstream host are omitted from the maps. Optional
|
||||
`INBOUND_ANTISPAM_MILTER` only when inbound relay is enabled (not on
|
||||
DMARC-only port 25); default `milter_default_action` is fail-open
|
||||
(`accept`). **DMARC ingest** (`DMARC_REPORTS_ENABLE=true`): allow-listed
|
||||
DMARC-only port 25); it is attached with its own `default_action`,
|
||||
`INBOUND_ANTISPAM_MILTER_ACTION`, which is `accept` (fail-open) unless set
|
||||
to `tempfail` (main.cf's `milter_default_action` is `tempfail`).
|
||||
**DMARC ingest** (`DMARC_REPORTS_ENABLE=true`): allow-listed
|
||||
report addresses via `check_recipient_access` on `postfix/dmarc_recipients`;
|
||||
messages pipe to `dmarc-ingest` (symlink to `panel`). When both flags are
|
||||
on, one listener serves both paths; per-IP rate uses
|
||||
`INBOUND_RATE_LIMIT_MESSAGES_PER_IP` and message size uses the greater of
|
||||
`INBOUND_MESSAGE_SIZE_LIMIT` and `DMARC_MESSAGE_SIZE_LIMIT`.
|
||||
- **No open relay** — `permit_sasl_authenticated`, `reject_unauth_destination`;
|
||||
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
|
||||
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`. That holds the
|
||||
envelope sender; the `From:` header is held to the same table by the
|
||||
panel's sender check (§ Panel roles).
|
||||
- **Level-1 rate limit** — `smtpd_client_message_rate_limit` /
|
||||
`anvil_rate_time_unit` from `RATE_LIMIT_*` env vars; independent of milter.
|
||||
- **Port 25 ends DATA only at `<CR><LF>.<CR><LF>`** —
|
||||
`smtpd_forbid_bare_newline=normalize` on the `smtp/inet` listener (Postfix
|
||||
3.10.13, `src/smtpd/smtpd.c`, `receive_data_message()`, the DATA loop at
|
||||
3713: a `.` line next to a bare `<LF>` is dropped, not taken as the end); a
|
||||
bare `<LF>` elsewhere is read as `<CR><LF>`. The submission ports keep
|
||||
Postfix's default, which is the same `normalize` since Postfix 3.9
|
||||
(`src/global/mail_params.h`, `DEF_SMTPD_FORBID_BARE_LF`); the image ran
|
||||
Postfix 3.7.11 until `2.7.0`, whose default was `no`, hence the explicit
|
||||
setting on port 25.
|
||||
- **The submission ports hide the application in `Received:`** (`2.7.0`) —
|
||||
`smtpd_hide_client_session=yes` on `smtps/inet` and `submission/inet`:
|
||||
Postfix's header reads `Received: by <hostname> (Postfix) id <queue id>`,
|
||||
with the recipient when there is one, and the date - no HELO name, client
|
||||
name or address, TLS, login or protocol (Postfix 3.10.13,
|
||||
`src/smtpd/smtpd.c:3495` and `:3627-3645`). Port 25 keeps the full
|
||||
header, which RFC 5321 requires there (`proto/postconf.proto:19587-19589`).
|
||||
The queue id in the header leads to the message's row in the panel's log
|
||||
and to smtpd's `client=` line in mail.log.
|
||||
- **Chroot disabled** for all services (DNS/TLS inside container).
|
||||
- **TLS certs** — read-only mount at `TLS_CERT_FILE` / `TLS_KEY_FILE`; daily
|
||||
reload via `cert-reload`.
|
||||
- **TLS certs** — read-only mount at `TLS_CERT_FILE` / `TLS_KEY_FILE`, copied
|
||||
at start into `/etc/postfix/tls-internal`, owned by root and `0600` as
|
||||
Postfix's TLS_README advises (Postfix itself reads the key as root and does
|
||||
not check its mode), which the mount cannot give; Postfix reads the copy.
|
||||
`cert-reload` compares the mount with the copy daily; when they differ and
|
||||
the certificate and key belong together it renews the copy (each file
|
||||
renamed into place) and reloads Postfix.
|
||||
|
||||
### OpenDKIM
|
||||
|
||||
Per-domain keys under `/data/opendkim/keys`; `KeyTable` / `SigningTable` maintained
|
||||
by the panel. Socket `/run/opendkim/opendkim.sock`.
|
||||
|
||||
The server's own name (`SELFPOST_HOSTNAME`) has a key and a line in both
|
||||
tables too, without being a row of the registry
|
||||
(`domain.Service.SetServerName`): the panel's notifications are sent as
|
||||
`postmaster@` that name and signed with it. No application can use it as an
|
||||
envelope sender - the sender map is built from the registry - and since
|
||||
`2.4.0` none can have a message signed as the server by writing it into
|
||||
`From:` either: the sender check holds the header against the login (§ Sender
|
||||
check). A sending domain of the same name shares its key and selector with
|
||||
the server; deleting that domain leaves the server signed under the default
|
||||
selector, which is the same key only if that was the domain's. At its start
|
||||
the notifier makes sure the tables have the line and name the selector the
|
||||
panel shows (`EnsureServerSigned`): tables are otherwise rebuilt only when a
|
||||
domain is added or deleted.
|
||||
|
||||
### Panel binary ([cmd/panel](../cmd/panel))
|
||||
|
||||
One process, four roles:
|
||||
One process, seven roles (eight with the inbound relay on: its inbound journal
|
||||
milter, § Postfix above, port 25):
|
||||
|
||||
1. **HTTP server** — `:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse
|
||||
proxy only. On start it runs `postconf -h` once for the deferred-mail retry
|
||||
proxy only. On start it runs `postconf -h -x` once for the deferred-mail retry
|
||||
parameters (`queue_run_delay`, `minimal_backoff_time`,
|
||||
`maximal_backoff_time`, `maximal_queue_lifetime`, `bounce_queue_lifetime`,
|
||||
`delay_warning_time`) and caches the snapshot on the handlers config. The
|
||||
`delay_warning_time`), reads each as Postfix does - one number and one
|
||||
unit, the parameter's own default unit when there is none (`5d` for the
|
||||
lifetimes, so a bare `3` is three days), `bounce_queue_lifetime` capped at
|
||||
`maximal_queue_lifetime` as qmgr caps it (Postfix 3.10.13,
|
||||
`src/global/conv_time.c`, `src/qmgr/qmgr.c`) - and caches the snapshot on
|
||||
the handlers config. The
|
||||
Outbound queue box and a delivery's `deferred` / `bounced` history print those
|
||||
numbers; they never call `postconf` per request. If `postconf` is missing,
|
||||
the panel logs a warning and uses Postfix 3.x compiled-in defaults
|
||||
(`300s` / `4000s` / `5d` / `0`) with a muted note on the box.
|
||||
2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
|
||||
2. **sender check** — unix socket `SENDER_CHECK_SOCKET`, first in the chain
|
||||
and **strict** (`default_action=tempfail`): § Sender check below.
|
||||
3. **DKIM signer** (`2.8.0`, [plans/panel-dkim-signing.md](plans/panel-dkim-signing.md))
|
||||
— unix sockets `DKIM_SIGN_SOCKET` (applications' mail on 465/587, signed
|
||||
with the key of the domain the SASL login belongs to) and
|
||||
`DKIM_SIGN_LOCAL_SOCKET` (the panel's own mail, `non_smtpd_milters`,
|
||||
signed with the key of the server's name); after the sender check and
|
||||
**strict**. `relaxed/simple`, RFC 6376 § 5.4.1's headers and `From` once
|
||||
more, go-msgauth/dkim; keys and selectors from OpenDKIM's KeyTable while
|
||||
both sign (`internal/milter/sign.go`).
|
||||
4. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
|
||||
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open**
|
||||
(`default_action=accept`) so milter failure does not stop mail. Domain
|
||||
ceilings apply to every client IP; an application ceiling **overrides** the
|
||||
@@ -130,13 +276,194 @@ One process, four roles:
|
||||
this process has admitted but not yet written (`internal/milter/inflight.go`),
|
||||
so concurrent sessions cannot each spend the same last slot; a reservation
|
||||
is released at end-of-message, on ABORT, or after a 10-minute TTL.
|
||||
3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by
|
||||
5. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by
|
||||
queue-id. Send-log `queued → sent` transitions depend on this goroutine alone
|
||||
(`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)).
|
||||
4. **rate-limit recalc** — every six hours (and on demand from Domain settings and
|
||||
It also keeps the lines Postfix writes under a queue id, for the message's
|
||||
page (§ Log tailer, *Stored lines*), and counts the mail Postfix deferred
|
||||
because the sender check did not answer.
|
||||
6. **rate-limit recalc** — every six hours (and on demand from Domain settings and
|
||||
the application form), recomputes level-2 **Auto** rate limits from send-log statistics.
|
||||
7. **notifier** — writes to the panel's users when something needs them
|
||||
(§ Notifications below).
|
||||
|
||||
Milter chain in Postfix: OpenDKIM (tempfail) then journal (accept on failure).
|
||||
Milter chain in Postfix: the sender check (tempfail), the panel's DKIM
|
||||
signer (tempfail), OpenDKIM (tempfail), then the journal (accept on
|
||||
failure); until OpenDKIM leaves the image both sign every message
|
||||
(docs/plans/panel-dkim-signing.md, stage 1). Mail submitted locally with
|
||||
`sendmail(1)` — the notifier's — passes the signer's own socket and OpenDKIM
|
||||
(`non_smtpd_milters`): it is signed, it is the panel's own and so needs no
|
||||
check of whose it is, and it is not an application's mail to be journalled
|
||||
or limited. Bounces Postfix
|
||||
generates pass no milter.
|
||||
|
||||
### Sender check
|
||||
|
||||
[internal/milter/sender.go](../internal/milter/sender.go) holds a message's
|
||||
`From:` header against the application that sends it
|
||||
([plans/from-header-binding.md](plans/from-header-binding.md)). Postfix holds
|
||||
the envelope sender against the SASL login, and OpenDKIM chooses the signing
|
||||
key by the header; until `2.4.0` nothing connected the two, and an
|
||||
application could have mail signed as another domain of the server or as the
|
||||
server itself.
|
||||
|
||||
- **The rule is the envelope's.** The check reads the file Postfix reads for
|
||||
`smtpd_sender_login_maps` and makes the same lookup: the address itself is
|
||||
bound to the login, or `@domain` is. So an application set to any address of
|
||||
its domain may write any address of that domain, and one with a list only
|
||||
the listed ones; everything else is refused - another registered domain,
|
||||
the server's name, a domain the server knows nothing of. One table for
|
||||
both senders, read as Postfix reads a texthash table and looked up as
|
||||
Postfix looks it up: the address, the address without its `+extension`
|
||||
(`recipient_delimiter`, which `build/postfix-config.sh` sets for that
|
||||
reason), then `@domain` - and the **first of these keys in the table
|
||||
decides**, its owners alone: an address listed for one application is not
|
||||
open to another that holds the whole domain. Logins are compared without
|
||||
regard to case, as Postfix compares them (Postfix 3.10.13,
|
||||
`src/smtpd/smtpd_check.c`, `reject_auth_sender_login_mismatch()`, over
|
||||
`src/global/mail_addr_find.c`, `mail_addr_find_opt()`). A key written
|
||||
twice makes Postfix refuse the whole table, and the check defers then too.
|
||||
- **How the header is read.** Exactly one `From:` field (none, or more than
|
||||
one, is refused), read with OpenDKIM's own parser: libopendkim's
|
||||
`dkim_mail_parse()`, ported line for line from Debian's opendkim
|
||||
2.11.0~beta2 (`internal/milter/mailparse.go`) and tested against the C on
|
||||
two hundred thousand lines. The check asks Postfix for header values with
|
||||
their leading space, as OpenDKIM does (`SMFIP_HDR_LEADSPC`), so both read the
|
||||
same bytes. The address it returns is the one judged - the one OpenDKIM
|
||||
signs for - whatever a display name, a comment or a quoted local part
|
||||
holds; a parse failure, no address, or a second one after it is refused,
|
||||
and so is an address over 256 bytes, which OpenDKIM would match its
|
||||
`SigningTable` against cut short (`dkimf_apply_signtable()`,
|
||||
`MAXADDRESS`). Encoded words are not decoded, by either. The line must also
|
||||
be one address in RFC 5322's grammar (`net/mail`), at the domain OpenDKIM
|
||||
signs for, so that a mail program shows the address that is signed; that
|
||||
address, its local part unquoted, is what the bindings are asked about.
|
||||
- **The verdict** comes at the end of the headers: `550 5.7.1`, permanent,
|
||||
naming the line, as a reply code of the milter's own. What the client wrote
|
||||
is reduced to printable ASCII and cut before it is said back, logged or
|
||||
stored. A message with no SASL login is refused as well; the submission
|
||||
ports do not let one this far.
|
||||
- **Strict, and therefore small.** Postfix attaches the socket with
|
||||
`default_action=tempfail`: while the panel does not answer on it within the
|
||||
milter timeouts, mail is deferred, not accepted unchecked. The role
|
||||
therefore reads no database. Its two inputs are files the panel rewrites by
|
||||
rename and the mail path itself goes by; they are read again when their
|
||||
time or size has changed, and a file that cannot be read defers mail - a
|
||||
table from before would let a deleted application go on sending. The
|
||||
send-log row of a refusal (`status=rejected`, `reason=from`,
|
||||
`header_from`) is handed to another goroutine through a buffer and is not
|
||||
waited for. It is one row for the message, naming the first recipient and
|
||||
how many more there were: a row per recipient would let one refused message
|
||||
write a thousand.
|
||||
- **What it costs is counted.** The role marks itself alive every two
|
||||
seconds (the socket's path with `.alive`, under `/run`; the entrypoint
|
||||
removes it at every container start, so a restart begins without one). At
|
||||
start the time since the mark is the check's
|
||||
downtime - counted from Postfix's own start if that was later, and not at
|
||||
all when supervisord says Postfix is not running, since mail was then
|
||||
stopped for another reason. Supervisord is asked for half a minute; when it
|
||||
does not answer, the time since the mark is taken as an upper bound and the
|
||||
day is marked estimated (`sender_check_days.estimated`), which the card
|
||||
shows as ≈. The log tailer counts the mail: Postfix
|
||||
writes a warning naming the socket and then, from the same smtpd process,
|
||||
`milter-reject: … 451 4.7.1 Service unavailable` from the connection on
|
||||
(the client is told `454 4.3.0` at `AUTH` and leaves), so what is counted
|
||||
is a connection turned away, once - how many messages it held back is not
|
||||
known: a client may bring one or several, or come back for the same one.
|
||||
The reject line does not name the milter and the journal milter's
|
||||
level-2 refusal reads the same, hence the warning. A panel that is running
|
||||
and stops answering in the middle of a message (its socket accepts, nothing
|
||||
replies) is met by cleanup, not smtpd: past `DATA` it is cleanup that talks
|
||||
to the milters, and after the content timeout it writes `warning: milter
|
||||
unix:<socket>: can't read SMFIC_HEADER reply packet header` and refuses the
|
||||
message with `4.7.1` (seen on the e2e stand with the panel frozen;
|
||||
`panel_hung_mid_message_is_counted` checks it at every run). One
|
||||
cleanup process serves message after message, so each such warning is
|
||||
counted as a message. The two are counted apart (the owner, 2026-10-14):
|
||||
connections turned away in `deferred`, messages deferred mid-message in
|
||||
`messages` of `sender_check_days`, a row per UTC day kept a year, and
|
||||
Server › Health shows today, 30 days and 12 months in a column each. The
|
||||
counts also see a panel that is running and not answering, which the mark
|
||||
does not. Downtime is cut at the UTC midnights it crossed. A log read again
|
||||
after an unclean stop does not count again: the count and the place in the
|
||||
log it reached (the file's fingerprint and the offset past the line) are
|
||||
saved in one transaction, and while the tailer catches up a warning at or
|
||||
before that place in the same file is passed over - a second warning of
|
||||
the same second is not lost; in another file the time decides.
|
||||
- **Why it is not a process of its own.** Decided with the owner: measure
|
||||
first. The role has a socket of its own so that the journal stays
|
||||
fail-open, and moving it out later changes where it is started and nothing
|
||||
else.
|
||||
|
||||
### Notifications
|
||||
|
||||
[internal/notify](../internal/notify/notify.go) reports four things to the
|
||||
users of the panel: a sending domain's DNS record that stopped matching, a
|
||||
level-2 rate limit that refused mail, the TLS certificate about to expire, a
|
||||
message more than three days in the queue. The rules of when a message goes
|
||||
out are the owner's and are written in
|
||||
[plans/panel-notifications.md](plans/panel-notifications.md) § When a message
|
||||
is sent; the operator's view is guide § Notifications.
|
||||
|
||||
- **Readings.** Once an hour, starting three minutes after the process does:
|
||||
every sending domain's records through a DNS checker of the notifier's own,
|
||||
forced past the cache (`cmd/panel/notifier.go` builds the same query a
|
||||
domain's page does); the certificate file; `postqueue -j` for the arrival
|
||||
time of each queued message. Once a minute: the send log's `rejected` rows
|
||||
of the last day, through a partial index of their own.
|
||||
- **A refused row is not believed whole.** Its domain is the one in
|
||||
`MAIL FROM`, which the client typed and Postfix has not yet held against
|
||||
the login when the milter refuses; the same status is written when an
|
||||
application is refused for its client address or its `From:` line. Since
|
||||
`2.4.0` a row says which (`send_log.reason`) and only a limit's rows are
|
||||
taken; a row from before has no reason. So refusals are counted by
|
||||
the domain the login is registered under, only for logins that exist, and
|
||||
only while a level-2 limit is set on the login or its domain
|
||||
(`Store.LimitRefusalsSince`). One subject per sending domain, whichever of
|
||||
its applications are refused.
|
||||
- **State** is a row per subject in `notify_state` (`dns:<domain>`, `cert`,
|
||||
`queue`, `limit:<domain>`), which is what makes each thing be said
|
||||
once and a restart say nothing again. A DNS subject goes *pending* on the
|
||||
first bad check and *open*, with a message, on a later one at least 45
|
||||
minutes after it - a count alone would let the last check before a restart
|
||||
and the first after it stand for "an hour apart"; back the same way. The
|
||||
certificate's row remembers which threshold was said for which expiry. A
|
||||
deleted domain's `dns:` row goes at the next check; its `limit:` row stays
|
||||
until it was last said more than 48 hours ago (`CheckLimits`).
|
||||
- **Said means sent.** A problem that arises while no user can be told, or
|
||||
while Postfix does not take the message, stays unsaid and is told at a
|
||||
later check. Its ending, with nobody left to tell, is simply over.
|
||||
- **Owed to each recipient** (since `2.5.4`, `notify_addressees`): the
|
||||
subject's first message records who it was addressed to and who was
|
||||
handed a copy; while the subject is open, every later check hands it to
|
||||
those not told yet - a copy that failed, or that the ceiling held back -
|
||||
who are still recipients. A user who comes to hear of a subject only while
|
||||
it is open is not mailed about it, and its ending goes to those who were
|
||||
told. A subject opened before `2.5.4` has no such record, and its ending
|
||||
goes to every recipient, as it did. It is owed to an address: a user who
|
||||
changes their e-mail while a subject is open is a new address, told
|
||||
neither its follow-up nor its end - accepted, a subject lasts hours to
|
||||
days.
|
||||
- **The notifier's own mail is not a subject.** A notification waiting for a
|
||||
mailbox that does not answer, or its bounce coming back to
|
||||
`postmaster@<hostname>` - which receives no mail - is left out of the
|
||||
queue check.
|
||||
- **A ceiling** of 60 messages an hour stands over all of it.
|
||||
- **No information is not a finding.** A lookup that did not answer, a queue
|
||||
that cannot be listed, a certificate that cannot be read move no state.
|
||||
- **Recipients** come from the store at the moment of sending: global users
|
||||
for everything, domain users for subjects that are a domain's; only users
|
||||
with an e-mail and their own switch on. One message per recipient, so users
|
||||
do not see each other's addresses.
|
||||
- **The instance switch** (settings key `notifications`, on unless turned
|
||||
off) stops the checks themselves, not only the mail: no state moves while
|
||||
it is off.
|
||||
- **Sending** is `sendmail -i -f postmaster@<hostname> -- <address>` as the
|
||||
unprivileged panel user, which is a member of `postdrop` and so can write
|
||||
the maildrop directory without the setgid bit `no-new-privileges` forbids.
|
||||
A message that could not be handed over leaves the state where it was and
|
||||
is tried at the next check. The text names the page to open and has no
|
||||
link: the panel does not know its own address.
|
||||
|
||||
### Log tailer and `mail.log` rotation
|
||||
|
||||
@@ -154,44 +481,197 @@ Rotation uses rename + `postfix reload`
|
||||
([build/logrotate-mail.conf](../build/logrotate-mail.conf)), not `copytruncate` —
|
||||
the latter can drop `status=sent` lines and leave send-log rows stuck at
|
||||
`queued`. After rename, logrotate runs `create 0640 postfix selfpost` (postlogd
|
||||
recreates the file lazily on first write as mode `0600`, which the unprivileged
|
||||
panel user cannot read). `follow()` drains the old inode once more before
|
||||
switching descriptors; the panel treats a missing log file as an empty tail, not
|
||||
an error.
|
||||
recreates the file on its first line after the reload, as root and mode
|
||||
`0600`, which the unprivileged panel user cannot read). `follow()` drains the
|
||||
old inode once more before switching descriptors, and keeps it open and reads
|
||||
it for a minute after: postlogd keeps writing to the renamed file until the
|
||||
reload makes it exit, seconds later (Postfix 3.10.13, src/postlogd/postlogd.c;
|
||||
src/master/dgram_server.c, `dgram_server_abort()`). The panel treats a missing
|
||||
log file as an empty tail, not an error.
|
||||
|
||||
**Delivery lines are read at the head of the line.** The pattern that turns a
|
||||
line into a row's status (`deliveryRe`) is matched after the timestamp, the
|
||||
host name and `program[pid]:`, at the queue id - like the stored lines' and the
|
||||
inbound journal's. The tail of a line is the far end's words (a reply, a HELO
|
||||
name), and a pattern that could start there let a stranger's HELO name file a
|
||||
delivery (audit 2026-10-12, M12).
|
||||
|
||||
**Read offset is persisted** (`logtail_state` table, in the baseline schema): the
|
||||
tailer stores its position plus a fingerprint of the log's first 512 bytes, and
|
||||
on start resumes from it, parsing the tail written while the panel was down. If
|
||||
the fingerprint no longer matches (rotated or recreated in the meantime) it reads
|
||||
the current file from the start; re-parsing lines is harmless because
|
||||
`UpdateStatus` writes the same status onto the same row. Only a first-ever start,
|
||||
with nothing stored, begins at end-of-file, so installing the panel does not
|
||||
replay a pre-existing log.
|
||||
tailer stores its position plus a fingerprint of the log - the hash of its first
|
||||
512 bytes and the file's device and inode, so two logs that begin alike are two
|
||||
files and a renamed file keeps its fingerprint - and on start resumes from it,
|
||||
parsing the tail written while the panel was down. If the fingerprint no longer
|
||||
matches, the log was rotated in the meantime: the tailer first opens
|
||||
`mail.log.1` (left plain by `delaycompress`), and if that is the file the saved
|
||||
place belongs to it reads it from there to its end, then the new file from its
|
||||
start - what Postfix wrote between the stop and the rotation is not lost. A
|
||||
`mail.log.1` that is another file (rotated twice) is logged with the time from
|
||||
which nothing was read. Re-parsing lines is harmless because `UpdateStatus`
|
||||
writes the same status onto the same row. Only a first-ever start, with nothing
|
||||
stored, begins at end-of-file, so installing the panel does not replay a
|
||||
pre-existing log.
|
||||
|
||||
**A refusal's line, and a refusal the panel did not write.** A message refused
|
||||
for its `From:` line has a queue id (cleanup gives one before the headers are
|
||||
judged); the sender check writes it on the row, and the message's page shows
|
||||
the lines stored under it - only those of the minutes around the refusal, since
|
||||
Postfix frees the id at once. A message the journal milter refused at
|
||||
`MAIL FROM` has none: the tailer reads smtpd's `NOQUEUE: milter-reject: MAIL
|
||||
from <client>[<address>]: …; from=<…> … sasl_username=<login>` - the login is
|
||||
Postfix's last field on an authenticated session (Postfix 3.10.13,
|
||||
`src/smtpd/smtpd.c`, `smtpd_whatsup()`), and a line without one is no
|
||||
application's - and puts it on the nearest refused row of that client
|
||||
address, login and sender within two minutes
|
||||
(`send_log.refusal_line`; the milter writes `send_log.client_ip`), retrying
|
||||
for 30 s when the row is not written yet; a line already on a row goes on no
|
||||
other. The sender check writes its
|
||||
row through a buffer after answering 550; a panel stopped in between wrote
|
||||
none. The tailer remembers each queue id's login from smtpd's `client=` line,
|
||||
and 30 s after cleanup's `milter-reject: END-OF-MESSAGE … 5.7.1 From:` line it
|
||||
writes the row from the line if no refused row of that id exists
|
||||
(`send_log.recovered`, shown on the page). cleanup's line names one
|
||||
recipient, the message's last (`src/cleanup/cleanup_milter.c`), so a row
|
||||
written from it has that one, which its page says. It reads from its saved offset, so
|
||||
a kill is covered as well as a clean stop.
|
||||
|
||||
**Expiry.** A message Postfix gives up on at the end of its queue lifetime has
|
||||
no line per recipient: qmgr writes one line for the message, `<id>:
|
||||
from=<...>, status=expired, returned to sender` (`force-expired` after
|
||||
`postsuper -e`; Postfix 3.10.13, `src/qmgr/qmgr_active.c`,
|
||||
`qmgr_active_done_25_generic()`), and the recipients' last lines say
|
||||
`deferred`. The tailer reads that line and closes the message's open rows -
|
||||
each recipient's newest, `queued` or `deferred` - as `bounced`.
|
||||
|
||||
**Queue reconcile** is the backstop for what the log cannot explain at all: a
|
||||
row still `queued` more than two minutes after it was accepted, whose queue id
|
||||
`postqueue -p` no longer lists, is marked `bounced` (swept every five minutes,
|
||||
row still open - `queued`, or `deferred` and waiting - more than two minutes
|
||||
after it was accepted, whose queue id `postqueue -p` no longer lists, is
|
||||
marked `bounced` with the reason `reconcile` (swept every five minutes,
|
||||
[internal/logtail](../internal/logtail/logtail.go),
|
||||
[postfix.QueueIDs](../internal/postfix/queue.go)). Postfix having dropped the
|
||||
message means nothing more will ever be reported about it, so the row can only
|
||||
be closed on an assumption, and it is closed as a failure because a delivery the
|
||||
panel cannot evidence must not be shown as one. Three things keep the sweep from
|
||||
panel cannot evidence must not be shown as one. What keeps the sweep from
|
||||
guessing where it need not: it starts only after the tailer has read to
|
||||
end-of-file once (on a restart the log itself holds the answer), the two-minute
|
||||
grace covers messages merely in flight, and a `postqueue` that cannot be read
|
||||
leaves every row untouched rather than closing them all. Now that the log
|
||||
survives the container, reaching this path means the lines are gone for good —
|
||||
rotated past fourteen files while the panel was down, or deleted.
|
||||
end-of-file once (on a restart the log itself holds the answer); the two-minute
|
||||
grace covers messages merely in flight; a `postqueue` that cannot be read, or
|
||||
whose listing is over the 4 MiB cap, leaves every row untouched rather than
|
||||
closing them all or those past the cut; and the close, by the row's id, is
|
||||
conditional on the row still being open, so a final status the log wrote in
|
||||
the meantime stands. A deferred row is the sweep's too because Postfix can
|
||||
drop a deferred message without a final line for its recipients: at expiry
|
||||
before `2.5.2` read that line, and with `postsuper -d`, which logs only
|
||||
`removed`. The listing's `#` - a message forced to expire - is read as part
|
||||
of the id's flags (`src/postqueue/showq_compat.c`, `showq_message()`).
|
||||
|
||||
**Which row a delivery line is.** The journal milter writes a row's
|
||||
recipient as Postfix will log it: the address the client gave, its quoted
|
||||
local part unquoted and quoted again only where it needs to be (`"ab"@x`
|
||||
becomes `ab@x`, `"a b"@x` stays; Postfix 3.10.13, `tok822_parse.c`,
|
||||
`quote_822_local.c`), case kept, and a recipient given twice - in any case -
|
||||
once, since cleanup keeps one (`cleanup_out_recipient.c`, `been_here()`
|
||||
folding case). A delivery line names it in `orig_to=` when Postfix rewrote
|
||||
the address, in `to=` otherwise (`log_adhoc.c` writes `orig_to=` only then),
|
||||
and is matched without regard to case. It updates only the newest row of its
|
||||
queue id and recipient: Postfix reuses a short queue id once the message has
|
||||
left the queue, and an old row must not be rewritten. `UpdateStatus`'s transaction
|
||||
starts with a write, so a commit by the `dmarc-ingest` process in between
|
||||
cannot fail it with `database is locked`.
|
||||
Now that the log survives the container, reaching this path should mean the
|
||||
lines are gone for good — rotated past fourteen files while the panel was down,
|
||||
or deleted — or that the tailer was not reading.
|
||||
|
||||
Whether the sweep earns its place is measured, not argued (audit 2026-10-12,
|
||||
H3). `delivery_log_days` (migration `0007`) keeps, per UTC day for a year:
|
||||
`reconcile_closed`, the rows the sweep closed; `reconcile_missed`, those of
|
||||
them whose final line - sent, bounced, or the message's expiry - was in
|
||||
`mail.log` or `mail.log.1` when the sweep closed them - the tailer did not
|
||||
read a line that was there, which is what a lost line looks like (it is never
|
||||
read late; a deferred line is not one, a deferred row has it by definition); `reconcile_contradicted`, those
|
||||
a delivery line read later gave a status - the log's status replaces the
|
||||
sweep's and the row's reason becomes `reconcile-contradicted`, so a row counts
|
||||
once and can be found; `tailer_blind_seconds`, the time `follow()` could not
|
||||
read `mail.log` (the file gone, reopening it failing; stretches under three
|
||||
seconds - a rotation's tick between rename and create, which loses nothing -
|
||||
are not counted); and `queued_over_lifetime`, the rows still open (`queued`
|
||||
or `deferred`; `queued` only before `2.5.2`) past the queue lifetime plus a
|
||||
maximal backoff, as the day's last sweep saw them - rows
|
||||
stuck although the sweep runs (mail on hold, or a queue that could not be
|
||||
listed). What removing the sweep would leave queued is closed − missed −
|
||||
contradicted: the rows nothing in the log ever resolved. No page shows the
|
||||
table: it is read off a copy of the database. The rule, written before the
|
||||
data: `reconcile_closed = 0` for a month → remove the sweep; closed, but
|
||||
neither missed nor contradicted → keep it; missed or contradicted → the tailer
|
||||
loses or delays lines - fix the tailer (the two deferred steps: the rotation
|
||||
window, pausing the sweep while blind) and keep the sweep; if what remains
|
||||
after that is never resolved by the log, mark such rows `lost` or remove the
|
||||
sweep. Decided after thirty days, or sooner if `reconcile_missed` or
|
||||
`reconcile_contradicted` is ever non-zero.
|
||||
|
||||
**Stored lines** (`send_log_lines`, since 2.2.0;
|
||||
[internal/logtail/lines.go](../internal/logtail/lines.go)). A send-log row
|
||||
lives for the retention window, ninety days by default, and `mail.log` for a
|
||||
day before it is rotated; a message's page (`/outbound/log/{id}`) shows what
|
||||
Postfix wrote about it, so the tailer keeps those lines in the database as it
|
||||
reads them:
|
||||
|
||||
- **What is kept:** a line headed by a queue id — after the timestamp, the
|
||||
host name and `program[pid]:` — with that id, the line's own time and the
|
||||
line as written, cut to 4096 bytes. The id is read from the head of the line
|
||||
only: the tail is the far end's text. `NOQUEUE` and `warning` stand in the
|
||||
same place and are not ids; the pattern accepts Postfix's short form
|
||||
(capital hexadecimal) and its long form (no vowels).
|
||||
- **Port-25 mail is left out.** A queue id opened by `postfix/inbound/smtpd`
|
||||
is a stranger's message and never gets a send-log row; none of its lines
|
||||
are stored, up to qmgr's `removed` or until another listener opens a
|
||||
message under the id (4096 such ids are remembered). What a stranger can
|
||||
make Postfix log is therefore not written to the database at his rate.
|
||||
- **No check for a row at write time.** A message's first lines (`client=`,
|
||||
`message-id=`) are logged before the journal milter writes its row at
|
||||
end-of-message, so the tailer cannot ask whether the id is in the send log.
|
||||
Lines that never get a row — bounces Postfix generates, port-25 mail met in
|
||||
mid-flight after a restart — are kept for a while.
|
||||
- **Written per pass, not per line:** the lines of one pass over the log go
|
||||
in one transaction (`InsertSendLogLines`), 500 at most, before the read
|
||||
offset is saved. A commit per line would be a disk sync per line on the
|
||||
connection the journal milter shares.
|
||||
- **Retention is one rule:** a line older than an hour whose queue id no
|
||||
send-log row carries is deleted (`PruneSendLogLines`), at start and after
|
||||
every six-hourly send-log sweep. That removes the lines of rows the sweep
|
||||
deleted and the lines that never had a row. The send log is asked once per
|
||||
queue id; the statement still walks the table, with the connection taken
|
||||
meanwhile. Measured on the test server (2 cores, 2026-10-15) with 1.37
|
||||
million lines, 120 days at 1 900 messages a day: 0.4 s when there is
|
||||
nothing to delete, 1.9 s to delete 30 days' lines (342 000) after a gap -
|
||||
under `busy_timeout`'s 5 s, and the longest a second process's write waited
|
||||
meanwhile was 1.8 s (assumptions D-S7, D-arch:516).
|
||||
- **Which lines are a row's.** Postfix reuses a queue id once the message has
|
||||
left the queue, and a row outlives that by months. `SendLogLines` gives a row
|
||||
the lines of its queue id dated from one hour before the row was written to
|
||||
the queue lifetime (`maximal_queue_lifetime`, which `bounce_queue_lifetime`
|
||||
cannot exceed) plus a day after it. That limits a chance reuse of
|
||||
the id to those days; it does not exclude it. A message with several
|
||||
recipients is several rows under one id, and each row's page shows all of
|
||||
the message's lines.
|
||||
- **Read twice, stored once.** The read offset is saved every few seconds, so
|
||||
after an unclean stop some lines are read again; while the tailer is
|
||||
catching up at start it writes a line only if the same line is not there.
|
||||
- **Not stored:** what the tailer never read — the tail of a file rotated away
|
||||
while the panel was down.
|
||||
- **Size:** about six lines of about 150 bytes per message — more than the row
|
||||
itself (the live installation, 2026-10-15: 6.0 lines per queue id, 146 bytes
|
||||
on average, over its first 18 lines; assumptions D-arch:532). The table is in the database and therefore in a full backup, which
|
||||
`log/` is not ([security.md](security.md) § Backup and domain export).
|
||||
|
||||
**Two one-shot reads** sit beside the follow loop and are unrelated to it, both
|
||||
serving panel pages on request: `TailLines` (the last *n* lines, for
|
||||
`/server/log`) and `QueueLines` (the lines carrying one queue-id, for
|
||||
`/outbound/log/{id}`). `QueueLines` scans a bounded tail of the current file —
|
||||
`/server/log`) and `QueueLines` (the lines carrying one queue-id). `QueueLines`
|
||||
is what a message's page falls back to when no lines are stored for its row —
|
||||
a message accepted before 2.2.0. It scans a bounded tail of the current file —
|
||||
finding a message's lines means reading rather than seeking — and matches the id
|
||||
anchored on the character before it, since queue ids are hexadecimal runs and a
|
||||
shorter one is regularly the tail of a longer one. Send-log rows outlive the log
|
||||
(retention 90 days, rotation 14 files), so an empty result is the expected end
|
||||
state for an older message and the page reports it as such, not as a failure.
|
||||
shorter one is regularly the tail of a longer one. For such a message an empty
|
||||
result is the expected end state once the log has rotated, and the page reports
|
||||
it as such, not as a failure.
|
||||
|
||||
---
|
||||
|
||||
@@ -226,6 +706,7 @@ only reads (the delete confirmation at `GET …/delete` renders a form).
|
||||
| Outbound › Log, Queue | `GET /outbound/log` · `/outbound/log/fragment` · `/outbound/log/{id}` · `GET /outbound/queue` · `/outbound/queue/fragment` |
|
||||
| Outbound › DMARC reports | `GET /outbound/dmarc` · `…/dmarc/domains/{id}` · `…/dmarc/reports/{id}` — registered only with `DMARC_REPORTS_ENABLE=true` |
|
||||
| Inbound › Domains | `GET, POST /inbound/domains` · `/inbound/domains/{id}` · `POST …/dns-recheck`, `…/upstream`, `…/recipients` · `GET, POST …/delete` — registered only with `INBOUND_RELAY_ENABLE=true` |
|
||||
| Inbound › Log, Filter lists | `GET /inbound/log` · `GET, POST /inbound/filter-lists` · `POST /inbound/filter-lists/{id}/delete` — registered only with `INBOUND_RELAY_ENABLE=true` |
|
||||
| Server | `GET /server/health` (+ `/fragment`) · `POST /server/health/recheck`, `/server/health/reload` · `GET /server/log` (+ `/fragment`) · `GET, POST /server/backup` · `POST /server/backup/import` · `/server/users`, `…/new`, `…/{uid}`, `…/{uid}/delete` · `GET, POST /server/settings` · `GET /server/components` |
|
||||
| Account | `GET /account` · `POST /account/profile`, `/account/password` |
|
||||
| User menu | `GET /help` · `POST /logout` (public mux) |
|
||||
@@ -269,7 +750,11 @@ is not.
|
||||
([handlers/handlers_inbound.go](../internal/web/handlers/handlers_inbound.go)):
|
||||
`requireInbound` (the list), `requireInboundDomain` (one domain — page,
|
||||
upstream, recipients, DNS re-check) and `requireInboundGlobal` (add, delete).
|
||||
All three answer 404 alike.
|
||||
All three answer 404 alike. The **Inbound log** (`/inbound/log`) is open to
|
||||
anyone who reaches an inbound domain (`requireInbound`) and shows the rows
|
||||
of their domains only; **Filter lists** (`/inbound/filter-lists`) are the
|
||||
instance's and the global role's (`requireInboundGlobal`)
|
||||
([handlers/handlers_inbound_log.go](../internal/web/handlers/handlers_inbound_log.go)).
|
||||
- **The DMARC hub** is open to anyone with outbound reach for the reports of
|
||||
their own domains; the *Ingest* box (server-wide statistics) is rendered for
|
||||
the global role only.
|
||||
@@ -293,9 +778,15 @@ a refresh cannot differ. HTMX refreshes them every 5 s while the operator is
|
||||
active on the page, every 30 s when the tab is visible but idle, and not at all
|
||||
when it is hidden — scheduled in `panel.js` via `data-poll`, not
|
||||
`hx-trigger="every …"`, because a trigger filter is evaluated with `new
|
||||
Function`, which the CSP forbids. Polling does not extend the session idle
|
||||
timeout (only non-`HX-Request` `GET`s and mutating requests count as
|
||||
activity). The Outbound queue's retry-policy box is outside the polled region:
|
||||
Function`, which the CSP forbids. The next refresh is scheduled when a
|
||||
request of the box ends, however it ended: a refresh that failed - an error
|
||||
answer, the panel restarting, the network gone - is tried again after twice
|
||||
the wait, doubling with each failure in a row up to a minute, and the wait
|
||||
is back to normal after one that works. A request without a session answers
|
||||
htmx with `HX-Redirect: /login` (status 401), and htmx loads the sign-in
|
||||
page in place of the whole page; a browser gets the 303 as before. Polling
|
||||
does not extend the session idle timeout (only non-`HX-Request` `GET`s and
|
||||
mutating requests count as activity). The Outbound queue's retry-policy box is outside the polled region:
|
||||
it is the start-up `postconf -h` snapshot (see [Panel
|
||||
binary](#panel-binary-cmdpanel)), not a live re-read.
|
||||
|
||||
@@ -341,8 +832,9 @@ is one layout and one way to render.
|
||||
`panel.css` (the mockups' stylesheet, rule for rule), `htmx.min.js`, `panel.js`,
|
||||
the IBM Plex subset and the logo files. Served with a content-derived `ETag`,
|
||||
and `/static/` does not list its files. The kit page
|
||||
`GET /server/components` renders every partial in every state from fixtures
|
||||
(global role only).
|
||||
`GET /server/components` renders every partial in every state from the data
|
||||
of `KitPage` ([kit.go](../internal/web/view/kit.go), production code that the
|
||||
guard tests render too; global role only).
|
||||
|
||||
**Guard tests** hold the design in place; they run in `go test ./...` and CI.
|
||||
They read their rules from the accepted mockups
|
||||
@@ -369,7 +861,7 @@ are in [plans/panel-redesign.md](plans/panel-redesign.md) § Enforcement.
|
||||
on every response: `Content-Security-Policy: default-src 'self'; object-src
|
||||
'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'`,
|
||||
`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`,
|
||||
`Referrer-Policy: no-referrer`, and `Strict-Transport-Security` when the cookie
|
||||
`Referrer-Policy: same-origin`, and `Strict-Transport-Security` when the cookie
|
||||
is `Secure`. Because the policy has no exemptions, no template carries an
|
||||
inline `<script>`, an event-handler attribute or `style="…"` — the page's
|
||||
behaviour is `htmx.min.js` plus `panel.js` (copy and *fill* buttons, the
|
||||
@@ -379,8 +871,13 @@ with scripts off. htmx is told not to inject its indicator stylesheet
|
||||
(`htmx-config` meta), which the CSP would otherwise have to allow.
|
||||
|
||||
State-changing requests (every non-`GET`/`HEAD`/`OPTIONS`) pass `originAllowed`
|
||||
first: `Sec-Fetch-Site` must say `same-origin`, or `Origin` must name the
|
||||
panel's host; a request with neither header is let through (an accepted risk).
|
||||
first: `Sec-Fetch-Site` must say `same-origin`, or, where the browser does not
|
||||
send it (an older one, or a current one over plain HTTP at a name other than
|
||||
`localhost`), `Origin` must name the panel's host - which a form of the panel
|
||||
carries under its `Referrer-Policy: same-origin`, the page's own origin (under
|
||||
`no-referrer` it would be `null` and refused); a request with neither header
|
||||
is let through (an accepted risk). A refusal logs the `Sec-Fetch-Site`,
|
||||
`Origin` and `Host` it compared.
|
||||
There are no CSRF tokens — see [security.md](security.md).
|
||||
|
||||
### Errors shown to users
|
||||
@@ -498,9 +995,9 @@ flowchart TB
|
||||
dmarcSvc --> postfix
|
||||
```
|
||||
|
||||
The four roles inside the `panel` process (HTTP server, journal-milter,
|
||||
log-tailer, rate-limit recalc) share one binary and one SQLite handle on
|
||||
purpose — see
|
||||
The roles inside the `panel` process (HTTP server, sender check,
|
||||
journal-milter, log-tailer, rate-limit recalc, notifier) share one binary and
|
||||
one SQLite handle on purpose — see
|
||||
[Panel binary](#panel-binary-cmdpanel) for why, and *Persistence* below for the
|
||||
single-connection trade-off that follows from it.
|
||||
|
||||
@@ -524,23 +1021,26 @@ single-connection trade-off that follows from it.
|
||||
| `postfix/dmarc_relay_domains` | Domains accepted for DMARC report delivery |
|
||||
| `postfix/queue/` | Postfix transit mail (deferred/active); survives container recreate |
|
||||
| `log/mail.log` | Postfix delivery log + rotated copies (excluded from backups) |
|
||||
| `manifest.json` | Backup version stamp (consumed on restore) |
|
||||
| `manifest.json` | Backup version stamp; removed by the first start after a restore once its Resync is done |
|
||||
| `contents.json` | A backup's last entry: the size and SHA-256 of each file under `data/`, compared by the first start after a restore before it opens the database (renamed `contents.checked.json` once compared, removed with the manifest) |
|
||||
|
||||
Not in `/data`: TLS certificates for the panel (reverse-proxy mount) — though
|
||||
full backups also archive the operator's `./certs` PEM files when present.
|
||||
|
||||
### Database
|
||||
|
||||
The schema is one embedded baseline, `internal/store/migrations/0001_init.sql`,
|
||||
applied by `store.Open` into an empty file; `PRAGMA user_version` is `1` and
|
||||
The schema is an embedded baseline, `internal/store/migrations/0001_init.sql`,
|
||||
and the migrations after it (`0002` to `0011` today; the next is `0012`),
|
||||
applied by `store.Open` in file order; `PRAGMA user_version` counts the files
|
||||
applied and
|
||||
`PRAGMA application_id` is `0x53503230` ("SP20"). There is no 1.x migration
|
||||
chain. **2.0 starts from an empty data directory:** a database with a schema
|
||||
version but without the 2.x `application_id` — what every 1.x file looks like —
|
||||
is refused at start with `ErrForeignSchema` and left untouched, as is one whose
|
||||
version is newer than the build knows. A restored 1.x backup fails the same
|
||||
way. Domains are carried across with a domain export, whose format is not the
|
||||
database. Later schema changes are new files (`0002_*.sql`, …); once `2.0.0` is
|
||||
released the baseline is never edited. History and the per-table list:
|
||||
database. A schema change is a new file; the baseline has not been edited
|
||||
since `2.0.0`. History and the per-table list:
|
||||
[schema-migrations.md](schema-migrations.md).
|
||||
|
||||
The tables that carry the panel's model:
|
||||
@@ -565,14 +1065,26 @@ The tables that carry the panel's model:
|
||||
(send-log retention). Nothing that belongs to a user.
|
||||
- `applications` (with `auth_ip_restrict` / `auth_allowed_ips`),
|
||||
`application_addresses`, `rate_limits` (no `allowed_ips` column), `send_log`,
|
||||
`send_log_lines` (what Postfix logged under a queue id — § Log tailer),
|
||||
`logtail_state`, `inbound_domains`, `inbound_transports`,
|
||||
`inbound_recipients`, `dmarc_reports`, `dmarc_report_records`.
|
||||
`inbound_recipients`, `inbound_spam_log` (port 25's decisions, § Postfix),
|
||||
`inbound_spam_list` (the filter lists), `dmarc_reports`,
|
||||
`dmarc_report_records`, `notify_state` and `notify_addressees` (what the
|
||||
notifier has said and to whom, § Notifications), `sender_check_days` (the
|
||||
sender check's downtime and the mail it deferred, per UTC day) and
|
||||
`delivery_log_days` (the queue sweep's measurements, § Log tailer).
|
||||
|
||||
The panel is the only writer; SQLite runs in WAL mode on one connection (see
|
||||
[Panel binary](#panel-binary-cmdpanel)), which is why a full backup snapshots
|
||||
with `VACUUM INTO` instead of copying the file.
|
||||
Two processes write: the panel, on one connection, and `dmarc-ingest`, which
|
||||
Postfix starts per report. SQLite runs in WAL mode (see
|
||||
[Panel binary](#panel-binary-cmdpanel)) and allows one write transaction at a
|
||||
time; every transaction that may write takes the lock at its `BEGIN`
|
||||
(`_txlock=immediate`, internal/store), so the other process waits for it
|
||||
within `busy_timeout`, and a schema migration is read and applied under that
|
||||
lock, once. A full backup snapshots with `VACUUM INTO`, which gives a
|
||||
consistent copy of a live database, instead of copying the file.
|
||||
|
||||
**Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90);
|
||||
**Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90), which
|
||||
the stored log lines follow;
|
||||
`mail.log` via logrotate (14 rotated files, check every 6h, rename +
|
||||
`postfix reload` in `postrotate` — see § Log tailer above).
|
||||
|
||||
@@ -582,13 +1094,14 @@ under `/data`), `docker-compose.yml`, `.env`, and `certs/` when present;
|
||||
version check on restore. Requires the project directory mounted read-only at
|
||||
`SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` in the default compose file). On the
|
||||
first successful boot after restore, the panel runs one **Resync** — OpenDKIM's
|
||||
tables, Postfix's sender map, inbound relay maps (when
|
||||
tables, Postfix's sender map, inbound relay maps and the filter lists (when
|
||||
`INBOUND_RELAY_ENABLE=true`), and DMARC maps (when `DMARC_REPORTS_ENABLE=true`)
|
||||
are re-derived from SQLite and both daemons are reloaded, so drift between the
|
||||
extracted archive and the database is healed before mail flows. Manual
|
||||
`POST /server/health/reload` (the *Reload configuration* button on Server ›
|
||||
Health) resyncs OpenDKIM, the sender map, and
|
||||
inbound maps only — not DMARC maps. Stopped-container
|
||||
Health) writes the same files in the same order (`HandleReload`; before
|
||||
`2.5.4` it left out the filter lists and the DMARC maps), and on a failure
|
||||
stops at that step and names it on the page. Stopped-container
|
||||
`tar` of `./data` alone remains possible for state-only copies (see guide).
|
||||
|
||||
**Optional encryption** of the two secret-bearing downloads
|
||||
@@ -625,12 +1138,18 @@ unsupported rather than as a missing doc:
|
||||
- **Panel paths and tuning:** `SELFPOST_DATA_DIR` (`/data`), `SELFPOST_DB_PATH`
|
||||
(`/data/selfpost.db`), `SELFPOST_SETUP_TOKEN_FILE`
|
||||
(`/data/setup-token`), `PANEL_HTTP_ADDR` (`:8080`),
|
||||
`JOURNAL_MILTER_SOCKET` (`/run/selfpost/journal.sock`), `MAIL_LOG`
|
||||
`JOURNAL_MILTER_SOCKET` (`/run/selfpost/journal.sock`),
|
||||
`SENDER_CHECK_SOCKET` (`/run/selfpost/sender.sock`),
|
||||
`DKIM_SIGN_SOCKET` (`/run/selfpost/sign.sock`) and `DKIM_SIGN_LOCAL_SOCKET`
|
||||
(`/run/selfpost/sign-local.sock` — the DKIM signer's, read by the panel,
|
||||
`build/postfix-config.sh` and `build/postfix-wrapper.sh`, which must agree),
|
||||
`INBOUND_JOURNAL_MILTER_SOCKET` (`/run/selfpost/inbound-journal.sock` — the
|
||||
inbound journal milter's socket, read by the panel, `build/postfix-config.sh`
|
||||
and `build/postfix-wrapper.sh`, which must agree), `MAIL_LOG`
|
||||
(`/data/log/mail.log` — read by the panel and written by Postfix, so a change
|
||||
here has to be matched in `build/postfix-config.sh`),
|
||||
`PANEL_COOKIE_SECURE` (`true`), `OPENDKIM_SOCKET`
|
||||
(`/run/opendkim/opendkim.sock`), `OPENDKIM_DIR` (`/data/opendkim`),
|
||||
`DKIM_SELECTOR_DEFAULT` (`selfpost`), `SASL_DB_PATH`
|
||||
`PANEL_COOKIE_SECURE` (`true`), `DKIM_DIR` (`/data/dkim` — the keys and
|
||||
the selector table), `DKIM_SELECTOR_DEFAULT` (`selfpost`), `SASL_DB_PATH`
|
||||
(`/data/sasl/sasldb2`), `SASL_REALM` (defaults to `SELFPOST_HOSTNAME`),
|
||||
`POSTFIX_DIR` (`/data/postfix`), `POSTFIX_SENDER_LOGIN_MAPS`
|
||||
(`/data/postfix/sender_login_maps` — read by Postfix config only; the panel
|
||||
@@ -647,12 +1166,15 @@ unsupported rather than as a missing doc:
|
||||
`POSTFIX_DMARC_RELAY_DOMAINS` (`/data/postfix/dmarc_relay_domains`),
|
||||
`POSTFIX_QUEUE_DIR` (`/data/postfix/queue` — set in `build/postfix-config.sh`),
|
||||
`SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` — operator project directory for
|
||||
full backups; mount `.:/selfpost-deploy:ro` in compose).
|
||||
full backups; mount `.:/selfpost-deploy:ro` in compose), `ANTISPAM_DIR`
|
||||
(`/data/antispam` — the filter lists and the filter's key, which the spam
|
||||
filter's container mounts).
|
||||
- **Milter and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`),
|
||||
`MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`),
|
||||
`MILTER_WAIT_TIMEOUT` (`30` seconds).
|
||||
- **Background maintenance:** `TLS_RELOAD_INTERVAL_SECONDS` (`86400` — daily
|
||||
`postfix reload` to pick up renewed certificates),
|
||||
- **Background maintenance:** `TLS_RELOAD_INTERVAL_SECONDS` (`86400` — how
|
||||
often a renewed certificate is looked for; Postfix is reloaded only when one
|
||||
is found),
|
||||
`LOGROTATE_INTERVAL_SECONDS` (`21600` — check `mail.log` rotation every six
|
||||
hours; logrotate keeps 14 rotated files on a daily schedule, and each
|
||||
rotation triggers `postfix reload`).
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>example.org · Outbound domains · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
|
||||
<link rel="stylesheet" href="../shared/brand.css">
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="sp-bar">
|
||||
<div class="sp-wrap">
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a class="sp-brand" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a></li>
|
||||
</ul>
|
||||
<ul class="sp-sections">
|
||||
<li><a href="status.html">Overview</a></li>
|
||||
<li><a href="domain.html" aria-current="true">Outbound</a></li>
|
||||
<li><a href="#">Inbound</a></li>
|
||||
<li><a href="#">Server</a></li>
|
||||
</ul>
|
||||
<ul class="sp-user">
|
||||
<li>
|
||||
<details class="dropdown">
|
||||
<summary>admin</summary>
|
||||
<ul dir="rtl">
|
||||
<li><a href="#">Account</a></li>
|
||||
<li><a href="#">Help</a></li>
|
||||
<li><a href="login.html">Sign out</a></li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<div class="sp-pages">
|
||||
<div class="sp-wrap">
|
||||
<a href="#" aria-current="true">Domains</a>
|
||||
<a href="#">Log</a>
|
||||
<a href="#">Queue</a>
|
||||
<a href="#">DMARC reports</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="sp-wrap">
|
||||
<div class="sp-head">
|
||||
<div>
|
||||
<p class="sp-kicker"><a href="#">Outbound domains</a> /</p>
|
||||
<h1>example.org</h1>
|
||||
</div>
|
||||
<small>1 284 messages in 30 days · peak 96 msg/h · average 1.8 msg/h</small>
|
||||
</div>
|
||||
|
||||
<section class="sp-sheet">
|
||||
<header><span class="sp-no">01</span><h2>Connection</h2><small>The same for every domain</small></header>
|
||||
<div class="sp-body sp-cols">
|
||||
<dl class="sp-kv">
|
||||
<dt>Server</dt>
|
||||
<dd><span class="sp-value">mail.example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
|
||||
</dl>
|
||||
<dl class="sp-kv">
|
||||
<dt>Ports</dt>
|
||||
<dd><span class="sp-value">465 — SSL/TLS (implicit)<br>587 — STARTTLS (submission)</span></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="sp-sheet">
|
||||
<header>
|
||||
<span class="sp-no">02</span><h2>DNS records</h2>
|
||||
<small>Checked 3 min ago</small>
|
||||
<form method="post" action="#"><button type="submit" class="outline secondary sp-btn-sm">Re-check</button></form>
|
||||
</header>
|
||||
|
||||
<div class="sp-record">
|
||||
<div class="sp-record-head"><h3>DKIM</h3><span class="sp-tag ok">published</span><small>Not a secret · selector <code>sp2026</code></small></div>
|
||||
<dl class="sp-kv">
|
||||
<dt>Host</dt><dd><span class="sp-value">sp2026._domainkey.example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
|
||||
<dt>Type</dt><dd><span class="sp-value plain">TXT</span></dd>
|
||||
<dt>Value</dt><dd><span class="sp-value">v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3kqV0mJd8e2f1Yb7Qw5uT9sLr4nHc6pZaXo1vKe0yWm2gBt8RjN5dCq3hUf7lPs9aEi4oMz6xTb1kGv0nYw…IDAQAB</span><button type="button" class="outline sp-copy">Copy</button></dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="sp-record">
|
||||
<div class="sp-record-head"><h3>SPF</h3><span class="sp-tag fail">mismatch</span><small>Merge into the existing record — never publish a second one</small></div>
|
||||
<dl class="sp-kv">
|
||||
<dt>Host</dt><dd><span class="sp-value">example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
|
||||
<dt>Type</dt><dd><span class="sp-value plain">TXT</span></dd>
|
||||
<dt>Value</dt><dd><span class="sp-value">v=spf1 ip4:203.0.113.25 ~all</span><button type="button" class="outline sp-copy">Copy</button></dd>
|
||||
<dt>In DNS now</dt><dd><span class="sp-value">v=spf1 include:_spf.google.com ~all</span></dd>
|
||||
</dl>
|
||||
<p class="sp-note fail">The published record does not authorize 203.0.113.25. Add <code>ip4:203.0.113.25</code> before <code>~all</code>.</p>
|
||||
</div>
|
||||
|
||||
<div class="sp-record">
|
||||
<div class="sp-record-head"><h3>DMARC</h3><span class="sp-tag ok">published</span><small>Report address from <a href="#">Settings</a></small></div>
|
||||
<dl class="sp-kv">
|
||||
<dt>Host</dt><dd><span class="sp-value">_dmarc.example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
|
||||
<dt>Type</dt><dd><span class="sp-value plain">TXT</span></dd>
|
||||
<dt>Value</dt><dd><span class="sp-value">v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
|
||||
</dl>
|
||||
<p class="sp-note"><code>p=none</code> does not affect delivery. Tighten to <code>quarantine</code>, then <code>reject</code>, once the <a href="#">reports</a> look clean.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="sp-sheet">
|
||||
<header>
|
||||
<span class="sp-no">03</span><h2>Applications</h2>
|
||||
<small>SASL logins that may send as this domain</small>
|
||||
<a href="#" role="button" class="sp-btn-sm">Add application</a>
|
||||
</header>
|
||||
<table class="sp-apps">
|
||||
<thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="sp-login">prod-server</td>
|
||||
<td>*@example.org</td>
|
||||
<td>1 102 msg · peak 96/h</td>
|
||||
<td><span class="sp-tag ok">200 / h</span></td>
|
||||
<td><a class="sp-link-btn" href="#">Edit</a><a class="sp-link-btn" href="#">New password</a><a class="sp-link-btn danger" href="#">Delete</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="sp-login">alerts</td>
|
||||
<td>alerts@example.org, noc@example.org</td>
|
||||
<td>182 msg · peak 14/h</td>
|
||||
<td><span class="sp-tag">domain</span> <span class="sp-tag ok">2 IPs</span></td>
|
||||
<td><a class="sp-link-btn" href="#">Edit</a><a class="sp-link-btn" href="#">New password</a><a class="sp-link-btn danger" href="#">Delete</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="sp-sheet sp-ledger">
|
||||
<header><span class="sp-no">04</span><h2>Domain settings</h2><small>Rarely changed</small></header>
|
||||
<details>
|
||||
<summary><strong>DMARC reports</strong><span class="sp-muted">Same as Settings — dmarc@mail.example.org</span><span class="sp-tag">default</span></summary>
|
||||
<div class="sp-detail"><p class="sp-muted">The form opens here.</p></div>
|
||||
</details>
|
||||
<details>
|
||||
<summary><strong>Rate limit</strong><span class="sp-muted">Auto ×3 — 288 messages / hour, recalculated yesterday</span><span class="sp-tag ok">active</span></summary>
|
||||
<div class="sp-detail"><p class="sp-muted">The form opens here.</p></div>
|
||||
</details>
|
||||
<details>
|
||||
<summary><strong>Export</strong><span class="sp-muted">DKIM key and applications as one file, optionally encrypted</span><span></span></summary>
|
||||
<div class="sp-detail"><p class="sp-muted">The form opens here.</p></div>
|
||||
</details>
|
||||
<details>
|
||||
<summary><strong>Delete domain</strong><span class="sp-muted">Removes the DKIM key and both applications</span><span></span></summary>
|
||||
<div class="sp-detail"><p class="sp-muted">The confirmation opens here.</p></div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<footer class="sp-foot">
|
||||
SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,37 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign in · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css">
|
||||
<link rel="stylesheet" href="../shared/brand.css">
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="sp-signin">
|
||||
<div class="sp-signin-card">
|
||||
<img src="../../selfpost-stamp.svg" alt="SelfPost">
|
||||
<section class="sp-sheet">
|
||||
<header><span class="sp-no">→</span><h2>Sign in</h2></header>
|
||||
<div class="sp-body">
|
||||
<form method="post" action="status.html">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" autocomplete="username" required>
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<p class="sp-tagline">YOUR SMTP. YOUR RULES.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-wrap sp-foot">
|
||||
© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,136 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Overview · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
|
||||
<link rel="stylesheet" href="../shared/brand.css">
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="sp-bar">
|
||||
<div class="sp-wrap">
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a class="sp-brand" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a></li>
|
||||
</ul>
|
||||
<ul class="sp-sections">
|
||||
<li><a href="status.html" aria-current="page">Overview</a></li>
|
||||
<li><a href="domain.html">Outbound</a></li>
|
||||
<li><a href="#">Inbound</a></li>
|
||||
<li><a href="#">Server</a></li>
|
||||
</ul>
|
||||
<ul class="sp-user">
|
||||
<li>
|
||||
<details class="dropdown">
|
||||
<summary>admin</summary>
|
||||
<ul dir="rtl">
|
||||
<li><a href="#">Account</a></li>
|
||||
<li><a href="#">Help</a></li>
|
||||
<li><a href="login.html">Sign out</a></li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="sp-wrap">
|
||||
<div class="sp-head">
|
||||
<div>
|
||||
<p class="sp-kicker">mail.example.org</p>
|
||||
<h1>Overview</h1>
|
||||
</div>
|
||||
<small>Updated 14:02:31 UTC · refreshes every 10 s</small>
|
||||
</div>
|
||||
|
||||
<section class="sp-sheet sp-verdict">
|
||||
<div class="sp-postmark warn"><span>SELFPOST</span><b>WARN</b><span>1 OF 6 CHECKS</span></div>
|
||||
<div>
|
||||
<h2>Mail is flowing. One thing needs attention.</h2>
|
||||
<p>The TLS certificate expires in 12 days and has not renewed yet. Everything else is healthy.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="sp-sheet sp-ledger">
|
||||
<header><span class="sp-no">01</span><h2>Checks</h2><small>Open a row for its detail</small></header>
|
||||
|
||||
<details>
|
||||
<summary><strong>Processes</strong><span class="sp-muted">5 of 5 running</span><span class="sp-tag ok">ok</span></summary>
|
||||
<div class="sp-detail">
|
||||
<table>
|
||||
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>postfix</td><td><span class="sp-tag ok">running</span></td><td class="sp-muted">pid 41, uptime 18 days</td></tr>
|
||||
<tr><td>opendkim</td><td><span class="sp-tag ok">running</span></td><td class="sp-muted">pid 39, uptime 18 days</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><strong>TLS certificate</strong><span class="sp-muted">Expires in 12 days — renewal has not run</span><span class="sp-tag warn">warn</span></summary>
|
||||
<div class="sp-detail">
|
||||
<dl class="sp-kv">
|
||||
<dt>Expires</dt><dd><span class="sp-value">2026-10-03 09:14 UTC</span></dd>
|
||||
<dt>Names</dt><dd><span class="sp-value">mail.example.org</span></dd>
|
||||
</dl>
|
||||
<p class="sp-note">Renewal normally runs 30 days before expiry. Check that port 80 is reachable and look for <code>acme</code> lines in the <a href="#">system log</a>.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Machine</strong><span class="sp-muted">CPU 12 % · memory 41 % · ↓ 18 kB/s ↑ 44 kB/s</span><span class="sp-tag ok">ok</span></summary>
|
||||
<div class="sp-detail">
|
||||
<table>
|
||||
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>CPU</td><td><meter value="12" min="0" max="100" low="70" high="90" optimum="10"></meter>12 %</td><td class="sp-muted">2 cores, load 0.21</td></tr>
|
||||
<tr><td>Memory</td><td><meter value="41" min="0" max="100" low="70" high="90" optimum="10"></meter>41 %</td><td class="sp-muted">812 MB of 1.9 GB</td></tr>
|
||||
<tr><td>Network</td><td>↓ 18 kB/s · ↑ 44 kB/s</td><td class="sp-muted">eth0: 2.1 GB in, 6.4 GB out</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Queues</strong><span class="sp-muted">Outbound empty · inbound empty</span><span class="sp-tag ok">ok</span></summary>
|
||||
<div class="sp-detail"><p>No messages are waiting. Open the <a href="#">outbound queue</a> or the <a href="#">inbound queue</a>.</p></div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Milter sockets</strong><span class="sp-muted">2 of 2 answering</span><span class="sp-tag ok">ok</span></summary>
|
||||
<div class="sp-detail">
|
||||
<table>
|
||||
<thead><tr><th>Milter</th><th>State</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>opendkim</td><td><span class="sp-tag ok">ok</span></td><td class="sp-muted">inet:127.0.0.1:8891</td></tr>
|
||||
<tr><td>journal-milter</td><td><span class="sp-tag ok">ok</span></td><td class="sp-muted">unix:/run/selfpost/journal.sock</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Hostname and reverse DNS</strong><span class="sp-muted">mail.example.org ⇄ 203.0.113.25</span><span class="sp-tag ok">ok</span></summary>
|
||||
<div class="sp-detail">
|
||||
<dl class="sp-kv">
|
||||
<dt>Hostname</dt><dd><span class="sp-value">mail.example.org</span></dd>
|
||||
<dt>Lookup</dt><dd><span class="sp-value">mail.example.org → 203.0.113.25<br>203.0.113.25 → mail.example.org</span></dd>
|
||||
</dl>
|
||||
<form method="post" action="#"><button type="submit" class="outline secondary sp-btn-sm">Re-check DNS</button></form>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<footer class="sp-foot">
|
||||
SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,206 +0,0 @@
|
||||
/* Direction A — "Blank" (the postal form). Pico CSS v2 carries every generic
|
||||
control (type, buttons, inputs, tables, details, dropdown); this file maps
|
||||
the brand onto Pico's variables and adds only what is SelfPost's own:
|
||||
the two-row header, the sheet, numbered form sections, the postmark and
|
||||
the DNS record block. */
|
||||
|
||||
/* :root[...] rather than [data-theme=light] alone: Pico declares its light
|
||||
palette on :root:not([data-theme=dark]), which a bare attribute selector
|
||||
loses to. */
|
||||
:root[data-theme=light] {
|
||||
--pico-font-family-sans-serif: var(--sp-sans);
|
||||
--pico-font-family-monospace: var(--sp-mono);
|
||||
--pico-font-family: var(--sp-sans);
|
||||
--pico-border-radius: 2px;
|
||||
--pico-spacing: .9rem;
|
||||
--pico-typography-spacing-vertical: .9rem;
|
||||
--pico-form-element-spacing-vertical: .5rem;
|
||||
--pico-form-element-spacing-horizontal: .75rem;
|
||||
|
||||
--pico-background-color: var(--sp-paper);
|
||||
--pico-color: var(--sp-ink);
|
||||
--pico-muted-color: var(--sp-muted);
|
||||
--pico-muted-border-color: var(--sp-rule);
|
||||
--pico-h1-color: var(--sp-ink); --pico-h2-color: var(--sp-ink);
|
||||
--pico-h3-color: var(--sp-ink); --pico-h4-color: var(--sp-ink);
|
||||
|
||||
--pico-primary: var(--sp-brick);
|
||||
--pico-primary-background: var(--sp-brick);
|
||||
--pico-primary-border: var(--sp-brick);
|
||||
--pico-primary-underline: rgb(122 59 46 / .35);
|
||||
--pico-primary-hover: var(--sp-brick-dark);
|
||||
--pico-primary-hover-background: var(--sp-brick-dark);
|
||||
--pico-primary-hover-border: var(--sp-brick-dark);
|
||||
--pico-primary-hover-underline: var(--sp-brick-dark);
|
||||
--pico-primary-focus: rgb(122 59 46 / .25);
|
||||
--pico-primary-inverse: #fff;
|
||||
|
||||
--pico-secondary: var(--sp-ink-2);
|
||||
--pico-secondary-background: var(--sp-ink-2);
|
||||
--pico-secondary-border: var(--sp-ink-2);
|
||||
--pico-secondary-hover: var(--sp-ink);
|
||||
--pico-secondary-hover-background: var(--sp-ink);
|
||||
--pico-secondary-hover-border: var(--sp-ink);
|
||||
--pico-secondary-focus: rgb(18 22 28 / .2);
|
||||
--pico-secondary-inverse: #fff;
|
||||
|
||||
--pico-form-element-background-color: #fff;
|
||||
--pico-form-element-border-color: var(--sp-rule-strong);
|
||||
--pico-form-element-active-border-color: var(--sp-brick);
|
||||
--pico-form-element-focus-color: rgb(122 59 46 / .25);
|
||||
--pico-table-border-color: var(--sp-rule);
|
||||
--pico-code-background-color: var(--sp-well);
|
||||
--pico-code-color: var(--sp-ink);
|
||||
--pico-dropdown-background-color: #fff;
|
||||
--pico-dropdown-border-color: var(--sp-rule);
|
||||
--pico-dropdown-hover-background-color: var(--sp-brick-tint);
|
||||
}
|
||||
|
||||
/* Pico grows the root font with the viewport (up to 131%). An ops panel wants
|
||||
one size everywhere. */
|
||||
:root { font-size: 15px; }
|
||||
body { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
h1 { font-size: 1.7rem; font-weight: 600; margin: 0; }
|
||||
small, .sp-muted { color: var(--sp-muted); }
|
||||
code, .sp-mono { font-family: var(--sp-mono); font-size: .88em; }
|
||||
|
||||
/* ---- header: sections on the first row, the section's pages on the second */
|
||||
.sp-wrap { width: 100%; max-width: 72rem; margin-inline: auto; padding-inline: 1.5rem; }
|
||||
.sp-bar { background: var(--sp-sheet); border-bottom: 1px solid var(--sp-rule); }
|
||||
.sp-bar nav { align-items: center; }
|
||||
.sp-bar nav li { padding-block: .45rem; }
|
||||
.sp-brand img { height: 46px; width: auto; display: block; }
|
||||
.sp-sections a {
|
||||
font-family: var(--sp-mono); font-size: .8rem; font-weight: 600;
|
||||
letter-spacing: .09em; text-transform: uppercase; text-decoration: none;
|
||||
color: var(--sp-muted); padding: .5rem .8rem; margin: 0; border-radius: 0;
|
||||
}
|
||||
.sp-sections a:hover { color: var(--sp-ink); }
|
||||
.sp-sections a[aria-current] { color: var(--sp-brick); box-shadow: inset 0 -3px 0 var(--sp-brick); }
|
||||
.sp-user summary { font-size: .9rem; }
|
||||
.sp-pages { background: var(--sp-paper); border-bottom: 1px solid var(--sp-rule); }
|
||||
.sp-pages .sp-wrap { display: flex; gap: 1.6rem; }
|
||||
.sp-pages a { color: var(--sp-muted); text-decoration: none; padding: .6rem 0; font-size: .95rem; }
|
||||
.sp-pages a:hover { color: var(--sp-ink); }
|
||||
.sp-pages a[aria-current] { color: var(--sp-ink); font-weight: 600; box-shadow: inset 0 -2px 0 var(--sp-ink); }
|
||||
|
||||
/* ---- the sheet: one column, one width, every page */
|
||||
main.sp-wrap { flex: 1; padding-block: 1.8rem 2.5rem; }
|
||||
.sp-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.4rem; }
|
||||
.sp-head .sp-kicker { font-family: var(--sp-mono); font-size: .75rem; letter-spacing: .1em; text-transform: uppercase; color: var(--sp-muted); margin: 0 0 .15rem; }
|
||||
.sp-head .sp-kicker a { color: inherit; }
|
||||
.sp-head-actions { display: flex; gap: .5rem; align-items: center; }
|
||||
.sp-head-actions > * { margin: 0; }
|
||||
.sp-sheet { background: var(--sp-sheet); border: 1px solid var(--sp-rule); margin-bottom: 1.4rem; }
|
||||
.sp-sheet > header {
|
||||
display: flex; align-items: baseline; gap: .8rem; padding: .7rem 1.2rem;
|
||||
border-bottom: 1px solid var(--sp-rule);
|
||||
}
|
||||
.sp-sheet > header .sp-no { font-family: var(--sp-mono); font-size: .75rem; color: var(--sp-brick); font-weight: 600; }
|
||||
.sp-sheet > header h2 {
|
||||
font-family: var(--sp-mono); font-size: .8rem; font-weight: 600; letter-spacing: .1em;
|
||||
text-transform: uppercase; margin: 0; flex: 1;
|
||||
}
|
||||
.sp-sheet > header small { font-size: .85rem; }
|
||||
.sp-sheet > .sp-body { padding: 1.1rem 1.2rem; }
|
||||
.sp-sheet > .sp-body > :last-child { margin-bottom: 0; }
|
||||
.sp-foot { color: var(--sp-muted); font-size: .8rem; border-top: 1px solid var(--sp-rule); padding-block: 1rem; }
|
||||
.sp-foot a { color: inherit; }
|
||||
|
||||
/* ---- status tag and the postmark */
|
||||
.sp-tag {
|
||||
display: inline-block; font-family: var(--sp-mono); font-size: .72rem; font-weight: 600;
|
||||
letter-spacing: .08em; text-transform: uppercase; padding: .1rem .5rem;
|
||||
border: 1px solid var(--sp-idle-rule); background: var(--sp-idle-bg); color: var(--sp-idle);
|
||||
border-radius: 2px; white-space: nowrap; text-decoration: none;
|
||||
}
|
||||
.sp-tag.ok { color: var(--sp-ok); background: var(--sp-ok-bg); border-color: var(--sp-ok-rule); }
|
||||
.sp-tag.warn { color: var(--sp-warn); background: var(--sp-warn-bg); border-color: var(--sp-warn-rule); }
|
||||
.sp-tag.fail { color: var(--sp-fail); background: var(--sp-fail-bg); border-color: var(--sp-fail-rule); }
|
||||
|
||||
.sp-verdict { display: flex; align-items: center; gap: 1.5rem; padding: 1.3rem 1.4rem; }
|
||||
.sp-verdict h2 { margin: 0 0 .2rem; font-size: 1.35rem; font-weight: 600; }
|
||||
.sp-verdict p { margin: 0; color: var(--sp-muted); }
|
||||
.sp-postmark {
|
||||
flex: none; width: 5.6rem; height: 5.6rem; border-radius: 50%;
|
||||
border: 2px solid currentColor; outline: 1px solid currentColor; outline-offset: 3px;
|
||||
display: grid; place-content: center; text-align: center; transform: rotate(-9deg);
|
||||
font-family: var(--sp-mono); font-weight: 600; letter-spacing: .08em; line-height: 1.15;
|
||||
color: var(--sp-ok);
|
||||
}
|
||||
.sp-postmark.warn { color: var(--sp-warn); }
|
||||
.sp-postmark.fail { color: var(--sp-fail); }
|
||||
.sp-postmark b { font-size: 1.25rem; display: block; }
|
||||
.sp-postmark span { font-size: .55rem; display: block; }
|
||||
|
||||
/* ---- ledger: one row per check, the row opens into its detail */
|
||||
.sp-ledger details { margin: 0; padding: 0; border-bottom: 1px solid var(--sp-rule); }
|
||||
.sp-ledger details:last-child { border-bottom: 0; }
|
||||
.sp-ledger summary {
|
||||
display: grid; grid-template-columns: 13rem 1fr auto 1.2rem; gap: 1rem; align-items: center;
|
||||
padding: .75rem 1.2rem; margin: 0; color: var(--sp-ink);
|
||||
}
|
||||
.sp-ledger summary::after { grid-column: 4; margin: 0; float: none; }
|
||||
.sp-ledger summary:hover { background: var(--sp-paper); }
|
||||
.sp-ledger summary strong { font-weight: 600; }
|
||||
.sp-ledger summary span.sp-muted { font-size: .93rem; }
|
||||
.sp-ledger .sp-detail { padding: .2rem 1.2rem 1.1rem 15.2rem; }
|
||||
.sp-ledger .sp-detail table { margin: 0; font-size: .93rem; }
|
||||
.sp-ledger .sp-detail > :last-child { margin-bottom: 0; }
|
||||
.sp-ledger meter { width: 9rem; height: .7rem; vertical-align: middle; margin-right: .5rem; }
|
||||
|
||||
/* ---- label / value / copy: the DNS record block and connection settings */
|
||||
.sp-record { padding: 1.1rem 1.2rem; border-bottom: 1px solid var(--sp-rule); }
|
||||
.sp-record:last-child { border-bottom: 0; }
|
||||
.sp-record-head { display: flex; align-items: center; gap: .7rem; margin-bottom: .7rem; }
|
||||
.sp-record-head h3 { margin: 0; font-size: 1.05rem; font-weight: 600; }
|
||||
.sp-record-head small { margin-left: auto; }
|
||||
.sp-kv { display: grid; grid-template-columns: 6.5rem 1fr; gap: .45rem 1rem; align-items: start; margin: 0; }
|
||||
.sp-kv dt {
|
||||
font-family: var(--sp-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase;
|
||||
color: var(--sp-muted); padding-top: .5rem; margin: 0;
|
||||
}
|
||||
.sp-kv dd { margin: 0; display: flex; gap: .4rem; align-items: stretch; min-width: 0; }
|
||||
.sp-value {
|
||||
flex: 1; min-width: 0; font-family: var(--sp-mono); font-size: .86rem; line-height: 1.5;
|
||||
background: var(--sp-well); border: 1px solid var(--sp-rule); padding: .4rem .65rem;
|
||||
overflow-wrap: anywhere; border-radius: 2px;
|
||||
}
|
||||
.sp-value.plain { background: none; border-color: transparent; padding-left: 0; font-family: var(--sp-sans); font-size: .95rem; }
|
||||
.sp-copy {
|
||||
flex: none; align-self: flex-start; height: 2.15rem; margin: 0; padding: 0 .7rem; font-size: .8rem;
|
||||
--pico-background-color: #fff; --pico-border-color: var(--sp-rule-strong); --pico-color: var(--sp-ink-2);
|
||||
}
|
||||
.sp-copy:hover { --pico-background-color: var(--sp-paper); --pico-border-color: var(--sp-ink-2); --pico-color: var(--sp-ink); }
|
||||
.sp-note { margin: .7rem 0 0 7.5rem; font-size: .9rem; color: var(--sp-muted); }
|
||||
.sp-note.fail { color: var(--sp-fail); }
|
||||
.sp-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); gap: 0 2.4rem; }
|
||||
|
||||
/* ---- applications */
|
||||
.sp-apps { margin: 0; font-size: .95rem; }
|
||||
.sp-apps td, .sp-apps th { padding-inline: 1.2rem .4rem; vertical-align: middle; }
|
||||
.sp-apps th { font-family: var(--sp-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); font-weight: 400; }
|
||||
.sp-apps td:last-child { text-align: right; padding-right: 1.2rem; white-space: nowrap; }
|
||||
.sp-apps .sp-login { font-family: var(--sp-mono); font-weight: 600; }
|
||||
.sp-link-btn { font-size: .9rem; margin-left: .9rem; }
|
||||
.sp-link-btn.danger { color: var(--sp-fail); --pico-underline: rgb(180 35 24 / .35); text-decoration-color: rgb(180 35 24 / .35); }
|
||||
.sp-btn-sm { padding: .35rem .8rem; font-size: .9rem; margin: 0; width: auto; }
|
||||
|
||||
/* ---- sign-in */
|
||||
.sp-signin { flex: 1; display: grid; place-content: center; padding: 2rem 1.5rem; }
|
||||
.sp-signin-card { width: 23rem; max-width: 100%; }
|
||||
.sp-signin-card img { display: block; width: 15rem; height: auto; margin: 0 auto 1.6rem; }
|
||||
.sp-signin-card .sp-body { padding: 1.4rem 1.4rem 1.2rem; }
|
||||
.sp-signin-card button { margin-bottom: 0; }
|
||||
.sp-tagline { text-align: center; font-family: var(--sp-mono); font-size: .75rem; letter-spacing: .16em; color: var(--sp-muted); margin-top: 1.2rem; }
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.sp-ledger summary { grid-template-columns: 1fr auto 1.2rem; }
|
||||
.sp-ledger summary span.sp-muted { grid-column: 1 / -1; grid-row: 2; }
|
||||
.sp-ledger summary::after { grid-column: 3; }
|
||||
.sp-ledger .sp-detail { padding-left: 1.2rem; }
|
||||
.sp-kv { grid-template-columns: 1fr; gap: .15rem; }
|
||||
.sp-kv dt { padding-top: .5rem; }
|
||||
.sp-note { margin-left: 0; }
|
||||
.sp-pages .sp-wrap { overflow-x: auto; white-space: nowrap; }
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>example.org · Outbound domains · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.5.1/dist/css/tabler.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
|
||||
<link rel="stylesheet" href="../shared/brand.css">
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a>
|
||||
<div class="navbar-collapse collapse show">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item"><a class="nav-link" href="status.html"><span class="nav-link-icon"><i class="ti ti-activity-heartbeat"></i></span><span class="nav-link-title">Overview</span></a></li>
|
||||
|
||||
<li class="sp-nav-label">Outbound</li>
|
||||
<li class="nav-item active"><a class="nav-link" href="domain.html"><span class="nav-link-icon"><i class="ti ti-world-upload"></i></span><span class="nav-link-title">Domains</span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-list-details"></i></span><span class="nav-link-title">Log</span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-stack-2"></i></span><span class="nav-link-title">Queue</span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-report-analytics"></i></span><span class="nav-link-title">DMARC reports</span></a></li>
|
||||
|
||||
<li class="sp-nav-label">Inbound</li>
|
||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-world-download"></i></span><span class="nav-link-title">Domains</span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-list-details"></i></span><span class="nav-link-title">Log</span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-stack-2"></i></span><span class="nav-link-title">Queue</span></a></li>
|
||||
|
||||
<li class="sp-nav-label">Server</li>
|
||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-file-text"></i></span><span class="nav-link-title">System log</span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-archive"></i></span><span class="nav-link-title">Backup</span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-users"></i></span><span class="nav-link-title">Users</span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-settings"></i></span><span class="nav-link-title">Settings</span></a></li>
|
||||
</ul>
|
||||
<ul class="navbar-nav sp-nav-foot">
|
||||
<li class="sp-nav-user">Signed in as admin</li>
|
||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-user-circle"></i></span><span class="nav-link-title">Account</span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-help"></i></span><span class="nav-link-title">Help</span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="login.html"><span class="nav-link-icon"><i class="ti ti-logout"></i></span><span class="nav-link-title">Sign out</span></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="page-wrapper">
|
||||
<div class="page-header">
|
||||
<div class="container-xl">
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle"><a href="#" class="text-reset">Outbound domains</a> /</div>
|
||||
<h2 class="page-title">example.org <span class="badge bg-red-lt ms-2">DNS: 1 mismatch</span></h2>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="btn-list">
|
||||
<form method="post" action="#"><button type="submit" class="btn"><i class="ti ti-refresh me-1"></i>Re-check DNS</button></form>
|
||||
<a href="#" class="btn btn-primary"><i class="ti ti-plus me-1"></i>Add application</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-body">
|
||||
<div class="container-xl">
|
||||
<div class="row row-cards">
|
||||
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3 class="card-title">DNS records</h3><div class="card-actions text-secondary small">Checked 3 min ago</div></div>
|
||||
|
||||
<div class="card-body sp-record">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<h4 class="m-0 me-2">DKIM</h4><span class="badge bg-green-lt">published</span>
|
||||
<span class="ms-auto text-secondary small">Not a secret · selector <code>sp2026</code></span>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-9">
|
||||
<label class="form-label">Host / name</label>
|
||||
<div class="input-group"><input class="form-control" readonly value="sp2026._domainkey.example.org"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Type</label>
|
||||
<input class="form-control" readonly value="TXT">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Value to publish</label>
|
||||
<div class="input-group"><textarea class="form-control" rows="3" readonly>v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3kqV0mJd8e2f1Yb7Qw5uT9sLr4nHc6pZaXo1vKe0yWm2gBt8RjN5dCq3hUf7lPs9aEi4oMz6xTb1kGv0nYw…IDAQAB</textarea><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body sp-record">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<h4 class="m-0 me-2">SPF</h4><span class="badge bg-red-lt">mismatch</span>
|
||||
<span class="ms-auto text-secondary small">Merge into the existing record — never publish a second one</span>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-9">
|
||||
<label class="form-label">Host / name</label>
|
||||
<div class="input-group"><input class="form-control" readonly value="example.org"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Type</label>
|
||||
<input class="form-control" readonly value="TXT">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Value to publish</label>
|
||||
<div class="input-group"><input class="form-control" readonly value="v=spf1 ip4:203.0.113.25 ~all"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">In DNS now</label>
|
||||
<input class="form-control" readonly value="v=spf1 include:_spf.google.com ~all">
|
||||
<div class="text-danger small mt-2">The published record does not authorize 203.0.113.25. Add <code>ip4:203.0.113.25</code> before <code>~all</code>.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body sp-record">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<h4 class="m-0 me-2">DMARC</h4><span class="badge bg-green-lt">published</span>
|
||||
<span class="ms-auto text-secondary small">Report address from <a href="#">Settings</a></span>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-9">
|
||||
<label class="form-label">Host / name</label>
|
||||
<div class="input-group"><input class="form-control" readonly value="_dmarc.example.org"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Type</label>
|
||||
<input class="form-control" readonly value="TXT">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Value to publish</label>
|
||||
<div class="input-group"><input class="form-control" readonly value="v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h3 class="card-title">Connection</h3></div>
|
||||
<div class="card-body">
|
||||
<div class="datagrid">
|
||||
<div class="datagrid-item"><div class="datagrid-title">Server</div><div class="datagrid-content font-monospace">mail.example.org</div></div>
|
||||
<div class="datagrid-item"><div class="datagrid-title">Ports</div><div class="datagrid-content font-monospace">465 SSL/TLS<br>587 STARTTLS</div></div>
|
||||
</div>
|
||||
<p class="text-secondary small mt-3 mb-0">The same for every domain. Authenticate with an application login.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h3 class="card-title">Sent, 30 days</h3></div>
|
||||
<div class="card-body">
|
||||
<div class="datagrid">
|
||||
<div class="datagrid-item"><div class="datagrid-title">Messages</div><div class="datagrid-content h2 m-0">1 284</div></div>
|
||||
<div class="datagrid-item"><div class="datagrid-title">Peak</div><div class="datagrid-content h2 m-0">96 <span class="text-secondary fs-5">/h</span></div></div>
|
||||
<div class="datagrid-item"><div class="datagrid-title">Average</div><div class="datagrid-content h2 m-0">1.8 <span class="text-secondary fs-5">/h</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h3 class="card-title">Domain settings</h3></div>
|
||||
<div class="list-group list-group-flush">
|
||||
<a href="#" class="list-group-item list-group-item-action d-flex align-items-center"><div><div>DMARC reports</div><div class="text-secondary small">Same as Settings</div></div><i class="ti ti-chevron-right ms-auto text-secondary"></i></a>
|
||||
<a href="#" class="list-group-item list-group-item-action d-flex align-items-center"><div><div>Rate limit <span class="badge bg-green-lt ms-1">active</span></div><div class="text-secondary small">Auto ×3 — 288 messages / hour</div></div><i class="ti ti-chevron-right ms-auto text-secondary"></i></a>
|
||||
<a href="#" class="list-group-item list-group-item-action d-flex align-items-center"><div><div>Export</div><div class="text-secondary small">Key and applications as one file</div></div><i class="ti ti-chevron-right ms-auto text-secondary"></i></a>
|
||||
<a href="#" class="list-group-item list-group-item-action d-flex align-items-center text-danger"><div>Delete domain</div><i class="ti ti-chevron-right ms-auto"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3 class="card-title">Applications</h3><div class="card-actions text-secondary small">SASL logins that may send as this domain</div></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th class="w-1"></th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="font-monospace fw-semibold">prod-server</td><td>*@example.org</td><td class="text-secondary">1 102 msg · peak 96/h</td>
|
||||
<td><span class="badge bg-green-lt">200 / h</span></td>
|
||||
<td><div class="btn-list flex-nowrap"><a href="#" class="btn btn-sm">Edit</a><a href="#" class="btn btn-sm">New password</a><a href="#" class="btn btn-sm btn-ghost-danger">Delete</a></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-monospace fw-semibold">alerts</td><td>alerts@example.org, noc@example.org</td><td class="text-secondary">182 msg · peak 14/h</td>
|
||||
<td><span class="badge bg-secondary-lt">domain</span> <span class="badge bg-green-lt">2 IPs</span></td>
|
||||
<td><div class="btn-list flex-nowrap"><a href="#" class="btn btn-sm">Edit</a><a href="#" class="btn btn-sm">New password</a><a href="#" class="btn btn-sm btn-ghost-danger">Delete</a></div></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="footer footer-transparent">
|
||||
<div class="container-xl sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,36 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign in · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.5.1/dist/css/tabler.min.css">
|
||||
<link rel="stylesheet" href="../shared/brand.css">
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
</head>
|
||||
<body class="d-flex flex-column">
|
||||
<div class="page page-center">
|
||||
<div class="container container-tight py-4">
|
||||
<div class="text-center mb-4"><img class="sp-signin-mark" src="../../selfpost-stamp.svg" alt="SelfPost"></div>
|
||||
<div class="card card-md">
|
||||
<div class="card-body">
|
||||
<h2 class="h2 text-center mb-4">Sign in</h2>
|
||||
<form method="post" action="status.html">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="username">Username</label>
|
||||
<input class="form-control" id="username" name="username" autocomplete="username" required>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label" for="password">Password</label>
|
||||
<input class="form-control" id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<div class="form-footer"><button type="submit" class="btn btn-primary w-100">Sign in</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center sp-tagline mt-4">YOUR SMTP. YOUR RULES.</div>
|
||||
<div class="text-center sp-foot mt-3">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,168 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Overview · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.5.1/dist/css/tabler.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
|
||||
<link rel="stylesheet" href="../shared/brand.css">
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a>
|
||||
<div class="navbar-collapse collapse show">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item active"><a class="nav-link" href="status.html"><span class="nav-link-icon"><i class="ti ti-activity-heartbeat"></i></span><span class="nav-link-title">Overview</span></a></li>
|
||||
|
||||
<li class="sp-nav-label">Outbound</li>
|
||||
<li class="nav-item"><a class="nav-link" href="domain.html"><span class="nav-link-icon"><i class="ti ti-world-upload"></i></span><span class="nav-link-title">Domains</span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-list-details"></i></span><span class="nav-link-title">Log</span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-stack-2"></i></span><span class="nav-link-title">Queue</span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-report-analytics"></i></span><span class="nav-link-title">DMARC reports</span></a></li>
|
||||
|
||||
<li class="sp-nav-label">Inbound</li>
|
||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-world-download"></i></span><span class="nav-link-title">Domains</span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-list-details"></i></span><span class="nav-link-title">Log</span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-stack-2"></i></span><span class="nav-link-title">Queue</span></a></li>
|
||||
|
||||
<li class="sp-nav-label">Server</li>
|
||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-file-text"></i></span><span class="nav-link-title">System log</span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-archive"></i></span><span class="nav-link-title">Backup</span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-users"></i></span><span class="nav-link-title">Users</span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-settings"></i></span><span class="nav-link-title">Settings</span></a></li>
|
||||
</ul>
|
||||
<ul class="navbar-nav sp-nav-foot">
|
||||
<li class="sp-nav-user">Signed in as admin</li>
|
||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-user-circle"></i></span><span class="nav-link-title">Account</span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-help"></i></span><span class="nav-link-title">Help</span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="login.html"><span class="nav-link-icon"><i class="ti ti-logout"></i></span><span class="nav-link-title">Sign out</span></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="page-wrapper">
|
||||
<div class="page-header">
|
||||
<div class="container-xl">
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle">mail.example.org</div>
|
||||
<h2 class="page-title">Overview</h2>
|
||||
</div>
|
||||
<div class="col-auto text-secondary small">Updated 14:02:31 UTC · refreshes every 10 s</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-body">
|
||||
<div class="container-xl">
|
||||
|
||||
<div class="alert alert-warning bg-white" role="alert">
|
||||
<div class="d-flex">
|
||||
<div><i class="ti ti-alert-triangle alert-icon"></i></div>
|
||||
<div>
|
||||
<h4 class="alert-title">Mail is flowing. One thing needs attention.</h4>
|
||||
<div class="text-secondary">The TLS certificate expires in 12 days and has not renewed yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row row-cards mb-3">
|
||||
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-cpu sp-tile-icon"></i><div><div class="fw-semibold">Machine</div><div class="text-secondary small">CPU 12 % · mem 41 %</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
|
||||
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-server-cog sp-tile-icon"></i><div><div class="fw-semibold">Processes</div><div class="text-secondary small">5 of 5 running</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
|
||||
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-stack-2 sp-tile-icon"></i><div><div class="fw-semibold">Queues</div><div class="text-secondary small">Both empty</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
|
||||
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-certificate sp-tile-icon"></i><div><div class="fw-semibold">TLS certificate</div><div class="text-secondary small">Expires in 12 days</div></div><span class="status status-yellow"><span class="status-dot"></span>Warn</span></div></div></div>
|
||||
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-plug-connected sp-tile-icon"></i><div><div class="fw-semibold">Milter sockets</div><div class="text-secondary small">2 of 2 answering</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
|
||||
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-arrows-exchange sp-tile-icon"></i><div><div class="fw-semibold">Reverse DNS</div><div class="text-secondary small">Forward = reverse</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
|
||||
</div>
|
||||
|
||||
<div class="row row-deck row-cards">
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3 class="card-title">Machine</h3><div class="card-actions"><span class="badge bg-green-lt">ok</span></div></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>CPU</td><td><progress class="sp-bar" value="12" max="100"></progress>12 %</td><td class="text-secondary">2 cores, load 0.21</td></tr>
|
||||
<tr><td>Memory</td><td><progress class="sp-bar" value="41" max="100"></progress>41 %</td><td class="text-secondary">812 MB of 1.9 GB</td></tr>
|
||||
<tr><td>Network</td><td>↓ 18 kB/s · ↑ 44 kB/s</td><td class="text-secondary">eth0: 2.1 GB in, 6.4 GB out</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3 class="card-title">Processes</h3><div class="card-actions"><span class="badge bg-green-lt">ok</span></div></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>postfix</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 41, uptime 18 days</td></tr>
|
||||
<tr><td>opendkim</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 39, uptime 18 days</td></tr>
|
||||
<tr><td>journal-milter</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 40, uptime 18 days</td></tr>
|
||||
<tr><td>panel</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 44, uptime 18 days</td></tr>
|
||||
<tr><td>dmarc-ingest</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 47, uptime 18 days</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3 class="card-title">TLS certificate</h3><div class="card-actions"><span class="badge bg-yellow-lt">warn</span></div></div>
|
||||
<div class="card-body">
|
||||
<div class="datagrid">
|
||||
<div class="datagrid-item"><div class="datagrid-title">Expires</div><div class="datagrid-content font-monospace">2026-10-03 09:14 UTC</div></div>
|
||||
<div class="datagrid-item"><div class="datagrid-title">Names</div><div class="datagrid-content font-monospace">mail.example.org</div></div>
|
||||
</div>
|
||||
<p class="text-secondary mt-3 mb-0">Renewal normally runs 30 days before expiry. Check port 80 and the <a href="#">system log</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3 class="card-title">Milter sockets</h3><div class="card-actions"><span class="badge bg-green-lt">ok</span></div></div>
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead><tr><th>Milter</th><th>State</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>opendkim<div class="text-secondary small font-monospace">inet:127.0.0.1:8891</div></td><td><span class="badge bg-green-lt">ok</span></td></tr>
|
||||
<tr><td>journal-milter<div class="text-secondary small font-monospace">unix:/run/selfpost/journal.sock</div></td><td><span class="badge bg-green-lt">ok</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3 class="card-title">Hostname and reverse DNS</h3><div class="card-actions"><span class="badge bg-green-lt">ok</span></div></div>
|
||||
<div class="card-body">
|
||||
<div class="datagrid">
|
||||
<div class="datagrid-item"><div class="datagrid-title">Hostname</div><div class="datagrid-content font-monospace">mail.example.org</div></div>
|
||||
<div class="datagrid-item"><div class="datagrid-title">Lookup</div><div class="datagrid-content font-monospace">→ 203.0.113.25<br>← mail.example.org</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer"><form method="post" action="#"><button type="submit" class="btn btn-sm"><i class="ti ti-refresh me-1"></i>Re-check DNS</button></form></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="footer footer-transparent">
|
||||
<div class="container-xl sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,80 +0,0 @@
|
||||
/* Direction B — "Console". Tabler (Bootstrap 5 admin kit) carries the shell,
|
||||
cards, tables, badges, input groups and the sign-in page as shipped; this
|
||||
file only re-points Tabler's variables at the brand and adds the two things
|
||||
Tabler has no word for: a sidebar group label and the DNS record block. */
|
||||
|
||||
:root, [data-bs-theme=light] {
|
||||
--tblr-font-sans-serif: var(--sp-sans);
|
||||
--tblr-font-monospace: var(--sp-mono);
|
||||
--tblr-body-bg: var(--sp-paper);
|
||||
--tblr-body-color: var(--sp-ink);
|
||||
--tblr-secondary: var(--sp-muted);
|
||||
--tblr-border-color: var(--sp-rule);
|
||||
--tblr-border-radius: 3px;
|
||||
--tblr-border-radius-lg: 4px;
|
||||
|
||||
--tblr-primary: #7a3b2e;
|
||||
--tblr-primary-rgb: 122, 59, 46;
|
||||
--tblr-link-color: #7a3b2e;
|
||||
--tblr-link-color-rgb: 122, 59, 46;
|
||||
--tblr-link-hover-color: #632f25;
|
||||
--tblr-link-hover-color-rgb: 99, 47, 37;
|
||||
|
||||
--tblr-green: #2c6b43; --tblr-green-rgb: 44, 107, 67;
|
||||
--tblr-yellow: #b5700f; --tblr-yellow-rgb: 181, 112, 15;
|
||||
--tblr-red: #b42318; --tblr-red-rgb: 180, 35, 24;
|
||||
--tblr-success: #2c6b43; --tblr-success-rgb: 44, 107, 67;
|
||||
--tblr-warning: #b5700f; --tblr-warning-rgb: 181, 112, 15;
|
||||
--tblr-danger: #b42318; --tblr-danger-rgb: 180, 35, 24;
|
||||
}
|
||||
body { font-feature-settings: normal; }
|
||||
.btn-primary { --tblr-btn-hover-bg: #632f25; --tblr-btn-active-bg: #632f25; }
|
||||
code, .font-monospace { font-family: var(--sp-mono); }
|
||||
|
||||
/* ---- sidebar: ink, with the stamp on its own paper */
|
||||
.navbar-vertical {
|
||||
--tblr-navbar-bg: var(--sp-ink); background: var(--sp-ink);
|
||||
--tblr-navbar-color: #aab2bd; --tblr-navbar-hover-color: #fff; --tblr-navbar-active-color: #fff;
|
||||
--tblr-navbar-border-color: transparent;
|
||||
}
|
||||
.navbar-vertical .navbar-brand { padding: 1.1rem 0 .9rem; justify-content: flex-start; }
|
||||
.navbar-vertical .navbar-brand img { height: 52px; width: auto; }
|
||||
.navbar-vertical .nav-link { padding-block: .42rem; }
|
||||
.navbar-vertical .nav-link-icon { font-size: 1.15rem; }
|
||||
.navbar-vertical .nav-item.active > .nav-link {
|
||||
background: rgb(255 255 255 / .07); box-shadow: inset 3px 0 0 #c4705e; color: #fff;
|
||||
}
|
||||
.sp-nav-label {
|
||||
font-family: var(--sp-mono); font-size: .66rem; font-weight: 600; letter-spacing: .14em;
|
||||
text-transform: uppercase; color: #6f7a88; padding: 1.1rem 1.5rem .3rem;
|
||||
}
|
||||
.sp-nav-foot { margin-top: auto; padding-bottom: .8rem; border-top: 1px solid rgb(255 255 255 / .08); padding-top: .6rem; }
|
||||
.sp-nav-user { color: #6f7a88; font-size: .8rem; padding: .3rem 1.5rem; }
|
||||
|
||||
/* ---- page */
|
||||
.page-pretitle { font-family: var(--sp-mono); letter-spacing: .1em; }
|
||||
.card-title { font-weight: 600; }
|
||||
.table thead th { font-family: var(--sp-mono); letter-spacing: .08em; }
|
||||
.sp-tile .card-body { display: flex; align-items: center; gap: .8rem; padding: .85rem 1rem; }
|
||||
.sp-tile .sp-tile-icon { font-size: 1.5rem; color: var(--sp-muted); }
|
||||
.sp-tile .fw-semibold { line-height: 1.2; }
|
||||
.sp-tile .status { margin-left: auto; }
|
||||
progress.sp-bar {
|
||||
appearance: none; -webkit-appearance: none; width: 8rem; height: .4rem; border: 0;
|
||||
border-radius: 1rem; overflow: hidden; vertical-align: middle; margin-right: .6rem; background: var(--sp-well);
|
||||
}
|
||||
progress.sp-bar::-webkit-progress-bar { background: var(--sp-well); }
|
||||
progress.sp-bar::-webkit-progress-value { background: var(--tblr-green); }
|
||||
progress.sp-bar::-moz-progress-bar { background: var(--tblr-green); }
|
||||
|
||||
/* ---- the DNS record block: Tabler's read-only input group, plus a status */
|
||||
.sp-record + .sp-record { border-top: 1px solid var(--sp-rule); }
|
||||
.sp-record .form-control[readonly], .sp-record textarea[readonly] {
|
||||
font-family: var(--sp-mono); font-size: .8rem; background: var(--sp-well);
|
||||
}
|
||||
.sp-record textarea { resize: none; }
|
||||
.sp-record .form-label { font-size: .75rem; color: var(--sp-muted); margin-bottom: .25rem; }
|
||||
|
||||
.sp-foot { color: var(--sp-muted); font-size: .8rem; }
|
||||
.sp-signin-mark { width: 15rem; height: auto; }
|
||||
.sp-tagline { font-family: var(--sp-mono); font-size: .72rem; letter-spacing: .16em; color: var(--sp-muted); }
|
||||
@@ -1,200 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>example.org · Outbound domains · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="../shared/brand.css">
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar is-primary" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand">
|
||||
<a class="navbar-item" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a>
|
||||
</div>
|
||||
<div class="navbar-menu is-active">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="status.html">Overview</a>
|
||||
<div class="navbar-item has-dropdown is-hoverable sp-current">
|
||||
<a class="navbar-link" href="domain.html">Outbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item is-active" href="domain.html">Domains</a>
|
||||
<a class="navbar-item" href="#">Log</a>
|
||||
<a class="navbar-item" href="#">Queue</a>
|
||||
<a class="navbar-item" href="#">DMARC reports</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="#">Inbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="#">Domains</a>
|
||||
<a class="navbar-item" href="#">Log</a>
|
||||
<a class="navbar-item" href="#">Queue</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="#">Server</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="#">System log</a>
|
||||
<a class="navbar-item" href="#">Backup</a>
|
||||
<a class="navbar-item" href="#">Users</a>
|
||||
<a class="navbar-item" href="#">Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="#">admin</a>
|
||||
<div class="navbar-dropdown is-right">
|
||||
<a class="navbar-item" href="#">Account</a>
|
||||
<a class="navbar-item" href="#">Help</a>
|
||||
<hr class="navbar-divider">
|
||||
<a class="navbar-item" href="login.html">Sign out</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<div class="container sp-page">
|
||||
<div class="level mb-5">
|
||||
<div class="level-left">
|
||||
<div>
|
||||
<p class="sp-kicker"><a href="status.html">Outbound domains</a> /</p>
|
||||
<h1 class="title is-3 mb-0">example.org</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<div class="buttons">
|
||||
<form method="post" action="#"><button type="submit" class="button">Re-check DNS</button></form>
|
||||
<a class="button is-primary" href="#">Add application</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-3">
|
||||
<aside class="menu sp-side">
|
||||
<p class="menu-label">This domain</p>
|
||||
<ul class="menu-list">
|
||||
<li><a class="is-active" href="#dns">DNS records <span class="tag is-danger is-light">1</span></a></li>
|
||||
<li><a href="#apps">Applications <span class="tag is-light">2</span></a></li>
|
||||
<li><a href="#connection">Connection</a></li>
|
||||
</ul>
|
||||
<p class="menu-label">Rarely changed</p>
|
||||
<ul class="menu-list">
|
||||
<li><a href="#">DMARC reports</a></li>
|
||||
<li><a href="#">Rate limit <span class="tag is-success is-light">active</span></a></li>
|
||||
<li><a href="#">Export</a></li>
|
||||
<li><a class="has-text-danger" href="#">Delete domain</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="column is-9">
|
||||
<div class="box" id="dns">
|
||||
<div class="sp-box-head"><h2>DNS records</h2><div class="sp-end">Checked 3 min ago</div></div>
|
||||
|
||||
<div class="sp-record">
|
||||
<div class="sp-record-head"><h3>DKIM</h3><span class="tag is-success is-light">published</span><small>Not a secret · selector <code>sp2026</code></small></div>
|
||||
<div class="sp-row">
|
||||
<div class="field">
|
||||
<label class="label">Host / name</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="sp2026._domainkey.example.org"></div><div class="control"><button type="button" class="button">Copy</button></div></div>
|
||||
</div>
|
||||
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Value to publish</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><textarea class="textarea" rows="3" readonly>v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3kqV0mJd8e2f1Yb7Qw5uT9sLr4nHc6pZaXo1vKe0yWm2gBt8RjN5dCq3hUf7lPs9aEi4oMz6xTb1kGv0nYw…IDAQAB</textarea></div><div class="control"><button type="button" class="button">Copy</button></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sp-record">
|
||||
<div class="sp-record-head"><h3>SPF</h3><span class="tag is-danger is-light">mismatch</span><small>Merge into the existing record — never publish a second one</small></div>
|
||||
<div class="sp-row">
|
||||
<div class="field">
|
||||
<label class="label">Host / name</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="example.org"></div><div class="control"><button type="button" class="button">Copy</button></div></div>
|
||||
</div>
|
||||
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Value to publish</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=spf1 ip4:203.0.113.25 ~all"></div><div class="control"><button type="button" class="button">Copy</button></div></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">In DNS now</label>
|
||||
<div class="control"><input class="input" readonly value="v=spf1 include:_spf.google.com ~all"></div>
|
||||
<p class="help is-danger">The published record does not authorize 203.0.113.25. Add <code>ip4:203.0.113.25</code> before <code>~all</code>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sp-record">
|
||||
<div class="sp-record-head"><h3>DMARC</h3><span class="tag is-success is-light">published</span><small>Report address from <a href="#">Settings</a></small></div>
|
||||
<div class="sp-row">
|
||||
<div class="field">
|
||||
<label class="label">Host / name</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="_dmarc.example.org"></div><div class="control"><button type="button" class="button">Copy</button></div></div>
|
||||
</div>
|
||||
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Value to publish</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org"></div><div class="control"><button type="button" class="button">Copy</button></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box" id="apps">
|
||||
<div class="sp-box-head"><h2>Applications</h2><div class="sp-end">SASL logins that may send as this domain</div></div>
|
||||
<table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="sp-mono"><strong>prod-server</strong></td><td>*@example.org</td><td class="sp-muted">1 102 msg · peak 96/h</td>
|
||||
<td><span class="tag is-success is-light">200 / h</span></td>
|
||||
<td><div class="buttons are-small is-right is-flex-wrap-nowrap"><a class="button" href="#">Edit</a><a class="button" href="#">New password</a><a class="button is-danger is-light" href="#">Delete</a></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="sp-mono"><strong>alerts</strong></td><td>alerts@example.org, noc@example.org</td><td class="sp-muted">182 msg · peak 14/h</td>
|
||||
<td><span class="tag is-light">domain</span> <span class="tag is-success is-light">2 IPs</span></td>
|
||||
<td><div class="buttons are-small is-right is-flex-wrap-nowrap"><a class="button" href="#">Edit</a><a class="button" href="#">New password</a><a class="button is-danger is-light" href="#">Delete</a></div></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<div class="box" id="connection">
|
||||
<div class="sp-box-head"><h2>Connection</h2><div class="sp-end">Same for every domain</div></div>
|
||||
<p class="sp-mono">mail.example.org</p>
|
||||
<p class="sp-mono sp-muted">465 SSL/TLS · 587 STARTTLS</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><h2>Sent, 30 days</h2></div>
|
||||
<div class="sp-stats">
|
||||
<div><b>1 284</b><span class="sp-muted">messages</span></div>
|
||||
<div><b>96</b><span class="sp-muted">peak / h</span></div>
|
||||
<div><b>1.8</b><span class="sp-muted">average / h</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,38 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign in · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="../shared/brand.css">
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="columns is-gapless sp-signin">
|
||||
<div class="column is-half sp-signin-brand">
|
||||
<div>
|
||||
<img src="../../selfpost-stamp.svg" alt="SelfPost">
|
||||
<p>YOUR SMTP. YOUR RULES.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-half sp-signin-form">
|
||||
<form method="post" action="status.html">
|
||||
<h1 class="title is-3">Sign in</h1>
|
||||
<div class="field">
|
||||
<label class="label" for="username">Username</label>
|
||||
<div class="control"><input class="input" id="username" name="username" autocomplete="username" required></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="password">Password</label>
|
||||
<div class="control"><input class="input" id="password" name="password" type="password" autocomplete="current-password" required></div>
|
||||
</div>
|
||||
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Sign in</button></div>
|
||||
</form>
|
||||
<p class="sp-foot">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,117 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Overview · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="../shared/brand.css">
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar is-primary" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand">
|
||||
<a class="navbar-item" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a>
|
||||
</div>
|
||||
<div class="navbar-menu is-active">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item is-active" href="status.html">Overview</a>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="domain.html">Outbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="domain.html">Domains</a>
|
||||
<a class="navbar-item" href="#">Log</a>
|
||||
<a class="navbar-item" href="#">Queue</a>
|
||||
<a class="navbar-item" href="#">DMARC reports</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="#">Inbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="#">Domains</a>
|
||||
<a class="navbar-item" href="#">Log</a>
|
||||
<a class="navbar-item" href="#">Queue</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="#">Server</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="#">System log</a>
|
||||
<a class="navbar-item" href="#">Backup</a>
|
||||
<a class="navbar-item" href="#">Users</a>
|
||||
<a class="navbar-item" href="#">Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="#">admin</a>
|
||||
<div class="navbar-dropdown is-right">
|
||||
<a class="navbar-item" href="#">Account</a>
|
||||
<a class="navbar-item" href="#">Help</a>
|
||||
<hr class="navbar-divider">
|
||||
<a class="navbar-item" href="login.html">Sign out</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<div class="container sp-page">
|
||||
<div class="level mb-5">
|
||||
<div class="level-left">
|
||||
<div>
|
||||
<p class="sp-kicker">mail.example.org</p>
|
||||
<h1 class="title is-3 mb-0">Overview</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-right sp-muted is-size-7">Updated 14:02:31 UTC · refreshes every 10 s</div>
|
||||
</div>
|
||||
|
||||
<div class="box sp-health">
|
||||
<a href="#"><div class="sp-h-name">Machine</div><div class="sp-h-value">12 % CPU</div><div class="sp-h-sub">41 % memory</div></a>
|
||||
<a href="#"><div class="sp-h-name">Processes</div><div class="sp-h-value">5 of 5</div><div class="sp-h-sub">running</div></a>
|
||||
<a href="#"><div class="sp-h-name">Queues</div><div class="sp-h-value">Empty</div><div class="sp-h-sub">outbound and inbound</div></a>
|
||||
<a href="#" class="warn"><div class="sp-h-name">TLS certificate</div><div class="sp-h-value">12 days</div><div class="sp-h-sub">renewal has not run</div></a>
|
||||
<a href="#"><div class="sp-h-name">Milter sockets</div><div class="sp-h-value">2 of 2</div><div class="sp-h-sub">answering</div></a>
|
||||
<a href="#"><div class="sp-h-name">Reverse DNS</div><div class="sp-h-value">Match</div><div class="sp-h-sub">203.0.113.25</div></a>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-7">
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><h2>Outbound domains</h2><div class="sp-end"><a class="button is-small is-primary" href="#">Add domain</a></div></div>
|
||||
<table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Domain</th><th>DNS</th><th>Apps</th><th>30 days</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><a href="domain.html"><strong>example.org</strong></a></td><td><span class="tag is-success is-light">DKIM</span> <span class="tag is-danger is-light">SPF</span> <span class="tag is-success is-light">DMARC</span></td><td>2</td><td class="sp-muted">1 284 msg</td></tr>
|
||||
<tr><td><a href="#"><strong>shop.example.org</strong></a></td><td><span class="tag is-success is-light">DKIM</span> <span class="tag is-success is-light">SPF</span> <span class="tag is-success is-light">DMARC</span></td><td>1</td><td class="sp-muted">18 920 msg</td></tr>
|
||||
<tr><td><a href="#"><strong>notify.acme.io</strong></a></td><td><span class="tag is-success is-light">DKIM</span> <span class="tag is-success is-light">SPF</span> <span class="tag is-warning is-light">DMARC</span></td><td>3</td><td class="sp-muted">402 msg</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-5">
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><h2>Inbound domains</h2><div class="sp-end"><a class="button is-small" href="#">Add domain</a></div></div>
|
||||
<table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Domain</th><th>MX</th><th>Upstream</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><a href="#"><strong>lists.example.org</strong></a></td><td><span class="tag is-success is-light">MX</span></td><td class="sp-muted sp-mono is-size-7">mx.internal:25</td></tr>
|
||||
<tr><td><a href="#"><strong>acme.io</strong></a></td><td><span class="tag is-success is-light">MX</span></td><td class="sp-muted sp-mono is-size-7">10.0.4.12:2525</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,98 +0,0 @@
|
||||
/* Direction C — "Counter". Bulma v1 (pure CSS, no JavaScript, no data: URIs)
|
||||
carries the navbar with hover dropdowns, columns, boxes, tables, tags,
|
||||
the side menu and input-with-addon fields. This file points Bulma's HSL
|
||||
variables at the brand and adds the health strip and the record block. */
|
||||
|
||||
:root {
|
||||
--bulma-family-primary: var(--sp-sans);
|
||||
--bulma-family-secondary: var(--sp-sans);
|
||||
--bulma-family-code: var(--sp-mono);
|
||||
--bulma-primary-h: 10deg; --bulma-primary-s: 45%; --bulma-primary-l: 33%;
|
||||
--bulma-link-h: 10deg; --bulma-link-s: 45%; --bulma-link-l: 33%;
|
||||
--bulma-success-h: 138deg; --bulma-success-s: 42%; --bulma-success-l: 30%;
|
||||
--bulma-warning-h: 36deg; --bulma-warning-s: 85%; --bulma-warning-l: 55%;
|
||||
--bulma-danger-h: 4deg; --bulma-danger-s: 76%; --bulma-danger-l: 40%;
|
||||
--bulma-radius: 6px; --bulma-radius-large: 10px;
|
||||
--bulma-body-size: 15px;
|
||||
/* Bulma derives link text and the on-primary colour from lightness steps;
|
||||
at the brick's 33 % both land off-brand, so they are pinned. */
|
||||
--bulma-link-text: var(--sp-brick);
|
||||
--bulma-link-text-hover: var(--sp-brick-dark);
|
||||
}
|
||||
.button.is-primary, .button.is-primary:hover { color: #fff; }
|
||||
a strong { color: inherit; }
|
||||
html { background: var(--sp-paper); }
|
||||
body { background: var(--sp-paper); color: var(--sp-ink); min-height: 100vh; display: flex; flex-direction: column; }
|
||||
main { flex: 1; }
|
||||
code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; padding: .1em .35em; font-size: .85em; }
|
||||
|
||||
/* ---- navbar: brick, the stamp on it, hover dropdowns */
|
||||
.navbar.is-primary { --bulma-navbar-height: 4rem; }
|
||||
.navbar-brand img { max-height: 3rem; height: 3rem; }
|
||||
.navbar.is-primary .navbar-item, .navbar.is-primary .navbar-link { color: var(--sp-on-brick); font-weight: 500; }
|
||||
.navbar.is-primary .navbar-link::after { border-color: var(--sp-on-brick); }
|
||||
.navbar.is-primary .navbar-start > .navbar-item.is-active,
|
||||
.navbar.is-primary .navbar-start > .has-dropdown.sp-current > .navbar-link { box-shadow: inset 0 -3px 0 var(--sp-on-brick); }
|
||||
.navbar-dropdown { border-top: 0; box-shadow: 0 8px 24px rgb(18 22 28 / .14); }
|
||||
.navbar.is-primary .navbar-dropdown .navbar-item { color: var(--sp-ink); font-weight: 400; }
|
||||
.navbar.is-primary .navbar-dropdown .navbar-item.is-active { color: var(--sp-brick); font-weight: 600; background: var(--sp-brick-tint); }
|
||||
|
||||
/* ---- page */
|
||||
.sp-page { padding-block: 2rem 2.5rem; }
|
||||
.sp-kicker { font-family: var(--sp-mono); font-size: .75rem; letter-spacing: .1em; text-transform: uppercase; color: var(--sp-muted); }
|
||||
.sp-kicker a { color: inherit; }
|
||||
.title { color: var(--sp-ink); font-weight: 600; }
|
||||
.box { box-shadow: none; border: 1px solid var(--sp-rule); }
|
||||
.box > .sp-box-head { display: flex; align-items: center; gap: .75rem; margin-bottom: 1rem; }
|
||||
.box > .sp-box-head h2 { font-size: 1.1rem; font-weight: 600; margin: 0; }
|
||||
.box > .sp-box-head .sp-end { margin-left: auto; color: var(--sp-muted); font-size: .85rem; display: flex; gap: .6rem; align-items: center; }
|
||||
.table { background: transparent; }
|
||||
.table thead th { font-family: var(--sp-mono); font-size: .7rem; font-weight: 400; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); }
|
||||
.table td { vertical-align: middle; }
|
||||
.tag { font-family: var(--sp-mono); font-weight: 600; letter-spacing: .04em; }
|
||||
.sp-muted { color: var(--sp-muted); }
|
||||
.sp-mono { font-family: var(--sp-mono); }
|
||||
|
||||
/* ---- health strip */
|
||||
.sp-health { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0; padding: 0; overflow: hidden; }
|
||||
.sp-health > a {
|
||||
display: block; padding: 1rem 1.2rem; color: var(--sp-ink); border-right: 1px solid var(--sp-rule);
|
||||
box-shadow: inset 0 3px 0 var(--sp-ok);
|
||||
}
|
||||
.sp-health > a:last-child { border-right: 0; }
|
||||
.sp-health > a:hover { background: var(--sp-paper); }
|
||||
.sp-health > a.warn { box-shadow: inset 0 3px 0 #d9962b; background: var(--sp-warn-bg); }
|
||||
.sp-health > a.fail { box-shadow: inset 0 3px 0 var(--sp-fail); background: var(--sp-fail-bg); }
|
||||
.sp-health .sp-h-name { font-family: var(--sp-mono); font-size: .68rem; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); }
|
||||
.sp-health .sp-h-value { font-size: 1.15rem; font-weight: 600; line-height: 1.3; }
|
||||
.sp-health .sp-h-sub { font-size: .8rem; color: var(--sp-muted); }
|
||||
|
||||
/* ---- domain page */
|
||||
.sp-side { position: sticky; top: 1.5rem; }
|
||||
.menu-label { font-family: var(--sp-mono); letter-spacing: .1em; }
|
||||
.menu-list a { display: flex; align-items: center; gap: .5rem; }
|
||||
.menu-list a .tag { margin-left: auto; }
|
||||
.sp-record + .sp-record { border-top: 1px solid var(--sp-rule); margin-top: 1.25rem; padding-top: 1.25rem; }
|
||||
.sp-record-head { display: flex; align-items: center; gap: .6rem; margin-bottom: .75rem; }
|
||||
.sp-record-head h3 { font-size: 1rem; font-weight: 600; margin: 0; }
|
||||
.sp-record-head small { margin-left: auto; color: var(--sp-muted); }
|
||||
.sp-record .label { font-size: .75rem; font-weight: 400; color: var(--sp-muted); margin-bottom: .25rem; }
|
||||
.sp-record .input[readonly], .sp-record .textarea[readonly] {
|
||||
font-family: var(--sp-mono); font-size: .82rem; background: var(--sp-well); box-shadow: none;
|
||||
}
|
||||
.sp-record .textarea { resize: none; min-height: 0; }
|
||||
.sp-record .field:not(:last-child) { margin-bottom: .6rem; }
|
||||
.sp-row { display: grid; grid-template-columns: 1fr 7rem; gap: .75rem; }
|
||||
.sp-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
|
||||
.sp-stats b { display: block; font-size: 1.5rem; font-weight: 600; line-height: 1.2; }
|
||||
|
||||
/* ---- sign-in */
|
||||
.sp-signin { min-height: 100vh; margin: 0; }
|
||||
.sp-signin-brand { background: var(--sp-brick); display: grid; place-content: center; text-align: center; padding: 3rem; }
|
||||
.sp-signin-brand img { width: 20rem; max-width: 100%; height: auto; }
|
||||
.sp-signin-brand p { font-family: var(--sp-mono); letter-spacing: .18em; font-size: .8rem; color: var(--sp-on-brick); margin-top: 1.6rem; }
|
||||
.sp-signin-form { display: flex; flex-direction: column; justify-content: center; padding: 3rem; }
|
||||
.sp-signin-form form { width: 21rem; max-width: 100%; margin-inline: auto; }
|
||||
.sp-foot { color: var(--sp-muted); font-size: .8rem; padding-block: 1.2rem; }
|
||||
.sp-foot a { color: inherit; text-decoration: underline; }
|
||||
.sp-signin-form .sp-foot { text-align: center; margin-top: 2.5rem; }
|
||||
@@ -3,19 +3,21 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Panel redesign · three directions</title>
|
||||
<title>Panel mockups</title>
|
||||
<link rel="stylesheet" href="shared/brand.css">
|
||||
<link rel="stylesheet" href="index.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<p class="kicker">SelfPost · panel redesign · stage 2</p>
|
||||
<h1>The accepted direction, every screen</h1>
|
||||
<p class="lead">Direction <strong>C</strong> (Bulma) was picked as the base: the important things in view, details one
|
||||
or two clicks away. It takes the postal signature of <strong>A</strong> — the postmark verdict, numbered mono section
|
||||
heads, the stamp's perforated paper edge under a brick bar that carries the wordmark — and the icons of <strong>B</strong>. Thirty-four screens and a components page live in
|
||||
<a href="panel/overview.html">panel/</a>. Serve this folder from the repository root: the pages load the panel's own
|
||||
fonts and mark by relative path.</p>
|
||||
<p class="kicker">SelfPost · panel mockups</p>
|
||||
<h1>Every screen of the panel</h1>
|
||||
<p class="lead">The accepted design (<a href="../../plans/panel-redesign.md">the plan</a>): Bulma as the base, the
|
||||
important things in view, details one or two clicks away, with a postal signature — the postmark verdict, numbered
|
||||
mono section heads, the stamp's perforated paper edge under a brick bar that carries the wordmark — and Tabler icons.
|
||||
The screens and a components page live in <a href="panel/overview.html">panel/</a>; a page of the running panel that
|
||||
disagrees with its mockup is wrong. Serve this folder from the repository root: the pages load the panel's own fonts
|
||||
and mark by relative path. The three directions of the redesign's first stage and the earlier 1.x mockups were
|
||||
removed on 2026-10-16; they are in the git history.</p>
|
||||
|
||||
<h2>Screen map</h2>
|
||||
<div class="scroll">
|
||||
@@ -44,7 +46,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>What changed against today's panel</h2>
|
||||
<h2>What changed against the 1.x panel</h2>
|
||||
<ul>
|
||||
<li><strong>Overview shows verdicts, Health shows tables.</strong> Process lists, socket paths and interface counters moved to
|
||||
<em>Server › Health</em>, one click from each card.</li>
|
||||
@@ -60,79 +62,6 @@
|
||||
<li><code>panel/build.py</code> wraps the fragments in <code>panel/src/</code> with the shared navbar; the built pages are committed,
|
||||
so nothing has to be run to view them.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Stage 1 · three directions on three key screens</h2>
|
||||
<p class="lead">Kept for the record: Overview, an outbound domain and sign-in, drawn three ways.</p>
|
||||
|
||||
<h2>Shared by all three</h2>
|
||||
<ul>
|
||||
<li><strong>Navigation by mail direction.</strong> Overview · <em>Outbound</em> (Domains, Log, Queue, DMARC reports) ·
|
||||
<em>Inbound</em> (Domains, Log, Queue) · <em>Server</em> (System log, Backup, Users, Settings). Account, Help and
|
||||
Sign out live with the user name. Eleven flat entries become four groups.</li>
|
||||
<li><strong>One block per DNS record.</strong> Today "DNS status" and "records to publish" show every host twice.
|
||||
Here a record is one block: its status, what to publish, and — only on a mismatch — what DNS holds now.</li>
|
||||
<li><strong>The domain page stops being nine cards.</strong> DNS and applications are the page; limits, report address,
|
||||
export and delete are "rarely changed" and sit behind one click.</li>
|
||||
<li><strong>Applications are a table</strong>; editing one is its own form page instead of a three-column panel inside a list row.</li>
|
||||
<li>Brand carriers stay: the stamp, brick, IBM Plex. No inline <code>style</code> or script in any mockup.</li>
|
||||
</ul>
|
||||
|
||||
<h2>The directions</h2>
|
||||
<div class="cards">
|
||||
<section>
|
||||
<p class="kicker">A · Pico CSS 2.1</p>
|
||||
<h3>Blank</h3>
|
||||
<p>The panel as a postal form: one centred sheet, numbered sections, mono field labels, a postmark for the
|
||||
verdict. Overview is a ledger — one row per check, the row opens into its detail. Sections on the first
|
||||
header row, the section's pages on the second.</p>
|
||||
<p class="links"><a href="a/status.html">Overview</a><a href="a/domain.html">Domain</a><a href="a/login.html">Sign in</a></p>
|
||||
</section>
|
||||
<section>
|
||||
<p class="kicker">B · Tabler 1.5 (Bootstrap 5)</p>
|
||||
<h3>Console</h3>
|
||||
<p>The classic admin kit: ink sidebar with every page one click away, status tiles, cards and tables in a
|
||||
12-column grid, read-only input groups with Copy. Almost nothing is ours; it also looks the most like
|
||||
every other admin panel.</p>
|
||||
<p class="links"><a href="b/status.html">Overview</a><a href="b/domain.html">Domain</a><a href="b/login.html">Sign in</a></p>
|
||||
</section>
|
||||
<section>
|
||||
<p class="kicker">C · Bulma 1.0</p>
|
||||
<h3>Counter</h3>
|
||||
<p>Brick top bar with CSS-only hover menus. Overview is the whole service on one screen: a health strip over
|
||||
the outbound and inbound domain lists. The domain page gets a sticky side menu. Softer, rounder, the
|
||||
boldest use of the brand colour.</p>
|
||||
<p class="links"><a href="c/status.html">Overview</a><a href="c/domain.html">Domain</a><a href="c/login.html">Sign in</a></p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<h2>What each one costs</h2>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr><th></th><th>A · Pico</th><th>B · Tabler</th><th>C · Bulma</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><th>Licence</th><td>MIT</td><td>MIT</td><td>MIT</td></tr>
|
||||
<tr><th>Vendored CSS (minified)</th><td>83 KB</td><td>694 KB</td><td>678 KB</td></tr>
|
||||
<tr><th>JavaScript</th><td>none</td><td>none for these screens; Bootstrap bundle only if dropdowns / modals are wanted</td><td>none</td></tr>
|
||||
<tr><th>Build step</th><td>none</td><td>none</td><td>none (a Sass build could trim it; not needed)</td></tr>
|
||||
<tr><th>CSP <code>default-src 'self'</code></th>
|
||||
<td>needs <code>img-src 'self' data:</code> — 14 <code>data:</code> SVGs (select chevron, checkbox tick)</td>
|
||||
<td>needs <code>img-src 'self' data:</code> — 28 <code>data:</code> SVGs</td>
|
||||
<td>works as is — no <code>data:</code> URIs</td></tr>
|
||||
<tr><th>Our own CSS in the mockup</th><td>≈ 200 lines</td><td>≈ 80 lines</td><td>≈ 100 lines</td></tr>
|
||||
<tr><th>Markup style</th><td>semantic HTML, few classes — closest to today's templates</td><td>class-heavy Bootstrap markup</td><td>class-based, moderate</td></tr>
|
||||
<tr><th>Own components</th><td>sheet, ledger row, postmark, record block</td><td>sidebar group label, record block</td><td>health strip, record block</td></tr>
|
||||
<tr><th>Recognisable</th><td>most</td><td>least</td><td>middle</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>Open points</h2>
|
||||
<ul>
|
||||
<li>Icons in B come from Tabler Icons (MIT) through its webfont for the mockup; the panel would vendor only the used glyphs as an SVG sprite.</li>
|
||||
<li><em>Inbound log</em> rides on the agreed <code>inbound-antispam-panel</code> journal; <em>Inbound queue</em> is a new filter over the one Postfix queue and needs its own roadmap row.</li>
|
||||
<li>Dark scheme and the narrow-screen shell are not drawn at this stage; all three libraries ship both.</li>
|
||||
<li>The earlier <a href="../panel-ui/index.html">panel-ui</a> experiment and <a href="../../plans/panel-redesign.md">panel-redesign.md</a> are superseded by whichever direction is accepted.</li>
|
||||
</ul>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -82,6 +82,8 @@
|
||||
<div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="admin" autocomplete="username" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div>
|
||||
<div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
|
||||
<p class="help">Your own address. A domain's report address can be filled with it from Domain settings.</p></div>
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="notify" checked> Send me notifications</label>
|
||||
<p class="help">To this address, when something needs you: a DNS record of a domain stops matching, a rate limit refuses mail, the TLS certificate is about to expire, a message has waited in the queue for three days. <a href="help.html#notifications">What is sent and when</a></p></div>
|
||||
<button type="submit" class="button is-primary">Save profile</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Full backup</h2><a class="sp-help" href="help.html#backup" title="Backups, encryption and restore"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="mb-3"><code>data/</code> (database, DKIM keys, credentials, the queue), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code> in one archive. Restore into an empty project directory and start the <strong>same SelfPost version</strong>. The reverse-proxy vhost is not included.</p>
|
||||
<p class="mb-3"><code>data/</code> (database, DKIM keys, credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code> in one archive. The Postfix queue is left out: it cannot be copied reliably while the server is running. Restore into an empty project directory and start the <strong>same SelfPost version</strong>. The reverse-proxy vhost is not included.</p>
|
||||
<form class="sp-form" method="post" action="#">
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="encrypt" checked> Encrypt with a password (<code>.spbk</code>)</label></div>
|
||||
<div class="sp-pair">
|
||||
|
||||
@@ -37,7 +37,7 @@ SIGNED_OUT = {"sp-signin", "sp-signin-brand", "sp-signin-form", "sp-foot"}
|
||||
# Selectors allowed to set a width. Everything else gets its width from the
|
||||
# layout it sits in — that is the whole point of the redesign.
|
||||
WIDTH_OK = (".navbar-brand img", ".sp-head .sp-lead", ".sp-head .sp-grow", ".sp-postmark", ".table td.sp-clip",
|
||||
".sp-h-icon", ".sp-signin", ".sp-timeline li::before", ".sp-perf")
|
||||
".sp-h-icon", ".sp-signin", ".sp-timeline li::before", ".sp-perf", ".sp-sheet")
|
||||
|
||||
|
||||
class Node:
|
||||
|
||||
@@ -84,15 +84,16 @@
|
||||
<dl class="sp-facts">
|
||||
<div><dt>list</dt><dd>page_head, then full-width boxes: the add form first, the table second. <a href="out-domains.html">Example</a></dd></div>
|
||||
<div><dt>detail</dt><dd>page_head, then <code>columns</code>: <code>is-3</code> side menu + <code>is-9</code> boxes. <a href="out-domain.html">Example</a></dd></div>
|
||||
<div><dt>cards</dt><dd>page_head, then <code>columns sp-cards</code>: two <code>is-6</code> stacks of numbered boxes read in rows, odd on the left and even on the right, each stack closed up. On a phone the boxes come in the order of their numbers: each box carries <code>data-order</code>, its own number, up to twelve. The two stacks under this box are laid out so. <a href="health.html">Example</a></dd></div>
|
||||
<div><dt>form</dt><dd>page_head, then boxes in equal <code>columns</code>, one submit row under them. <a href="out-app.html">Example</a></dd></div>
|
||||
<div><dt>signed out</dt><dd>The split brand / form screen, no navbar. <a href="login.html">Example</a></dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="columns sp-cards">
|
||||
<div class="column is-6">
|
||||
<div class="box">
|
||||
<div class="box" data-order="1">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>postmark</h2><div class="sp-end">Worst status of the page's checks</div></div>
|
||||
<div class="sp-box-body">
|
||||
<div class="buttons">
|
||||
@@ -104,23 +105,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="box" data-order="3">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>status_tag</h2><div class="sp-end">One mapping, one function</div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Status</th><th>Tag</th><th>Bulma classes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>ok, published, running, sent</td><td><span class="tag is-success is-light">ok</span></td><td class="sp-mono sp-small">tag is-success is-light</td></tr>
|
||||
<tr><td>warn, deferred, quiet</td><td><span class="tag is-warning is-light">warn</span></td><td class="sp-mono sp-small">tag is-warning is-light</td></tr>
|
||||
<tr><td>error, fail, mismatch, bounced</td><td><span class="tag is-danger is-light">fail</span></td><td class="sp-mono sp-small">tag is-danger is-light</td></tr>
|
||||
<tr><td>error, fail, mismatch, bounced, reject, rate-limited</td><td><span class="tag is-danger is-light">fail</span></td><td class="sp-mono sp-small">tag is-danger is-light</td></tr>
|
||||
<tr><td>unknown, off, neutral counts</td><td><span class="tag is-light">off</span></td><td class="sp-mono sp-small">tag is-light</td></tr>
|
||||
<tr><td>held: quarantine · role: global</td><td><span class="tag is-primary is-light">quarantine</span></td><td class="sp-mono sp-small">tag is-primary is-light</td></tr>
|
||||
<tr><td>a tag that opens a <code>sheet</code></td><td><button type="button" class="tag is-danger is-light" popovertarget="kit-sheet">reject</button><div class="sp-sheet" id="kit-sheet" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-danger is-light">reject</span><h2>sheet</h2><button type="button" popovertarget="kit-sheet" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>What it holds</th><td>What does not fit the row, as name and value</td></tr><tr><th>How it closes</th><td class="sp-mono sp-small">Esc<br>a click outside<br>the cross</td></tr></tbody></table></div></div></div></div></td><td class="sp-mono sp-small">button.tag + sp-sheet (popover)</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<div class="box">
|
||||
<div class="box" data-order="2">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>box · box_head</h2><div class="sp-end">The end slot: a note, a tag, a link or a filter form</div><a class="sp-help" href="help.html#checks" title="Optional: the section's Help topic"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body"><p class="mb-2">The <i class="ti ti-help-circle"></i> after the end slot is <code>sp-help</code>: an optional link to the section's topic on the Help page. It replaces today's help drawer.</p><p>box_body — padded content. A <code>table</code> or a run of <code>sp-record</code> blocks sits directly in the box, without box_body.</p></div>
|
||||
<div class="sp-box-foot"><span>box_foot — a legend, paging</span><span class="sp-end">end slot</span></div>
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<div><dt>Last report</dt><dd>2026-09-21 06:12 UTC</dd></div>
|
||||
<div><dt>This week</dt><dd>14 kept · 0 parse failures</dd></div>
|
||||
<div><dt>Hosted addresses</dt><dd class="sp-mono sp-small">dmarc-reports+<domain>@mail.example.org</dd></div>
|
||||
<div><dt>Retention</dt><dd>500 reports or 90 days</dd></div>
|
||||
<div><dt>Retention</dt><dd>500 reports a domain or 90 days</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -74,10 +74,45 @@
|
||||
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="columns sp-cards">
|
||||
<div class="column is-6">
|
||||
<div class="box" id="machine">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Machine</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#checks" title="What these readings mean"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="box" id="processes" data-order="1">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Processes</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>postfix</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 41, uptime 18 days</td></tr>
|
||||
<tr><td>journal-milter</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 40, uptime 18 days</td></tr>
|
||||
<tr><td>panel</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 44, uptime 18 days</td></tr>
|
||||
<tr><td>dmarc-ingest</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 47, uptime 18 days</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
<div class="box" id="sender-check" data-order="3">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Sender check</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#sender-check" title="What is checked and what these numbers are for"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Period</th><th>Panel not answering</th><th>Connections turned away</th><th>Messages deferred</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Today</td><td>0 s</td><td>0</td><td>0</td></tr>
|
||||
<tr><td>Last 30 days</td><td>4 s</td><td>0</td><td>0</td></tr>
|
||||
<tr><td>Last 12 months</td><td>≈ 41 s</td><td>3</td><td>1</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="sp-muted">The panel holds each message's <code>From:</code> against the application that sends it. While the panel does not answer, Postfix answers the sending application with a temporary error instead of accepting mail unchecked, and the application tries again later. Time is counted only while Postfix itself was running; ≈ marks a period with a start at which that could not be asked, and its time is at most what is shown. Counted since <span class="sp-mono">2026-10-13</span>.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" id="hostname" data-order="5">
|
||||
<div class="sp-box-head"><span class="sp-no">05</span><h2>Hostname and reverse DNS</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#rdns" title="Forward and reverse DNS"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Hostname</dt><dd class="sp-mono">mail.example.org</dd></div>
|
||||
<div><dt>Lookup</dt><dd class="sp-mono">→ 203.0.113.25<br>← mail.example.org</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" id="machine" data-order="7">
|
||||
<div class="sp-box-head"><span class="sp-no">07</span><h2>Machine</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#checks" title="What these readings mean"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
@@ -89,67 +124,51 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<div class="box" id="processes">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Processes</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>postfix</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 41, uptime 18 days</td></tr>
|
||||
<tr><td>opendkim</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 39, uptime 18 days</td></tr>
|
||||
<tr><td>journal-milter</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 40, uptime 18 days</td></tr>
|
||||
<tr><td>panel</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 44, uptime 18 days</td></tr>
|
||||
<tr><td>dmarc-ingest</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 47, uptime 18 days</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-4">
|
||||
<div class="box" id="certificate">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>TLS certificate</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div><a class="sp-help" href="help.html#certificate" title="When it warns and what to do"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Expires</dt><dd class="sp-mono">2026-10-03 09:14 UTC</dd></div>
|
||||
<div><dt>Names</dt><dd class="sp-mono">mail.example.org</dd></div>
|
||||
</dl>
|
||||
<p class="mt-4 has-text-danger">Expires in 12 days — renewal has not run.</p>
|
||||
<p class="sp-muted">Renewal normally runs 30 days before expiry. Check that port 80 is reachable and look for <code>acme</code> lines in the <a href="system-log.html">system log</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<div class="box" id="sockets">
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Milter sockets</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
|
||||
<div class="box" id="sockets" data-order="2">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Milter sockets</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Milter</th><th>State</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>opendkim<br><span class="sp-muted sp-mono sp-small">inet:127.0.0.1:8891</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>dkim-signer <span class="sp-muted">· applications</span><br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/sign.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>dkim-signer <span class="sp-muted">· notifications</span><br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/sign-local.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>journal-milter<br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/journal.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>sender-check<br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/sender.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>rspamd <span class="sp-muted">· inbound spam filter</span><br><span class="sp-muted sp-mono sp-small">inet:antispam:11332</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<div class="box" id="hostname">
|
||||
<div class="sp-box-head"><span class="sp-no">05</span><h2>Hostname and reverse DNS</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#rdns" title="Forward and reverse DNS"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="box" id="certificate" data-order="4">
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>TLS certificate</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#certificate" title="When it warns and what to do"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Hostname</dt><dd class="sp-mono">mail.example.org</dd></div>
|
||||
<div><dt>Lookup</dt><dd class="sp-mono">→ 203.0.113.25<br>← mail.example.org</dd></div>
|
||||
<div><dt>Expires</dt><dd class="sp-mono">2026-11-02 09:14 UTC</dd></div>
|
||||
<div><dt>Names</dt><dd class="sp-mono">mail.example.org</dd></div>
|
||||
<div><dt>Renewal expires</dt><dd class="sp-mono">2027-01-31 08:02 UTC</dd></div>
|
||||
<div><dt>Renewal mounted</dt><dd class="sp-mono">2026-10-15 03:12 UTC</dd></div>
|
||||
</dl>
|
||||
<p class="mt-4 sp-muted">Postfix still serves the certificate above. A renewed one is waiting in the mounted file; Postfix takes it up at its next check for a renewal, at most a day after it was mounted.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" id="notification-mail" data-order="6">
|
||||
<div class="sp-box-head"><span class="sp-no">06</span><h2>Notification mail</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div><a class="sp-help" href="help.html#notifications" title="What the panel sends and what makes it arrive"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Record</th><th>State</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>DKIM<br><span class="sp-muted sp-mono sp-small">sp2026._domainkey.mail.example.org</span></td><td><span class="tag is-success is-light">published</span></td></tr>
|
||||
<tr><td>SPF<br><span class="sp-muted sp-mono sp-small">mail.example.org</span></td><td><span class="tag is-warning is-light">missing</span></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="sp-muted">Sent from <span class="sp-mono">postmaster@mail.example.org</span>. What to publish is under <a href="settings.html#notification-mail">Settings</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" id="configuration" data-order="8">
|
||||
<div class="sp-box-head"><span class="sp-no">08</span><h2>Configuration</h2><div class="sp-end">Safe to run at any time</div></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="mb-3">Regenerates the Postfix configuration and the spam filter's lists from the database, removes the keys of deleted domains, and reloads Postfix. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
|
||||
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-reload"></i>Reload configuration</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box" id="configuration">
|
||||
<div class="sp-box-head"><span class="sp-no">06</span><h2>Configuration</h2><div class="sp-end">Safe to run at any time</div></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="mb-3">Regenerates the OpenDKIM and Postfix configuration from the database and reloads both daemons. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
|
||||
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-reload"></i>Reload configuration</button></form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
<ul class="menu-list">
|
||||
<li><a href="#dns"><i class="ti ti-world-check"></i>DNS records</a></li>
|
||||
<li><a href="#apps"><i class="ti ti-apps"></i>Applications</a></li>
|
||||
<li><a href="#sender-check"><i class="ti ti-shield-check"></i>Sender check</a></li>
|
||||
<li><a href="#limits"><i class="ti ti-gauge"></i>Rate limits</a></li>
|
||||
<li><a href="#log"><i class="ti ti-list-details"></i>Outbound log</a></li>
|
||||
<li><a href="#queue"><i class="ti ti-stack-2"></i>Queue and retries</a></li>
|
||||
@@ -97,12 +98,15 @@
|
||||
<li><a href="#upstream"><i class="ti ti-arrow-forward-up"></i>Upstream</a></li>
|
||||
<li><a href="#recipients"><i class="ti ti-address-book"></i>Valid recipients</a></li>
|
||||
<li><a href="#filter"><i class="ti ti-shield-check"></i>Spam filter</a></li>
|
||||
<li><a href="#inbound-log"><i class="ti ti-list-details"></i>Inbound log</a></li>
|
||||
<li><a href="#filter-lists"><i class="ti ti-filter"></i>Filter lists</a></li>
|
||||
</ul>
|
||||
<p class="menu-label">Server</p>
|
||||
<ul class="menu-list">
|
||||
<li><a href="#backup"><i class="ti ti-archive"></i>Backup and moving a domain</a></li>
|
||||
<li><a href="#users"><i class="ti ti-users"></i>Users and roles</a></li>
|
||||
<li><a href="#settings"><i class="ti ti-settings"></i>Settings</a></li>
|
||||
<li><a href="#notifications"><i class="ti ti-mail"></i>Notifications</a></li>
|
||||
</ul>
|
||||
<p class="menu-label">Account</p>
|
||||
<ul class="menu-list">
|
||||
@@ -114,34 +118,38 @@
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Overview</h2></div>
|
||||
<div class="sp-help-topic" id="checks"><h3>Health checks</h3><p>Six checks, and the worst of them is the postmark. <span class="tag is-success is-light">ok</span> needs nothing; <span class="tag is-warning is-light">warn</span> still delivers mail but will stop doing so if ignored; <span class="tag is-danger is-light">fail</span> means mail is being lost or refused now. <a href="health.html">Health</a> shows the readings behind them: CPU and memory are the container's own, not the host's spare capacity, and network is a short window, not a daily total.</p></div>
|
||||
<div class="sp-help-topic" id="certificate"><h3>TLS certificate</h3><p>The certificate Postfix presents on 465 and 587. The reverse proxy or the image mounts it, the panel does not issue it. It warns 14 days before expiry and fails once expired, or when the file is missing or unreadable — clients that verify certificates then refuse to connect.</p></div>
|
||||
<div class="sp-help-topic" id="certificate"><h3>TLS certificate</h3><p>The certificate Postfix presents on 465 and 587. The reverse proxy or the image mounts it, the panel does not issue it. Postfix serves its own copy of the mounted file and looks for a renewed one once a day by default; until it takes one up, the card shows it as a renewal waiting, and warns once it has waited longer than that check allows - the container log's cert-reload lines say why. It warns 14 days before expiry unless a renewal is already waiting, and fails once expired, or when the file is missing or unreadable — clients that verify certificates then refuse to connect.</p></div>
|
||||
<div class="sp-help-topic" id="rdns"><h3>Hostname and reverse DNS</h3><p>The hostname (<code>SELFPOST_HOSTNAME</code>) must resolve to this server's address, and that address must resolve back to the hostname. Large receivers reject or spam-folder mail from hosts where the two disagree. The PTR record is set at your hosting provider, not in your DNS zone.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Outbound</h2></div>
|
||||
<div class="sp-help-topic" id="dns"><h3>DNS records</h3><p>DKIM proves the message was signed by this relay, SPF lists the addresses allowed to send for the domain, DMARC tells receivers what to do when both fail and where to report. Publish all three exactly as shown; merge SPF into an existing record rather than adding a second one. A domain's status is the worst of its three records, and results are cached for a few minutes, so use Re-check DNS after publishing. The SPF check is shallow: it looks for this server's address in the record itself and does not follow <code>include:</code> or <code>redirect=</code>, so a record that relies on them is reported as a warning, not a failure. <code>p=none</code> does not affect delivery; tighten to <code>quarantine</code>, then <code>reject</code>, once the reports look clean. A domain's report address is set in its Domain settings.</p></div>
|
||||
<div class="sp-help-topic" id="apps"><h3>Applications</h3><p>An application is one SASL login, unique across all domains. It may send as any address of its domain or only as listed addresses, and, with a list of client IPs set, only from those addresses. The password is shown once at creation or regeneration and is not stored in readable form.</p></div>
|
||||
<div class="sp-help-topic" id="sender-check"><h3>Sender check</h3><p>An application sends only as itself, and that holds for both senders a message has. The envelope sender is held against the login by Postfix. The <code>From:</code> line, the one a mail program shows and the one DMARC holds the DKIM signature to, is held against it by the panel, under the same rule: an application set to any address of its domain may write any address of that domain, and one with a list of addresses only those. A message with any other <code>From:</code> - another of your domains, the server's own name, an address somewhere else, or none at all - is refused for good with <code>550 5.7.1</code>. The line must hold one address, read as a mail program reads it: what a display name around it says is not judged, and a line holding more than one address is refused. It is a rejected row of the Outbound log, and its page says which address was refused. This is what keeps one application from sending, under this server's signature, as another domain or another application.<br><br>The check is strict. While the panel does not answer, Postfix does not accept mail unchecked: it replies with a temporary error and the application sends again later. Server › Health counts what that costs: for how long the panel did not answer while Postfix itself was running; how many connections Postfix turned away because of it - it refuses from the connection on, before a message is offered, so a client that tries three times is three and a monitor that only connects is one; and how many messages were refused with a temporary error in the middle, when the panel stopped answering after a message had begun. A time marked ≈ includes a start at which supervisord could not say since when Postfix was running: the time since the panel's last sign of life is counted, which may be more than Postfix was up. The card warns when a connection was turned away or a message refused in the last 24 hours. If the numbers stay at nothing, the check is where it should be; if they do not, it belongs in a process of its own.</p></div>
|
||||
<div class="sp-help-topic" id="limits"><h3>Rate limits</h3><p>Level 1 is a per-IP ceiling set in <code>.env</code>. Level 2 is optional, per domain and per application, entered by hand or computed from the last 30 days of sending, and can never exceed level 1. An application's own limit replaces its domain's.</p></div>
|
||||
<div class="sp-help-topic" id="log"><h3>Outbound log</h3><p>One row per message and recipient, with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out) and rejected (refused before queueing under a level-2 rate limit, so it never reached Postfix). A message's page lists these steps and the lines Postfix wrote about it in <code>mail.log</code>, found by queue id; that file is rotated daily and about two weeks are kept, so an old message may show none. Rows are deleted after the retention set in Server › Settings, 90 days by default. A domain administrator sees only the rows of their own domains.</p></div>
|
||||
<div class="sp-help-topic" id="log"><h3>Outbound log</h3><p>One row per message and recipient, with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out; or closed by the panel when Postfix no longer held the message and <code>mail.log</code> never said what became of it) and rejected (refused before queueing, so it never reached the queue: under a level-2 rate limit, from a client address the application does not allow, or for a <code>From:</code> line that is not the application's - the message's page says which). A message's page lists these steps and the lines Postfix wrote about it in <code>mail.log</code>, found by queue id - and for a message refused before it had one, Postfix's one line about the refusal. The lines are kept with the row: they stay on the page after that file has rotated, for as long as the row does. A message accepted before version 2.2.0 shows them only while they are still in the current file. Rows are deleted after the retention set in Server › Settings, 90 days by default, and their lines with them. A domain administrator sees only the rows of their own domains.</p></div>
|
||||
<div class="sp-help-topic" id="queue"><h3>Queue and retries</h3><p>The page lists, as <code>postqueue -p</code> prints it, the messages Postfix has accepted and not yet delivered: mail being sent right now and mail deferred for a later attempt. Only the global role can open it, and it only shows; nothing here deletes or forces a message. A deferred message is tried again after the first retry delay, then at gaps that double up to a cap, until the receiving server takes it or the queue lifetime runs out and it bounces; there is no attempt counter, and the box on the page shows this server's values. A queue that keeps growing means a receiver is refusing or not answering: open the message from the Outbound log to read the reason Postfix logged.</p></div>
|
||||
<div class="sp-help-topic" id="dmarc"><h3>DMARC reports</h3><p>An aggregate report is the summary a receiving provider sends, usually daily, to the <code>rua=</code> address of your DMARC record: which servers sent mail as your domain and whether SPF or DKIM passed for it. A domain's report address is one of three choices, set in its Domain settings: no reports (the default for a new domain), the SelfPost hosted address (an address on this server whose reports are parsed and shown under DMARC reports; offered only while hosted reports are enabled on the server), or an address typed for the domain (an ordinary mailbox that receives the raw XML; a button fills in your own profile e-mail). When the typed address is on another domain than the one sending, that domain must publish a report-authorization record, or providers will not send; the domain's page shows it with the other DNS records and checks it. In the tables pass means SPF or DKIM passed and aligned, fail means neither did, and your own server shows as <em>this relay</em>. A fail from any other source is someone else sending as your domain — a forwarder, an application you forgot, or abuse — so do not tighten <code>p=</code> until that sender is fixed or removed. A domain administrator reads the reports of their own domains.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Inbound</h2></div>
|
||||
<div class="sp-help-topic" id="inbound"><h3>Backup-MX and forwarding</h3><p>An optional backup-MX or forwarder, switched on in the server's <code>.env</code>; the Inbound menu exists only then. Postfix accepts mail on port 25 for the listed domains only and hands each message on to that domain's upstream; there are no mailboxes. For each domain publish the MX record its page shows, and keep a primary MX the domain already has if this is a backup. The MX tag is green when at least one MX of the domain points at this server, and a domain accepts no mail until an upstream is saved on its page. Adding and deleting domains is for the global role.</p></div>
|
||||
<div class="sp-help-topic" id="upstream"><h3>Upstream</h3><p>The upstream is the server that actually holds the mailboxes. Give its host and port — used exactly as written, with no MX lookup — and choose the TLS for the hop to it: Opportunistic uses TLS when the upstream offers it and otherwise sends in the clear, Required holds the mail until a TLS session is possible, Off never uses TLS. While the upstream does not answer, Postfix keeps the mail and retries as for any other mail.</p></div>
|
||||
<div class="sp-help-topic" id="upstream"><h3>Upstream</h3><p>The upstream is the server that actually holds the mailboxes. Give its host and port — used exactly as written, with no MX lookup — and choose the TLS for the hop to it: Opportunistic uses TLS when the upstream offers it and otherwise sends in the clear; Required sends only over TLS, and the mail waits in the queue while the upstream does not offer it or the TLS handshake fails - an upstream that answers STARTTLS with a permanent error bounces it; Off never uses TLS. While the upstream does not answer, Postfix keeps the mail and retries as for any other mail.</p></div>
|
||||
<div class="sp-help-topic" id="recipients"><h3>Valid recipients</h3><p>Choose whom this domain accepts. Listed addresses only: the relay accepts exactly the addresses you list, one per line or comma-separated, and refuses any other while the sender is still connected, so it never has to send a bounce for them. Any recipient: everything at the domain is accepted and passed on, and an address the upstream then rejects comes back as a bounce from this server to a sender who is often forged — backscatter. Prefer a list, and keep it in step with the mailboxes upstream.</p></div>
|
||||
<div class="sp-help-topic" id="filter"><h3>Spam filter</h3><p>The spam filter belongs to the server, not to a domain. The server's administrator attaches it in the server's <code>.env</code>, and from then on every inbound domain is screened by it before mail is handed on; the box shows on or off and has nothing to switch. Off means mail goes to the upstream unfiltered. While the filter is down, mail is either accepted unfiltered (the default) or deferred, so the sending server tries again later, as the server's setting says; the box states which.</p></div>
|
||||
<div class="sp-help-topic" id="filter"><h3>Spam filter</h3><p>The spam filter belongs to the server, not to a domain. The server's administrator attaches it in the server's <code>.env</code>, and from then on every inbound domain is screened by it before mail is handed on; the box shows on or off and has nothing to switch. Off means mail goes to the upstream unfiltered. While the filter is down, mail is either accepted unfiltered (the default) or refused with a temporary error, so the sending server tries again later, as the server's setting says; the box states which.</p></div>
|
||||
<div class="sp-help-topic" id="inbound-log"><h3>Inbound log</h3><p>One row per recipient of a message offered on port 25 for an inbound domain, with what was decided: <span class="tag is-success is-light">accept</span> — handed on to the upstream; <span class="tag is-danger is-light">reject</span> — refused for good; <span class="tag is-warning is-light">tempfail</span> — the sender was asked to come back later; <span class="tag is-primary is-light">quarantine</span> — held by the filter. <em>Why</em> names who decided. <code>rspamd</code> is the spam filter, with its action, the score against the reject threshold and the heaviest symbols; <code>postfix</code> is the relay's own rule — an unknown recipient, a sender domain that does not exist — with its reply; <code>selfpost</code> means nobody judged the message: it was relayed with no filter attached. Accept says what this server did, not what the upstream did with the message afterwards.<br><br>Accepted mail is recorded as it passes; refused mail is read from <code>mail.log</code>, so its row has no subject, and a message refused after its text was sent is one row however many recipients it had. A refusal to relay for a domain that is not an inbound one is every scanner on the internet and is not recorded. Rows are kept for the number of days set in Settings, and never more than 10 000.</p></div>
|
||||
<div class="sp-help-topic" id="filter-lists"><h3>Filter lists</h3><p>Two lists for the whole server that correct the spam filter by hand. An entry is a sender address, a sender domain — that domain, not its subdomains — or a client address or network in CIDR form. A sender on the Deny list is refused outright. A sender on the Allow list is given a weight no spam score outweighs, so the filter lets it through. The sender's address is whatever the sending server says it is - anyone can claim to be a sender you allow - so allow a client address or network where you can, and a sender address only for mail you would rather receive forged than lose. When entries of both lists match one message, <strong>Deny wins</strong>.<br><br>SelfPost does not apply the lists itself: it writes them as files under <code>/data/antispam/</code>, and rspamd rereads a file within seconds of a change. With the spam filter off the lists are kept and can be edited, but nothing reads them. The sender matched is the envelope sender — the one in the log — not the From line a mail program shows. Matched counts the messages in the Inbound log that the entry decided, so it reaches back as far as the log does; never means not once in that time, and such an entry is one to consider removing.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div>
|
||||
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys, application credentials and the queue), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
|
||||
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys and application credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. The Postfix queue is not in it. The backup is taken while the server is running, and Postfix keeps moving messages through the queue, so a copy could hold a message twice or miss it; mail still waiting for delivery is therefore not carried over, and a restored server starts with an empty queue. To keep the queue, stop the container and archive the project directory instead. The private key in <code>certs/</code> has to be readable by the panel: give it to group 10998 on the host (<code>chgrp 10998</code>, <code>chmod 640</code>). Until then the panel refuses to make a backup and names the file, rather than hand over an archive without the key. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
|
||||
<div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out of their other sessions.</p></div>
|
||||
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it.</p></div>
|
||||
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. With the inbound relay on there is a second number, for the Inbound log, with the same range. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it. Notifications are turned on or off here for everyone.</p></div>
|
||||
<div class="sp-help-topic" id="notifications"><h3>Notifications</h3><p>The panel writes to its users when something needs them, from <code>postmaster@</code> the server's hostname. Four things are reported. A sending domain's SPF, DKIM or DMARC record stops matching: sent once two hourly checks in a row have found it so, and once more when the record matches again. A level-2 rate limit refuses mail: sent at the first refusal, then at most once a day for the same limit. The TLS certificate is 14 days from expiry, and again at 3 days if it is still the same certificate. A message has waited in the queue for more than three days, with one more message when none is left. A global user is told all of it; a domain administrator is told about the DNS records and limits of their own domains.<br><br>Each user turns notifications on or off under Account and needs an e-mail there; Server › Settings turns them off for everyone. They are the panel's own mail: not rows of the Outbound log, and not counted against any limit.<br><br>For that mail to arrive, the server's own name needs two DNS records of its own, as each of your domains does: a DKIM key, which the panel generates and signs notifications with, and an SPF record, <code>v=spf1 a -all</code>. Server › Settings gives both with the values to publish, and Server › Health shows whether they are in place. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains does not depend on either.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">05</span><h2>Account</h2></div>
|
||||
<div class="sp-help-topic" id="account"><h3>Your account</h3><p>Account is your own, whatever your role: your username and e-mail, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected.</p></div>
|
||||
<div class="sp-help-topic" id="account"><h3>Your account</h3><p>Account is your own, whatever your role: your username and e-mail, whether the panel sends you notifications, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Add an entry</h2><a class="sp-help" href="help.html#inbound" title="How the lists are applied"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Add an entry</h2><a class="sp-help" href="help.html#filter-lists" title="How the lists are applied"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<form class="sp-form" method="post" action="#">
|
||||
<div class="columns">
|
||||
@@ -84,7 +84,7 @@
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<div class="field"><label class="label" for="etype">Entry is</label>
|
||||
<div class="control"><div class="select is-fullwidth"><select id="etype" name="entry_type"><option>a sender address</option><option>a sender domain</option><option>a client IP or CIDR</option></select></div></div></div>
|
||||
<div class="control"><div class="select is-fullwidth"><select id="etype" name="entry_type"><option>a sender address</option><option>a sender domain</option><option>a client address or network</option></select></div></div></div>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<div class="field"><label class="label" for="value">Value</label>
|
||||
@@ -103,11 +103,11 @@
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Deny</h2><div class="sp-end">3 entries · wins over Allow</div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Value</th><th>Note</th><th>Added</th><th></th></tr></thead>
|
||||
<thead><tr><th>Value</th><th>Kind</th><th>Matched</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-small">@bulk.example <span class="tag is-light">domain</span></td><td class="sp-muted">newsletter nobody subscribed to</td><td class="sp-muted sp-nowrap sp-small">2026-09-02</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
|
||||
<tr><td class="sp-mono sp-small">198.51.100.0/24 <span class="tag is-light">ip</span></td><td class="sp-muted">dictionary attack on lists.example.org</td><td class="sp-muted sp-nowrap sp-small">2026-08-19</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
|
||||
<tr><td class="sp-mono sp-small">win@lottery.example <span class="tag is-light">address</span></td><td class="sp-muted">—</td><td class="sp-muted sp-nowrap sp-small">2026-07-30</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
|
||||
<tr><td class="sp-mono sp-small">bulk.example</td><td><button type="button" class="tag is-light" popovertarget="deny-1">domain</button><div class="sp-sheet" id="deny-1" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-light">domain</span><h2>bulk.example</h2><button type="button" popovertarget="deny-1" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Note</th><td>newsletter nobody subscribed to</td></tr><tr><th>Added</th><td class="sp-mono sp-small">2026-09-02</td></tr><tr><th>Matched</th><td>41 in the last 90 days</td></tr><tr><th>Last match</th><td class="sp-mono sp-small">2026-09-21 13:57</td></tr></tbody></table></div></div></div></div></td><td class="sp-nowrap">41</td><td class="sp-actions"><form method="post" action="#"><button type="submit" class="has-text-danger">Remove</button></form></td></tr>
|
||||
<tr><td class="sp-mono sp-small">198.51.100.0/24</td><td><button type="button" class="tag is-light" popovertarget="deny-2">ip</button><div class="sp-sheet" id="deny-2" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-light">ip</span><h2>198.51.100.0/24</h2><button type="button" popovertarget="deny-2" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Note</th><td>dictionary attack on lists.example.org</td></tr><tr><th>Added</th><td class="sp-mono sp-small">2026-08-19</td></tr><tr><th>Matched</th><td>1203 in the last 90 days</td></tr><tr><th>Last match</th><td class="sp-mono sp-small">2026-09-21 09:12</td></tr></tbody></table></div></div></div></div></td><td class="sp-nowrap">1203</td><td class="sp-actions"><form method="post" action="#"><button type="submit" class="has-text-danger">Remove</button></form></td></tr>
|
||||
<tr><td class="sp-mono sp-small">win@lottery.example</td><td><button type="button" class="tag is-light" popovertarget="deny-3">address</button><div class="sp-sheet" id="deny-3" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-light">address</span><h2>win@lottery.example</h2><button type="button" popovertarget="deny-3" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Note</th><td>—</td></tr><tr><th>Added</th><td class="sp-mono sp-small">2026-07-30</td></tr><tr><th>Matched</th><td>never in the last 90 days</td></tr></tbody></table></div></div></div></div></td><td class="sp-muted sp-nowrap">never</td><td class="sp-actions"><form method="post" action="#"><button type="submit" class="has-text-danger">Remove</button></form></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
@@ -116,10 +116,10 @@
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Allow</h2><div class="sp-end">2 entries</div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Value</th><th>Note</th><th>Added</th><th></th></tr></thead>
|
||||
<thead><tr><th>Value</th><th>Kind</th><th>Matched</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-small">@partner.example <span class="tag is-light">domain</span></td><td class="sp-muted">invoices were scored as spam</td><td class="sp-muted sp-nowrap sp-small">2026-09-11</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
|
||||
<tr><td class="sp-mono sp-small">noreply@github.example <span class="tag is-light">address</span></td><td class="sp-muted">security advisories</td><td class="sp-muted sp-nowrap sp-small">2026-06-04</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
|
||||
<tr><td class="sp-mono sp-small">partner.example</td><td><button type="button" class="tag is-light" popovertarget="allow-1">domain</button><div class="sp-sheet" id="allow-1" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-light">domain</span><h2>partner.example</h2><button type="button" popovertarget="allow-1" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Note</th><td>invoices were scored as spam</td></tr><tr><th>Added</th><td class="sp-mono sp-small">2026-09-11</td></tr><tr><th>Matched</th><td>7 in the last 90 days</td></tr><tr><th>Last match</th><td class="sp-mono sp-small">2026-09-19 16:40</td></tr></tbody></table></div></div></div></div></td><td class="sp-nowrap">7</td><td class="sp-actions"><form method="post" action="#"><button type="submit" class="has-text-danger">Remove</button></form></td></tr>
|
||||
<tr><td class="sp-mono sp-small">noreply@github.example</td><td><button type="button" class="tag is-light" popovertarget="allow-2">address</button><div class="sp-sheet" id="allow-2" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-light">address</span><h2>noreply@github.example</h2><button type="button" popovertarget="allow-2" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Note</th><td>security advisories</td></tr><tr><th>Added</th><td class="sp-mono sp-small">2026-06-04</td></tr><tr><th>Matched</th><td>18 in the last 90 days</td></tr><tr><th>Last match</th><td class="sp-mono sp-small">2026-09-21 13:30</td></tr></tbody></table></div></div></div></div></td><td class="sp-nowrap">18</td><td class="sp-actions"><form method="post" action="#"><button type="submit" class="has-text-danger">Remove</button></form></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
@@ -130,10 +130,10 @@
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Where the lists go</h2><div class="sp-end"><span class="tag is-success is-light">applied</span></div></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Applied by</dt><dd>rspamd, from map files</dd></div>
|
||||
<div><dt>Files</dt><dd class="sp-mono sp-small">/data/antispam/allow.map<br>/data/antispam/deny.map</dd></div>
|
||||
<div><dt>Applied by</dt><dd>rspamd, from map files it rereads within seconds of a change</dd></div>
|
||||
<div><dt>Files</dt><dd><span class="sp-mono">/data/antispam/</span> — six, one for each list and kind of entry</dd></div>
|
||||
<div><dt>Last written</dt><dd>2026-09-11 08:40 UTC</dd></div>
|
||||
<div><dt>When the filter is off</dt><dd class="sp-muted">The lists are kept and editable, but nothing reads them until <code>INBOUND_ANTISPAM_MILTER</code> is set.</dd></div>
|
||||
<div><dt>When both lists match</dt><dd>Deny wins, whichever entries they are.</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker">Inbound</p>
|
||||
<h1 class="title is-3">Inbound log</h1>
|
||||
<p class="sp-lead">What port 25 decided about each message, one row per recipient. Accepts come from the inbound journal, rejects from <code>mail.log</code>; the filter's reasons appear when the spam filter is on.</p>
|
||||
<p class="sp-lead">What port 25 decided about each message, one row per recipient. Accepted mail is recorded as it passes, refused mail is read from <code>mail.log</code>; the filter's verdict appears when the spam filter is on. Rows older than 90 days are deleted — change that in <a href="settings.html">Settings</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,19 +83,20 @@
|
||||
<input class="input" type="date" name="to" value="2026-09-21" aria-label="To date">
|
||||
<button type="submit" class="button"><i class="ti ti-filter"></i>Filter</button>
|
||||
</form>
|
||||
<a class="sp-help" href="help.html#inbound-log" title="What the decisions mean"><i class="ti ti-help-circle"></i></a>
|
||||
</div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Time, UTC</th><th>From · client</th><th>To</th><th>Subject</th><th>Decision</th><th>Why</th><th></th></tr></thead>
|
||||
<thead><tr><th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:00:18</td><td>t.berg@<wbr>uni.example<br><span class="sp-mono sp-muted sp-small">192.0.2.77</span></td><td>dev@<wbr>lists.example.org</td><td class="sp-clip">Re: [dev] patch series v3</td><td><span class="tag is-success is-light">accept</span></td><td class="sp-muted sp-small">rspamd · score 0.4</td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:59:02</td><td>invoice@<wbr>partner-billing.example<br><span class="sp-mono sp-muted sp-small">203.0.113.140</span></td><td>owner@<wbr>lists.example.org</td><td class="sp-clip">Outstanding invoice — action required</td><td><span class="tag is-primary is-light">quarantine</span></td><td class="sp-muted sp-small">rspamd · score 9.8 · PHISHING, FORGED_SENDER</td><td class="sp-actions"><a href="in-quarantine-message.html">Review</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:57:41</td><td>promo@<wbr>bulk.example<br><span class="sp-mono sp-muted sp-small">198.51.100.90</span></td><td>sales@<wbr>lists.example.org</td><td class="sp-clip sp-muted">—</td><td><span class="tag is-danger is-light">reject</span></td><td class="sp-muted sp-small">postfix · 550 5.1.1 unknown recipient</td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:51:30</td><td>billing@<wbr>partner.example<br><span class="sp-mono sp-muted sp-small">198.51.100.12</span></td><td>owner@<wbr>lists.example.org</td><td class="sp-clip">Invoice 2026-0917</td><td><span class="tag is-danger is-light">reject</span></td><td class="sp-muted sp-small">rspamd · score 16.2 · BAYES_SPAM</td><td class="sp-actions"><a href="in-filter-lists.html">Allow sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:44:02</td><td>hr@<wbr>partner.example<br><span class="sp-mono sp-muted sp-small">192.0.2.31</span></td><td>jobs@<wbr>acme.io</td><td class="sp-clip">Application: backend engineer</td><td><span class="tag is-warning is-light">tempfail</span></td><td class="sp-muted sp-small">selfpost · upstream 10.0.4.12 timed out</td><td class="sp-actions"></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:30:55</td><td>noreply@<wbr>github.example<br><span class="sp-mono sp-muted sp-small">192.0.2.200</span></td><td>security@<wbr>lists.example.org</td><td class="sp-clip">[advisory] new report</td><td><span class="tag is-success is-light">accept</span></td><td class="sp-muted sp-small">rspamd · allow list</td><td class="sp-actions"></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:00:18</td><td>t.berg@<wbr>uni.example<br><span class="sp-mono sp-muted sp-small">192.0.2.77</span></td><td>dev@<wbr>lists.example.org</td><td class="sp-clip">Re: [dev] patch series v3</td><td><button type="button" class="tag is-success is-light" popovertarget="why-1">accept</button><div class="sp-sheet" id="why-1" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-success is-light">accept</span><h2>Why</h2><button type="button" popovertarget="why-1" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Verdict</th><td>no action, score 0.40 / 15.00</td></tr><tr><th>Subject</th><td>Re: [dev] patch series v3</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:59:02</td><td>invoice@<wbr>partner-billing.example<br><span class="sp-mono sp-muted sp-small">203.0.113.140</span></td><td>owner@<wbr>lists.example.org</td><td class="sp-clip">Outstanding invoice — action required</td><td><button type="button" class="tag is-success is-light" popovertarget="why-2">accept</button><div class="sp-sheet" id="why-2" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-success is-light">accept</span><h2>Why</h2><button type="button" popovertarget="why-2" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Verdict</th><td>add header, score 9.80 / 15.00</td></tr><tr><th>Symbols</th><td class="sp-mono sp-small">PHISHING(5.00)<br>FORGED_SENDER(3.00)<br>R_SPF_FAIL(1.00)</td></tr><tr><th>Subject</th><td>Outstanding invoice — action required</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:57:41</td><td>promo@<wbr>bulk.example<br><span class="sp-mono sp-muted sp-small">198.51.100.90</span></td><td>sales@<wbr>lists.example.org</td><td class="sp-clip sp-muted">—</td><td><button type="button" class="tag is-danger is-light" popovertarget="why-3">reject</button><div class="sp-sheet" id="why-3" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-danger is-light">reject</span><h2>Why</h2><button type="button" popovertarget="why-3" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>postfix</td></tr><tr><th>Reply</th><td class="sp-mono sp-small">550 5.1.1 <sales@lists.example.org>: Recipient address rejected: User unknown in relay recipient table</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:51:30</td><td>billing@<wbr>partner.example<br><span class="sp-mono sp-muted sp-small">198.51.100.12</span></td><td>owner@<wbr>lists.example.org</td><td class="sp-clip sp-muted">—</td><td><button type="button" class="tag is-danger is-light" popovertarget="why-4">reject</button><div class="sp-sheet" id="why-4" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-danger is-light">reject</span><h2>Why</h2><button type="button" popovertarget="why-4" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Reply</th><td class="sp-mono sp-small">5.7.1 Spam message rejected</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Allow sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:44:02</td><td>hr@<wbr>partner.example<br><span class="sp-mono sp-muted sp-small">192.0.2.31</span></td><td>jobs@<wbr>acme.io</td><td class="sp-clip sp-muted">—</td><td><button type="button" class="tag is-warning is-light" popovertarget="why-5">tempfail</button><div class="sp-sheet" id="why-5" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-warning is-light">tempfail</span><h2>Why</h2><button type="button" popovertarget="why-5" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Reply</th><td class="sp-mono sp-small">4.7.1 Try again later</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Allow sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:30:55</td><td>noreply@<wbr>github.example<br><span class="sp-mono sp-muted sp-small">192.0.2.200</span></td><td>security@<wbr>lists.example.org</td><td class="sp-clip">[advisory] new report</td><td><button type="button" class="tag is-success is-light" popovertarget="why-6">accept</button><div class="sp-sheet" id="why-6" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-success is-light">accept</span><h2>Why</h2><button type="button" popovertarget="why-6" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Verdict</th><td>no action, score -98.10 / 15.00</td></tr><tr><th>Symbols</th><td class="sp-mono sp-small">SELFPOST_ALLOW_FROM(-100.00)<br>R_DKIM_ALLOW(-0.20)</td></tr><tr><th>Subject</th><td>[advisory] new report</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="sp-box-foot"><span>Page 1 of 38</span><a href="#">Older →</a><span class="sp-end">Allow / Deny sender opens <a href="in-filter-lists.html">Filter lists</a> with the entry filled in · global administrator only</span></div>
|
||||
<div class="sp-box-foot"><span>Page 1 of 38</span><a href="#">Older →</a><span class="sp-end">A decision opens its reason · Allow / Deny sender opens <a href="in-filter-lists.html">Filter lists</a> with the entry filled in</span></div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
<ul class="sp-list-check">
|
||||
<li><i class="ti ti-x"></i>permanently delete its DKIM signing key;</li>
|
||||
<li><i class="ti ti-x"></i>delete <strong>both applications</strong> (<span class="sp-mono">prod-server</span>, <span class="sp-mono">alerts</span>) with their credentials and sender bindings;</li>
|
||||
<li><i class="ti ti-x"></i>reload OpenDKIM so the domain is no longer signed.</li>
|
||||
<li><i class="ti ti-x"></i>stop signing mail as the domain.</li>
|
||||
</ul>
|
||||
<p class="sp-muted mb-4">The published DKIM record becomes invalid — remove it from DNS unless you plan to re-add the domain. Inbound domains are not touched.</p>
|
||||
<form method="post" action="out-domains.html">
|
||||
|
||||
@@ -79,7 +79,9 @@
|
||||
<form class="sp-end" method="get" action="#">
|
||||
<div class="select"><select name="domain"><option>All domains</option><option>example.org</option><option>shop.example.org</option><option>notify.acme.io</option></select></div>
|
||||
<div class="select"><select name="app"><option>All applications</option><option>prod-server</option><option>alerts</option></select></div>
|
||||
<div class="select"><select name="status"><option>Any status</option><option>sent</option><option>deferred</option><option>bounced</option><option>rate-limited</option></select></div>
|
||||
<div class="select"><select name="status"><option>Any status</option><option>queued</option><option>sent</option><option>deferred</option><option>bounced</option><option>rejected</option></select></div>
|
||||
<input class="input" type="date" name="from" value="2026-09-14" aria-label="From date">
|
||||
<input class="input" type="date" name="to" value="2026-09-21" aria-label="To date">
|
||||
<button type="submit" class="button"><i class="ti ti-filter"></i>Filter</button>
|
||||
</form>
|
||||
<a class="sp-help" href="help.html#log" title="What the statuses mean"><i class="ti ti-help-circle"></i></a>
|
||||
@@ -91,7 +93,8 @@
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:50</td><td>shop@<wbr>shop.example.org</td><td>anna.lind@<wbr>outlook.com</td><td class="sp-clip">Your order #48212 has shipped</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:58:07</td><td>alerts@<wbr>example.org</td><td>noc@<wbr>example.org</td><td class="sp-clip">[FIRING] disk usage above 90 % on db-2</td><td><span class="tag is-warning is-light">deferred</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:41:19</td><td>no-reply@<wbr>notify.acme.io</td><td>j.doe@<wbr>nonexistent.example</td><td class="sp-clip">Reset your password</td><td><span class="tag is-danger is-light">bounced</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:40:02</td><td>prod@<wbr>example.org</td><td>billing@<wbr>partner.example</td><td class="sp-clip">Invoice 2026-0917</td><td><span class="tag is-light">rate-limited</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:40:55</td><td>shop@<wbr>shop.example.org</td><td>k.berg@<wbr>web.de</td><td class="sp-clip">A message from the director</td><td><span class="tag is-danger is-light">wrong From</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:40:02</td><td>prod@<wbr>example.org</td><td>billing@<wbr>partner.example</td><td class="sp-clip">Invoice 2026-0917</td><td><span class="tag is-danger is-light">rate-limited</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:12:44</td><td>shop@<wbr>shop.example.org</td><td>p.novak@<wbr>seznam.cz</td><td class="sp-clip">We received your return request</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Delivery log</h2><div class="sp-end">Lines from <code>mail.log</code> with this queue id</div></div>
|
||||
<pre class="sp-log"><span class="sp-d">13:58:07</span> postfix/smtpd[2114]: 4XcB7k2Jm9z1: client=unknown[198.51.100.7], sasl_username=alerts
|
||||
<span class="sp-d">13:58:07</span> postfix/cleanup[2120]: 4XcB7k2Jm9z1: message-id=<a81f0c@alertmanager>
|
||||
<span class="sp-d">13:58:07</span> opendkim[39]: 4XcB7k2Jm9z1: DKIM-Signature field added (s=sp2026, d=example.org)
|
||||
<span class="sp-d">13:58:07</span> postfix/qmgr[2061]: 4XcB7k2Jm9z1: from=<alerts@example.org>, size=2214, nrcpt=1 (queue active)
|
||||
<span class="sp-d">13:58:09</span> <span class="sp-w">postfix/smtp[2123]: 4XcB7k2Jm9z1: to=<noc@example.org>, relay=mx1.example.org[192.0.2.10]:25, delay=1.9, status=deferred (host mx1.example.org said: 451 4.7.1 Greylisted, try again in 5 minutes)</span></pre>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -79,36 +79,22 @@
|
||||
"columns": [
|
||||
{
|
||||
"is-6": [
|
||||
"box[Processes]:table",
|
||||
"box[Sender check]:table+body",
|
||||
"box[Hostname and reverse DNS]:body",
|
||||
"box[Machine]:table"
|
||||
]
|
||||
},
|
||||
{
|
||||
"is-6": [
|
||||
"box[Processes]:table"
|
||||
"box[Milter sockets]:table",
|
||||
"box[TLS certificate]:body",
|
||||
"box[Notification mail]:table+body",
|
||||
"box[Configuration]:body"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
"is-4": [
|
||||
"box[TLS certificate]:body"
|
||||
]
|
||||
},
|
||||
{
|
||||
"is-4": [
|
||||
"box[Milter sockets]:table"
|
||||
]
|
||||
},
|
||||
{
|
||||
"is-4": [
|
||||
"box[Hostname and reverse DNS]:body"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"box[Configuration]:body"
|
||||
}
|
||||
],
|
||||
"help": [
|
||||
"head",
|
||||
@@ -122,9 +108,9 @@
|
||||
{
|
||||
"is-9": [
|
||||
"box[Overview]:help×3",
|
||||
"box[Outbound]:help×6",
|
||||
"box[Inbound]:help×4",
|
||||
"box[Server]:help×3",
|
||||
"box[Outbound]:help×7",
|
||||
"box[Inbound]:help×6",
|
||||
"box[Server]:help×4",
|
||||
"box[Account]:help"
|
||||
]
|
||||
}
|
||||
@@ -406,12 +392,14 @@
|
||||
"columns": [
|
||||
{
|
||||
"is-6": [
|
||||
"box[Log retention]:body"
|
||||
"box[Log retention]:body",
|
||||
"box[Sending rate limits]:body"
|
||||
]
|
||||
},
|
||||
{
|
||||
"is-6": [
|
||||
"box[Sending rate limits]:body"
|
||||
"box[Notifications]:body",
|
||||
"box[Notification mail]:record×2"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
<a href="health.html"><span class="sp-h-icon"><i class="ti ti-server-cog"></i></span><span><span class="sp-h-name">Processes</span><span class="sp-h-value">5 of 5 running</span><span class="sp-h-sub">up 18 days</span></span></a>
|
||||
<a href="health.html" class="sp-warn"><span class="sp-h-icon"><i class="ti ti-certificate"></i></span><span><span class="sp-h-name">TLS certificate</span><span class="sp-h-value">Expires in 12 days</span><span class="sp-h-sub">renewal has not run</span></span></a>
|
||||
<a href="out-queue.html"><span class="sp-h-icon"><i class="ti ti-stack-2"></i></span><span><span class="sp-h-name">Queues</span><span class="sp-h-value">Both empty</span><span class="sp-h-sub">outbound and inbound</span></span></a>
|
||||
<a href="health.html"><span class="sp-h-icon"><i class="ti ti-plug-connected"></i></span><span><span class="sp-h-name">Milter sockets</span><span class="sp-h-value">3 of 3 answering</span><span class="sp-h-sub">incl. the spam filter</span></span></a>
|
||||
<a href="health.html"><span class="sp-h-icon"><i class="ti ti-plug-connected"></i></span><span><span class="sp-h-name">Milter sockets</span><span class="sp-h-value">5 of 5 answering</span><span class="sp-h-sub">incl. the spam filter</span></span></a>
|
||||
<a href="health.html"><span class="sp-h-icon"><i class="ti ti-arrows-exchange"></i></span><span><span class="sp-h-name">Reverse DNS</span><span class="sp-h-value">Forward = reverse</span><span class="sp-h-sub">203.0.113.25</span></span></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -82,11 +82,12 @@
|
||||
<div class="field"><label class="label" for="ret">Keep outbound log rows, days</label>
|
||||
<div class="control"><input class="input" id="ret" name="send_log_retention_days" type="number" min="7" max="365" value="30" required></div>
|
||||
<p class="help">The main driver of <code>/data</code> growth. Does not affect <code>mail.log</code> rotation (fourteen daily files).</p></div>
|
||||
<div class="field"><label class="label" for="inret">Keep inbound log rows, days</label>
|
||||
<div class="control"><input class="input" id="inret" name="inbound_spam_log_retention_days" type="number" min="7" max="365" value="90" required></div>
|
||||
<p class="help">The inbound log also never holds more than 10 000 rows: refused mail is logged at the rate strangers knock on port 25.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<div class="box" id="rate-limits">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Sending rate limits</h2><div class="sp-end">Read-only</div><a class="sp-help" href="help.html#limits" title="Levels 1 and 2"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
@@ -97,6 +98,43 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<div class="box" id="notifications">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Notifications</h2><a class="sp-help" href="help.html#notifications" title="What is sent and when"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="notify" checked> Send notification e-mails</label>
|
||||
<p class="help">From <span class="sp-mono">postmaster@mail.example.org</span>, to every user who has an e-mail and has not turned them off under <a href="account.html">Account</a>.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box" id="notification-mail">
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Notification mail</h2><div class="sp-end">DNS records of the server's own name</div><a class="sp-help" href="help.html#notifications" title="What the panel sends and what makes it arrive"><i class="ti ti-help-circle"></i></a></div>
|
||||
|
||||
<div class="sp-record">
|
||||
<div class="sp-record-head"><h3>DKIM</h3><span class="tag is-success is-light">published</span><small>Not a secret · selector <code>sp2026</code></small></div>
|
||||
<div class="sp-row">
|
||||
<div class="field"><label class="label">Host / name</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="sp2026._domainkey.mail.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
||||
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
|
||||
</div>
|
||||
<div class="field"><label class="label">Value to publish</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><textarea class="textarea" rows="5" readonly>v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq7n2Lw0c5vXeR1dYt8uJb3mKs6pHf9aZoWg4iNy2TqCx0lVjE7rUh5sBk1dMa8fPz3oGc6yXw9tIe4nQv2uL…IDAQAB</textarea></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
||||
</div>
|
||||
|
||||
<div class="sp-record">
|
||||
<div class="sp-record-head"><h3>SPF</h3><span class="tag is-warning is-light">missing</span><small>For the server's own name, not for your domains</small></div>
|
||||
<div class="sp-row">
|
||||
<div class="field"><label class="label">Host / name</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="mail.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
||||
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
|
||||
</div>
|
||||
<div class="field"><label class="label">Value to publish</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=spf1 a -all"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
||||
<p class="help">Notifications are sent without it, and a receiver may file them as spam.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons"><button type="submit" class="button is-primary">Save settings</button></div>
|
||||
</form>
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
<div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="admin" autocomplete="username" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div>
|
||||
<div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
|
||||
<p class="help">Your own address. A domain's report address can be filled with it from Domain settings.</p></div>
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="notify" checked> Send me notifications</label>
|
||||
<p class="help">To this address, when something needs you: a DNS record of a domain stops matching, a rate limit refuses mail, the TLS certificate is about to expire, a message has waited in the queue for three days. <a href="help.html#notifications">What is sent and when</a></p></div>
|
||||
<button type="submit" class="button is-primary">Save profile</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Full backup</h2><a class="sp-help" href="help.html#backup" title="Backups, encryption and restore"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="mb-3"><code>data/</code> (database, DKIM keys, credentials, the queue), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code> in one archive. Restore into an empty project directory and start the <strong>same SelfPost version</strong>. The reverse-proxy vhost is not included.</p>
|
||||
<p class="mb-3"><code>data/</code> (database, DKIM keys, credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code> in one archive. The Postfix queue is left out: it cannot be copied reliably while the server is running. Restore into an empty project directory and start the <strong>same SelfPost version</strong>. The reverse-proxy vhost is not included.</p>
|
||||
<form class="sp-form" method="post" action="#">
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="encrypt" checked> Encrypt with a password (<code>.spbk</code>)</label></div>
|
||||
<div class="sp-pair">
|
||||
|
||||
@@ -18,15 +18,16 @@
|
||||
<dl class="sp-facts">
|
||||
<div><dt>list</dt><dd>page_head, then full-width boxes: the add form first, the table second. <a href="out-domains.html">Example</a></dd></div>
|
||||
<div><dt>detail</dt><dd>page_head, then <code>columns</code>: <code>is-3</code> side menu + <code>is-9</code> boxes. <a href="out-domain.html">Example</a></dd></div>
|
||||
<div><dt>cards</dt><dd>page_head, then <code>columns sp-cards</code>: two <code>is-6</code> stacks of numbered boxes read in rows, odd on the left and even on the right, each stack closed up. On a phone the boxes come in the order of their numbers: each box carries <code>data-order</code>, its own number, up to twelve. The two stacks under this box are laid out so. <a href="health.html">Example</a></dd></div>
|
||||
<div><dt>form</dt><dd>page_head, then boxes in equal <code>columns</code>, one submit row under them. <a href="out-app.html">Example</a></dd></div>
|
||||
<div><dt>signed out</dt><dd>The split brand / form screen, no navbar. <a href="login.html">Example</a></dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="columns sp-cards">
|
||||
<div class="column is-6">
|
||||
<div class="box">
|
||||
<div class="box" data-order="1">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>postmark</h2><div class="sp-end">Worst status of the page's checks</div></div>
|
||||
<div class="sp-box-body">
|
||||
<div class="buttons">
|
||||
@@ -38,23 +39,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="box" data-order="3">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>status_tag</h2><div class="sp-end">One mapping, one function</div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Status</th><th>Tag</th><th>Bulma classes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>ok, published, running, sent</td><td><span class="tag is-success is-light">ok</span></td><td class="sp-mono sp-small">tag is-success is-light</td></tr>
|
||||
<tr><td>warn, deferred, quiet</td><td><span class="tag is-warning is-light">warn</span></td><td class="sp-mono sp-small">tag is-warning is-light</td></tr>
|
||||
<tr><td>error, fail, mismatch, bounced</td><td><span class="tag is-danger is-light">fail</span></td><td class="sp-mono sp-small">tag is-danger is-light</td></tr>
|
||||
<tr><td>error, fail, mismatch, bounced, reject, rate-limited</td><td><span class="tag is-danger is-light">fail</span></td><td class="sp-mono sp-small">tag is-danger is-light</td></tr>
|
||||
<tr><td>unknown, off, neutral counts</td><td><span class="tag is-light">off</span></td><td class="sp-mono sp-small">tag is-light</td></tr>
|
||||
<tr><td>held: quarantine · role: global</td><td><span class="tag is-primary is-light">quarantine</span></td><td class="sp-mono sp-small">tag is-primary is-light</td></tr>
|
||||
<tr><td>a tag that opens a <code>sheet</code></td><td><button type="button" class="tag is-danger is-light" popovertarget="kit-sheet">reject</button><div class="sp-sheet" id="kit-sheet" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-danger is-light">reject</span><h2>sheet</h2><button type="button" popovertarget="kit-sheet" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>What it holds</th><td>What does not fit the row, as name and value</td></tr><tr><th>How it closes</th><td class="sp-mono sp-small">Esc<br>a click outside<br>the cross</td></tr></tbody></table></div></div></div></div></td><td class="sp-mono sp-small">button.tag + sp-sheet (popover)</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<div class="box">
|
||||
<div class="box" data-order="2">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>box · box_head</h2><div class="sp-end">The end slot: a note, a tag, a link or a filter form</div><a class="sp-help" href="help.html#checks" title="Optional: the section's Help topic"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body"><p class="mb-2">The <i class="ti ti-help-circle"></i> after the end slot is <code>sp-help</code>: an optional link to the section's topic on the Help page. It replaces today's help drawer.</p><p>box_body — padded content. A <code>table</code> or a run of <code>sp-record</code> blocks sits directly in the box, without box_body.</p></div>
|
||||
<div class="sp-box-foot"><span>box_foot — a legend, paging</span><span class="sp-end">end slot</span></div>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div><dt>Last report</dt><dd>2026-09-21 06:12 UTC</dd></div>
|
||||
<div><dt>This week</dt><dd>14 kept · 0 parse failures</dd></div>
|
||||
<div><dt>Hosted addresses</dt><dd class="sp-mono sp-small">dmarc-reports+<domain>@mail.example.org</dd></div>
|
||||
<div><dt>Retention</dt><dd>500 reports or 90 days</dd></div>
|
||||
<div><dt>Retention</dt><dd>500 reports a domain or 90 days</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,10 +8,45 @@
|
||||
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="columns sp-cards">
|
||||
<div class="column is-6">
|
||||
<div class="box" id="machine">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Machine</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#checks" title="What these readings mean"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="box" id="processes" data-order="1">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Processes</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>postfix</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 41, uptime 18 days</td></tr>
|
||||
<tr><td>journal-milter</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 40, uptime 18 days</td></tr>
|
||||
<tr><td>panel</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 44, uptime 18 days</td></tr>
|
||||
<tr><td>dmarc-ingest</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 47, uptime 18 days</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
<div class="box" id="sender-check" data-order="3">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Sender check</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#sender-check" title="What is checked and what these numbers are for"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Period</th><th>Panel not answering</th><th>Connections turned away</th><th>Messages deferred</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Today</td><td>0 s</td><td>0</td><td>0</td></tr>
|
||||
<tr><td>Last 30 days</td><td>4 s</td><td>0</td><td>0</td></tr>
|
||||
<tr><td>Last 12 months</td><td>≈ 41 s</td><td>3</td><td>1</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="sp-muted">The panel holds each message's <code>From:</code> against the application that sends it. While the panel does not answer, Postfix answers the sending application with a temporary error instead of accepting mail unchecked, and the application tries again later. Time is counted only while Postfix itself was running; ≈ marks a period with a start at which that could not be asked, and its time is at most what is shown. Counted since <span class="sp-mono">2026-10-13</span>.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" id="hostname" data-order="5">
|
||||
<div class="sp-box-head"><span class="sp-no">05</span><h2>Hostname and reverse DNS</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#rdns" title="Forward and reverse DNS"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Hostname</dt><dd class="sp-mono">mail.example.org</dd></div>
|
||||
<div><dt>Lookup</dt><dd class="sp-mono">→ 203.0.113.25<br>← mail.example.org</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" id="machine" data-order="7">
|
||||
<div class="sp-box-head"><span class="sp-no">07</span><h2>Machine</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#checks" title="What these readings mean"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
@@ -23,66 +58,50 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<div class="box" id="processes">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Processes</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>postfix</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 41, uptime 18 days</td></tr>
|
||||
<tr><td>opendkim</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 39, uptime 18 days</td></tr>
|
||||
<tr><td>journal-milter</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 40, uptime 18 days</td></tr>
|
||||
<tr><td>panel</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 44, uptime 18 days</td></tr>
|
||||
<tr><td>dmarc-ingest</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 47, uptime 18 days</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-4">
|
||||
<div class="box" id="certificate">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>TLS certificate</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div><a class="sp-help" href="help.html#certificate" title="When it warns and what to do"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Expires</dt><dd class="sp-mono">2026-10-03 09:14 UTC</dd></div>
|
||||
<div><dt>Names</dt><dd class="sp-mono">mail.example.org</dd></div>
|
||||
</dl>
|
||||
<p class="mt-4 has-text-danger">Expires in 12 days — renewal has not run.</p>
|
||||
<p class="sp-muted">Renewal normally runs 30 days before expiry. Check that port 80 is reachable and look for <code>acme</code> lines in the <a href="system-log.html">system log</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<div class="box" id="sockets">
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Milter sockets</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
|
||||
<div class="box" id="sockets" data-order="2">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Milter sockets</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Milter</th><th>State</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>opendkim<br><span class="sp-muted sp-mono sp-small">inet:127.0.0.1:8891</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>dkim-signer <span class="sp-muted">· applications</span><br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/sign.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>dkim-signer <span class="sp-muted">· notifications</span><br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/sign-local.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>journal-milter<br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/journal.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>sender-check<br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/sender.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>rspamd <span class="sp-muted">· inbound spam filter</span><br><span class="sp-muted sp-mono sp-small">inet:antispam:11332</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<div class="box" id="hostname">
|
||||
<div class="sp-box-head"><span class="sp-no">05</span><h2>Hostname and reverse DNS</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#rdns" title="Forward and reverse DNS"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="box" id="certificate" data-order="4">
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>TLS certificate</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#certificate" title="When it warns and what to do"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Hostname</dt><dd class="sp-mono">mail.example.org</dd></div>
|
||||
<div><dt>Lookup</dt><dd class="sp-mono">→ 203.0.113.25<br>← mail.example.org</dd></div>
|
||||
<div><dt>Expires</dt><dd class="sp-mono">2026-11-02 09:14 UTC</dd></div>
|
||||
<div><dt>Names</dt><dd class="sp-mono">mail.example.org</dd></div>
|
||||
<div><dt>Renewal expires</dt><dd class="sp-mono">2027-01-31 08:02 UTC</dd></div>
|
||||
<div><dt>Renewal mounted</dt><dd class="sp-mono">2026-10-15 03:12 UTC</dd></div>
|
||||
</dl>
|
||||
<p class="mt-4 sp-muted">Postfix still serves the certificate above. A renewed one is waiting in the mounted file; Postfix takes it up at its next check for a renewal, at most a day after it was mounted.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" id="notification-mail" data-order="6">
|
||||
<div class="sp-box-head"><span class="sp-no">06</span><h2>Notification mail</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div><a class="sp-help" href="help.html#notifications" title="What the panel sends and what makes it arrive"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Record</th><th>State</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>DKIM<br><span class="sp-muted sp-mono sp-small">sp2026._domainkey.mail.example.org</span></td><td><span class="tag is-success is-light">published</span></td></tr>
|
||||
<tr><td>SPF<br><span class="sp-muted sp-mono sp-small">mail.example.org</span></td><td><span class="tag is-warning is-light">missing</span></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="sp-muted">Sent from <span class="sp-mono">postmaster@mail.example.org</span>. What to publish is under <a href="settings.html#notification-mail">Settings</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" id="configuration" data-order="8">
|
||||
<div class="sp-box-head"><span class="sp-no">08</span><h2>Configuration</h2><div class="sp-end">Safe to run at any time</div></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="mb-3">Regenerates the Postfix configuration and the spam filter's lists from the database, removes the keys of deleted domains, and reloads Postfix. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
|
||||
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-reload"></i>Reload configuration</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box" id="configuration">
|
||||
<div class="sp-box-head"><span class="sp-no">06</span><h2>Configuration</h2><div class="sp-end">Safe to run at any time</div></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="mb-3">Regenerates the OpenDKIM and Postfix configuration from the database and reloads both daemons. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
|
||||
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-reload"></i>Reload configuration</button></form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<ul class="menu-list">
|
||||
<li><a href="#dns"><i class="ti ti-world-check"></i>DNS records</a></li>
|
||||
<li><a href="#apps"><i class="ti ti-apps"></i>Applications</a></li>
|
||||
<li><a href="#sender-check"><i class="ti ti-shield-check"></i>Sender check</a></li>
|
||||
<li><a href="#limits"><i class="ti ti-gauge"></i>Rate limits</a></li>
|
||||
<li><a href="#log"><i class="ti ti-list-details"></i>Outbound log</a></li>
|
||||
<li><a href="#queue"><i class="ti ti-stack-2"></i>Queue and retries</a></li>
|
||||
@@ -31,12 +32,15 @@
|
||||
<li><a href="#upstream"><i class="ti ti-arrow-forward-up"></i>Upstream</a></li>
|
||||
<li><a href="#recipients"><i class="ti ti-address-book"></i>Valid recipients</a></li>
|
||||
<li><a href="#filter"><i class="ti ti-shield-check"></i>Spam filter</a></li>
|
||||
<li><a href="#inbound-log"><i class="ti ti-list-details"></i>Inbound log</a></li>
|
||||
<li><a href="#filter-lists"><i class="ti ti-filter"></i>Filter lists</a></li>
|
||||
</ul>
|
||||
<p class="menu-label">Server</p>
|
||||
<ul class="menu-list">
|
||||
<li><a href="#backup"><i class="ti ti-archive"></i>Backup and moving a domain</a></li>
|
||||
<li><a href="#users"><i class="ti ti-users"></i>Users and roles</a></li>
|
||||
<li><a href="#settings"><i class="ti ti-settings"></i>Settings</a></li>
|
||||
<li><a href="#notifications"><i class="ti ti-mail"></i>Notifications</a></li>
|
||||
</ul>
|
||||
<p class="menu-label">Account</p>
|
||||
<ul class="menu-list">
|
||||
@@ -48,34 +52,38 @@
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Overview</h2></div>
|
||||
<div class="sp-help-topic" id="checks"><h3>Health checks</h3><p>Six checks, and the worst of them is the postmark. <span class="tag is-success is-light">ok</span> needs nothing; <span class="tag is-warning is-light">warn</span> still delivers mail but will stop doing so if ignored; <span class="tag is-danger is-light">fail</span> means mail is being lost or refused now. <a href="health.html">Health</a> shows the readings behind them: CPU and memory are the container's own, not the host's spare capacity, and network is a short window, not a daily total.</p></div>
|
||||
<div class="sp-help-topic" id="certificate"><h3>TLS certificate</h3><p>The certificate Postfix presents on 465 and 587. The reverse proxy or the image mounts it, the panel does not issue it. It warns 14 days before expiry and fails once expired, or when the file is missing or unreadable — clients that verify certificates then refuse to connect.</p></div>
|
||||
<div class="sp-help-topic" id="certificate"><h3>TLS certificate</h3><p>The certificate Postfix presents on 465 and 587. The reverse proxy or the image mounts it, the panel does not issue it. Postfix serves its own copy of the mounted file and looks for a renewed one once a day by default; until it takes one up, the card shows it as a renewal waiting, and warns once it has waited longer than that check allows - the container log's cert-reload lines say why. It warns 14 days before expiry unless a renewal is already waiting, and fails once expired, or when the file is missing or unreadable — clients that verify certificates then refuse to connect.</p></div>
|
||||
<div class="sp-help-topic" id="rdns"><h3>Hostname and reverse DNS</h3><p>The hostname (<code>SELFPOST_HOSTNAME</code>) must resolve to this server's address, and that address must resolve back to the hostname. Large receivers reject or spam-folder mail from hosts where the two disagree. The PTR record is set at your hosting provider, not in your DNS zone.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Outbound</h2></div>
|
||||
<div class="sp-help-topic" id="dns"><h3>DNS records</h3><p>DKIM proves the message was signed by this relay, SPF lists the addresses allowed to send for the domain, DMARC tells receivers what to do when both fail and where to report. Publish all three exactly as shown; merge SPF into an existing record rather than adding a second one. A domain's status is the worst of its three records, and results are cached for a few minutes, so use Re-check DNS after publishing. The SPF check is shallow: it looks for this server's address in the record itself and does not follow <code>include:</code> or <code>redirect=</code>, so a record that relies on them is reported as a warning, not a failure. <code>p=none</code> does not affect delivery; tighten to <code>quarantine</code>, then <code>reject</code>, once the reports look clean. A domain's report address is set in its Domain settings.</p></div>
|
||||
<div class="sp-help-topic" id="apps"><h3>Applications</h3><p>An application is one SASL login, unique across all domains. It may send as any address of its domain or only as listed addresses, and, with a list of client IPs set, only from those addresses. The password is shown once at creation or regeneration and is not stored in readable form.</p></div>
|
||||
<div class="sp-help-topic" id="sender-check"><h3>Sender check</h3><p>An application sends only as itself, and that holds for both senders a message has. The envelope sender is held against the login by Postfix. The <code>From:</code> line, the one a mail program shows and the one DMARC holds the DKIM signature to, is held against it by the panel, under the same rule: an application set to any address of its domain may write any address of that domain, and one with a list of addresses only those. A message with any other <code>From:</code> - another of your domains, the server's own name, an address somewhere else, or none at all - is refused for good with <code>550 5.7.1</code>. The line must hold one address, read as a mail program reads it: what a display name around it says is not judged, and a line holding more than one address is refused. It is a rejected row of the Outbound log, and its page says which address was refused. This is what keeps one application from sending, under this server's signature, as another domain or another application.<br><br>The check is strict. While the panel does not answer, Postfix does not accept mail unchecked: it replies with a temporary error and the application sends again later. Server › Health counts what that costs: for how long the panel did not answer while Postfix itself was running; how many connections Postfix turned away because of it - it refuses from the connection on, before a message is offered, so a client that tries three times is three and a monitor that only connects is one; and how many messages were refused with a temporary error in the middle, when the panel stopped answering after a message had begun. A time marked ≈ includes a start at which supervisord could not say since when Postfix was running: the time since the panel's last sign of life is counted, which may be more than Postfix was up. The card warns when a connection was turned away or a message refused in the last 24 hours. If the numbers stay at nothing, the check is where it should be; if they do not, it belongs in a process of its own.</p></div>
|
||||
<div class="sp-help-topic" id="limits"><h3>Rate limits</h3><p>Level 1 is a per-IP ceiling set in <code>.env</code>. Level 2 is optional, per domain and per application, entered by hand or computed from the last 30 days of sending, and can never exceed level 1. An application's own limit replaces its domain's.</p></div>
|
||||
<div class="sp-help-topic" id="log"><h3>Outbound log</h3><p>One row per message and recipient, with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out) and rejected (refused before queueing under a level-2 rate limit, so it never reached Postfix). A message's page lists these steps and the lines Postfix wrote about it in <code>mail.log</code>, found by queue id; that file is rotated daily and about two weeks are kept, so an old message may show none. Rows are deleted after the retention set in Server › Settings, 90 days by default. A domain administrator sees only the rows of their own domains.</p></div>
|
||||
<div class="sp-help-topic" id="log"><h3>Outbound log</h3><p>One row per message and recipient, with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out; or closed by the panel when Postfix no longer held the message and <code>mail.log</code> never said what became of it) and rejected (refused before queueing, so it never reached the queue: under a level-2 rate limit, from a client address the application does not allow, or for a <code>From:</code> line that is not the application's - the message's page says which). A message's page lists these steps and the lines Postfix wrote about it in <code>mail.log</code>, found by queue id - and for a message refused before it had one, Postfix's one line about the refusal. The lines are kept with the row: they stay on the page after that file has rotated, for as long as the row does. A message accepted before version 2.2.0 shows them only while they are still in the current file. Rows are deleted after the retention set in Server › Settings, 90 days by default, and their lines with them. A domain administrator sees only the rows of their own domains.</p></div>
|
||||
<div class="sp-help-topic" id="queue"><h3>Queue and retries</h3><p>The page lists, as <code>postqueue -p</code> prints it, the messages Postfix has accepted and not yet delivered: mail being sent right now and mail deferred for a later attempt. Only the global role can open it, and it only shows; nothing here deletes or forces a message. A deferred message is tried again after the first retry delay, then at gaps that double up to a cap, until the receiving server takes it or the queue lifetime runs out and it bounces; there is no attempt counter, and the box on the page shows this server's values. A queue that keeps growing means a receiver is refusing or not answering: open the message from the Outbound log to read the reason Postfix logged.</p></div>
|
||||
<div class="sp-help-topic" id="dmarc"><h3>DMARC reports</h3><p>An aggregate report is the summary a receiving provider sends, usually daily, to the <code>rua=</code> address of your DMARC record: which servers sent mail as your domain and whether SPF or DKIM passed for it. A domain's report address is one of three choices, set in its Domain settings: no reports (the default for a new domain), the SelfPost hosted address (an address on this server whose reports are parsed and shown under DMARC reports; offered only while hosted reports are enabled on the server), or an address typed for the domain (an ordinary mailbox that receives the raw XML; a button fills in your own profile e-mail). When the typed address is on another domain than the one sending, that domain must publish a report-authorization record, or providers will not send; the domain's page shows it with the other DNS records and checks it. In the tables pass means SPF or DKIM passed and aligned, fail means neither did, and your own server shows as <em>this relay</em>. A fail from any other source is someone else sending as your domain — a forwarder, an application you forgot, or abuse — so do not tighten <code>p=</code> until that sender is fixed or removed. A domain administrator reads the reports of their own domains.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Inbound</h2></div>
|
||||
<div class="sp-help-topic" id="inbound"><h3>Backup-MX and forwarding</h3><p>An optional backup-MX or forwarder, switched on in the server's <code>.env</code>; the Inbound menu exists only then. Postfix accepts mail on port 25 for the listed domains only and hands each message on to that domain's upstream; there are no mailboxes. For each domain publish the MX record its page shows, and keep a primary MX the domain already has if this is a backup. The MX tag is green when at least one MX of the domain points at this server, and a domain accepts no mail until an upstream is saved on its page. Adding and deleting domains is for the global role.</p></div>
|
||||
<div class="sp-help-topic" id="upstream"><h3>Upstream</h3><p>The upstream is the server that actually holds the mailboxes. Give its host and port — used exactly as written, with no MX lookup — and choose the TLS for the hop to it: Opportunistic uses TLS when the upstream offers it and otherwise sends in the clear, Required holds the mail until a TLS session is possible, Off never uses TLS. While the upstream does not answer, Postfix keeps the mail and retries as for any other mail.</p></div>
|
||||
<div class="sp-help-topic" id="upstream"><h3>Upstream</h3><p>The upstream is the server that actually holds the mailboxes. Give its host and port — used exactly as written, with no MX lookup — and choose the TLS for the hop to it: Opportunistic uses TLS when the upstream offers it and otherwise sends in the clear; Required sends only over TLS, and the mail waits in the queue while the upstream does not offer it or the TLS handshake fails - an upstream that answers STARTTLS with a permanent error bounces it; Off never uses TLS. While the upstream does not answer, Postfix keeps the mail and retries as for any other mail.</p></div>
|
||||
<div class="sp-help-topic" id="recipients"><h3>Valid recipients</h3><p>Choose whom this domain accepts. Listed addresses only: the relay accepts exactly the addresses you list, one per line or comma-separated, and refuses any other while the sender is still connected, so it never has to send a bounce for them. Any recipient: everything at the domain is accepted and passed on, and an address the upstream then rejects comes back as a bounce from this server to a sender who is often forged — backscatter. Prefer a list, and keep it in step with the mailboxes upstream.</p></div>
|
||||
<div class="sp-help-topic" id="filter"><h3>Spam filter</h3><p>The spam filter belongs to the server, not to a domain. The server's administrator attaches it in the server's <code>.env</code>, and from then on every inbound domain is screened by it before mail is handed on; the box shows on or off and has nothing to switch. Off means mail goes to the upstream unfiltered. While the filter is down, mail is either accepted unfiltered (the default) or deferred, so the sending server tries again later, as the server's setting says; the box states which.</p></div>
|
||||
<div class="sp-help-topic" id="filter"><h3>Spam filter</h3><p>The spam filter belongs to the server, not to a domain. The server's administrator attaches it in the server's <code>.env</code>, and from then on every inbound domain is screened by it before mail is handed on; the box shows on or off and has nothing to switch. Off means mail goes to the upstream unfiltered. While the filter is down, mail is either accepted unfiltered (the default) or refused with a temporary error, so the sending server tries again later, as the server's setting says; the box states which.</p></div>
|
||||
<div class="sp-help-topic" id="inbound-log"><h3>Inbound log</h3><p>One row per recipient of a message offered on port 25 for an inbound domain, with what was decided: <span class="tag is-success is-light">accept</span> — handed on to the upstream; <span class="tag is-danger is-light">reject</span> — refused for good; <span class="tag is-warning is-light">tempfail</span> — the sender was asked to come back later; <span class="tag is-primary is-light">quarantine</span> — held by the filter. <em>Why</em> names who decided. <code>rspamd</code> is the spam filter, with its action, the score against the reject threshold and the heaviest symbols; <code>postfix</code> is the relay's own rule — an unknown recipient, a sender domain that does not exist — with its reply; <code>selfpost</code> means nobody judged the message: it was relayed with no filter attached. Accept says what this server did, not what the upstream did with the message afterwards.<br><br>Accepted mail is recorded as it passes; refused mail is read from <code>mail.log</code>, so its row has no subject, and a message refused after its text was sent is one row however many recipients it had. A refusal to relay for a domain that is not an inbound one is every scanner on the internet and is not recorded. Rows are kept for the number of days set in Settings, and never more than 10 000.</p></div>
|
||||
<div class="sp-help-topic" id="filter-lists"><h3>Filter lists</h3><p>Two lists for the whole server that correct the spam filter by hand. An entry is a sender address, a sender domain — that domain, not its subdomains — or a client address or network in CIDR form. A sender on the Deny list is refused outright. A sender on the Allow list is given a weight no spam score outweighs, so the filter lets it through. The sender's address is whatever the sending server says it is - anyone can claim to be a sender you allow - so allow a client address or network where you can, and a sender address only for mail you would rather receive forged than lose. When entries of both lists match one message, <strong>Deny wins</strong>.<br><br>SelfPost does not apply the lists itself: it writes them as files under <code>/data/antispam/</code>, and rspamd rereads a file within seconds of a change. With the spam filter off the lists are kept and can be edited, but nothing reads them. The sender matched is the envelope sender — the one in the log — not the From line a mail program shows. Matched counts the messages in the Inbound log that the entry decided, so it reaches back as far as the log does; never means not once in that time, and such an entry is one to consider removing.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div>
|
||||
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys, application credentials and the queue), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
|
||||
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys and application credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. The Postfix queue is not in it. The backup is taken while the server is running, and Postfix keeps moving messages through the queue, so a copy could hold a message twice or miss it; mail still waiting for delivery is therefore not carried over, and a restored server starts with an empty queue. To keep the queue, stop the container and archive the project directory instead. The private key in <code>certs/</code> has to be readable by the panel: give it to group 10998 on the host (<code>chgrp 10998</code>, <code>chmod 640</code>). Until then the panel refuses to make a backup and names the file, rather than hand over an archive without the key. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
|
||||
<div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out of their other sessions.</p></div>
|
||||
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it.</p></div>
|
||||
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. With the inbound relay on there is a second number, for the Inbound log, with the same range. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it. Notifications are turned on or off here for everyone.</p></div>
|
||||
<div class="sp-help-topic" id="notifications"><h3>Notifications</h3><p>The panel writes to its users when something needs them, from <code>postmaster@</code> the server's hostname. Four things are reported. A sending domain's SPF, DKIM or DMARC record stops matching: sent once two hourly checks in a row have found it so, and once more when the record matches again. A level-2 rate limit refuses mail: sent at the first refusal, then at most once a day for the same limit. The TLS certificate is 14 days from expiry, and again at 3 days if it is still the same certificate. A message has waited in the queue for more than three days, with one more message when none is left. A global user is told all of it; a domain administrator is told about the DNS records and limits of their own domains.<br><br>Each user turns notifications on or off under Account and needs an e-mail there; Server › Settings turns them off for everyone. They are the panel's own mail: not rows of the Outbound log, and not counted against any limit.<br><br>For that mail to arrive, the server's own name needs two DNS records of its own, as each of your domains does: a DKIM key, which the panel generates and signs notifications with, and an SPF record, <code>v=spf1 a -all</code>. Server › Settings gives both with the values to publish, and Server › Health shows whether they are in place. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains does not depend on either.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">05</span><h2>Account</h2></div>
|
||||
<div class="sp-help-topic" id="account"><h3>Your account</h3><p>Account is your own, whatever your role: your username and e-mail, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected.</p></div>
|
||||
<div class="sp-help-topic" id="account"><h3>Your account</h3><p>Account is your own, whatever your role: your username and e-mail, whether the panel sends you notifications, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Add an entry</h2><a class="sp-help" href="help.html#inbound" title="How the lists are applied"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Add an entry</h2><a class="sp-help" href="help.html#filter-lists" title="How the lists are applied"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<form class="sp-form" method="post" action="#">
|
||||
<div class="columns">
|
||||
@@ -18,7 +18,7 @@
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<div class="field"><label class="label" for="etype">Entry is</label>
|
||||
<div class="control"><div class="select is-fullwidth"><select id="etype" name="entry_type"><option>a sender address</option><option>a sender domain</option><option>a client IP or CIDR</option></select></div></div></div>
|
||||
<div class="control"><div class="select is-fullwidth"><select id="etype" name="entry_type"><option>a sender address</option><option>a sender domain</option><option>a client address or network</option></select></div></div></div>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<div class="field"><label class="label" for="value">Value</label>
|
||||
@@ -37,11 +37,11 @@
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Deny</h2><div class="sp-end">3 entries · wins over Allow</div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Value</th><th>Note</th><th>Added</th><th></th></tr></thead>
|
||||
<thead><tr><th>Value</th><th>Kind</th><th>Matched</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-small">@bulk.example <span class="tag is-light">domain</span></td><td class="sp-muted">newsletter nobody subscribed to</td><td class="sp-muted sp-nowrap sp-small">2026-09-02</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
|
||||
<tr><td class="sp-mono sp-small">198.51.100.0/24 <span class="tag is-light">ip</span></td><td class="sp-muted">dictionary attack on lists.example.org</td><td class="sp-muted sp-nowrap sp-small">2026-08-19</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
|
||||
<tr><td class="sp-mono sp-small">win@lottery.example <span class="tag is-light">address</span></td><td class="sp-muted">—</td><td class="sp-muted sp-nowrap sp-small">2026-07-30</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
|
||||
<tr><td class="sp-mono sp-small">bulk.example</td><td><button type="button" class="tag is-light" popovertarget="deny-1">domain</button><div class="sp-sheet" id="deny-1" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-light">domain</span><h2>bulk.example</h2><button type="button" popovertarget="deny-1" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Note</th><td>newsletter nobody subscribed to</td></tr><tr><th>Added</th><td class="sp-mono sp-small">2026-09-02</td></tr><tr><th>Matched</th><td>41 in the last 90 days</td></tr><tr><th>Last match</th><td class="sp-mono sp-small">2026-09-21 13:57</td></tr></tbody></table></div></div></div></div></td><td class="sp-nowrap">41</td><td class="sp-actions"><form method="post" action="#"><button type="submit" class="has-text-danger">Remove</button></form></td></tr>
|
||||
<tr><td class="sp-mono sp-small">198.51.100.0/24</td><td><button type="button" class="tag is-light" popovertarget="deny-2">ip</button><div class="sp-sheet" id="deny-2" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-light">ip</span><h2>198.51.100.0/24</h2><button type="button" popovertarget="deny-2" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Note</th><td>dictionary attack on lists.example.org</td></tr><tr><th>Added</th><td class="sp-mono sp-small">2026-08-19</td></tr><tr><th>Matched</th><td>1203 in the last 90 days</td></tr><tr><th>Last match</th><td class="sp-mono sp-small">2026-09-21 09:12</td></tr></tbody></table></div></div></div></div></td><td class="sp-nowrap">1203</td><td class="sp-actions"><form method="post" action="#"><button type="submit" class="has-text-danger">Remove</button></form></td></tr>
|
||||
<tr><td class="sp-mono sp-small">win@lottery.example</td><td><button type="button" class="tag is-light" popovertarget="deny-3">address</button><div class="sp-sheet" id="deny-3" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-light">address</span><h2>win@lottery.example</h2><button type="button" popovertarget="deny-3" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Note</th><td>—</td></tr><tr><th>Added</th><td class="sp-mono sp-small">2026-07-30</td></tr><tr><th>Matched</th><td>never in the last 90 days</td></tr></tbody></table></div></div></div></div></td><td class="sp-muted sp-nowrap">never</td><td class="sp-actions"><form method="post" action="#"><button type="submit" class="has-text-danger">Remove</button></form></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
@@ -50,10 +50,10 @@
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Allow</h2><div class="sp-end">2 entries</div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Value</th><th>Note</th><th>Added</th><th></th></tr></thead>
|
||||
<thead><tr><th>Value</th><th>Kind</th><th>Matched</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-small">@partner.example <span class="tag is-light">domain</span></td><td class="sp-muted">invoices were scored as spam</td><td class="sp-muted sp-nowrap sp-small">2026-09-11</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
|
||||
<tr><td class="sp-mono sp-small">noreply@github.example <span class="tag is-light">address</span></td><td class="sp-muted">security advisories</td><td class="sp-muted sp-nowrap sp-small">2026-06-04</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
|
||||
<tr><td class="sp-mono sp-small">partner.example</td><td><button type="button" class="tag is-light" popovertarget="allow-1">domain</button><div class="sp-sheet" id="allow-1" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-light">domain</span><h2>partner.example</h2><button type="button" popovertarget="allow-1" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Note</th><td>invoices were scored as spam</td></tr><tr><th>Added</th><td class="sp-mono sp-small">2026-09-11</td></tr><tr><th>Matched</th><td>7 in the last 90 days</td></tr><tr><th>Last match</th><td class="sp-mono sp-small">2026-09-19 16:40</td></tr></tbody></table></div></div></div></div></td><td class="sp-nowrap">7</td><td class="sp-actions"><form method="post" action="#"><button type="submit" class="has-text-danger">Remove</button></form></td></tr>
|
||||
<tr><td class="sp-mono sp-small">noreply@github.example</td><td><button type="button" class="tag is-light" popovertarget="allow-2">address</button><div class="sp-sheet" id="allow-2" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-light">address</span><h2>noreply@github.example</h2><button type="button" popovertarget="allow-2" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Note</th><td>security advisories</td></tr><tr><th>Added</th><td class="sp-mono sp-small">2026-06-04</td></tr><tr><th>Matched</th><td>18 in the last 90 days</td></tr><tr><th>Last match</th><td class="sp-mono sp-small">2026-09-21 13:30</td></tr></tbody></table></div></div></div></div></td><td class="sp-nowrap">18</td><td class="sp-actions"><form method="post" action="#"><button type="submit" class="has-text-danger">Remove</button></form></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
@@ -64,10 +64,10 @@
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Where the lists go</h2><div class="sp-end"><span class="tag is-success is-light">applied</span></div></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Applied by</dt><dd>rspamd, from map files</dd></div>
|
||||
<div><dt>Files</dt><dd class="sp-mono sp-small">/data/antispam/allow.map<br>/data/antispam/deny.map</dd></div>
|
||||
<div><dt>Applied by</dt><dd>rspamd, from map files it rereads within seconds of a change</dd></div>
|
||||
<div><dt>Files</dt><dd><span class="sp-mono">/data/antispam/</span> — six, one for each list and kind of entry</dd></div>
|
||||
<div><dt>Last written</dt><dd>2026-09-11 08:40 UTC</dd></div>
|
||||
<div><dt>When the filter is off</dt><dd class="sp-muted">The lists are kept and editable, but nothing reads them until <code>INBOUND_ANTISPAM_MILTER</code> is set.</dd></div>
|
||||
<div><dt>When both lists match</dt><dd>Deny wins, whichever entries they are.</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker">Inbound</p>
|
||||
<h1 class="title is-3">Inbound log</h1>
|
||||
<p class="sp-lead">What port 25 decided about each message, one row per recipient. Accepts come from the inbound journal, rejects from <code>mail.log</code>; the filter's reasons appear when the spam filter is on.</p>
|
||||
<p class="sp-lead">What port 25 decided about each message, one row per recipient. Accepted mail is recorded as it passes, refused mail is read from <code>mail.log</code>; the filter's verdict appears when the spam filter is on. Rows older than 90 days are deleted — change that in <a href="settings.html">Settings</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,17 +17,18 @@
|
||||
<input class="input" type="date" name="to" value="2026-09-21" aria-label="To date">
|
||||
<button type="submit" class="button"><i class="ti ti-filter"></i>Filter</button>
|
||||
</form>
|
||||
<a class="sp-help" href="help.html#inbound-log" title="What the decisions mean"><i class="ti ti-help-circle"></i></a>
|
||||
</div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Time, UTC</th><th>From · client</th><th>To</th><th>Subject</th><th>Decision</th><th>Why</th><th></th></tr></thead>
|
||||
<thead><tr><th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:00:18</td><td>t.berg@<wbr>uni.example<br><span class="sp-mono sp-muted sp-small">192.0.2.77</span></td><td>dev@<wbr>lists.example.org</td><td class="sp-clip">Re: [dev] patch series v3</td><td><span class="tag is-success is-light">accept</span></td><td class="sp-muted sp-small">rspamd · score 0.4</td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:59:02</td><td>invoice@<wbr>partner-billing.example<br><span class="sp-mono sp-muted sp-small">203.0.113.140</span></td><td>owner@<wbr>lists.example.org</td><td class="sp-clip">Outstanding invoice — action required</td><td><span class="tag is-primary is-light">quarantine</span></td><td class="sp-muted sp-small">rspamd · score 9.8 · PHISHING, FORGED_SENDER</td><td class="sp-actions"><a href="in-quarantine-message.html">Review</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:57:41</td><td>promo@<wbr>bulk.example<br><span class="sp-mono sp-muted sp-small">198.51.100.90</span></td><td>sales@<wbr>lists.example.org</td><td class="sp-clip sp-muted">—</td><td><span class="tag is-danger is-light">reject</span></td><td class="sp-muted sp-small">postfix · 550 5.1.1 unknown recipient</td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:51:30</td><td>billing@<wbr>partner.example<br><span class="sp-mono sp-muted sp-small">198.51.100.12</span></td><td>owner@<wbr>lists.example.org</td><td class="sp-clip">Invoice 2026-0917</td><td><span class="tag is-danger is-light">reject</span></td><td class="sp-muted sp-small">rspamd · score 16.2 · BAYES_SPAM</td><td class="sp-actions"><a href="in-filter-lists.html">Allow sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:44:02</td><td>hr@<wbr>partner.example<br><span class="sp-mono sp-muted sp-small">192.0.2.31</span></td><td>jobs@<wbr>acme.io</td><td class="sp-clip">Application: backend engineer</td><td><span class="tag is-warning is-light">tempfail</span></td><td class="sp-muted sp-small">selfpost · upstream 10.0.4.12 timed out</td><td class="sp-actions"></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:30:55</td><td>noreply@<wbr>github.example<br><span class="sp-mono sp-muted sp-small">192.0.2.200</span></td><td>security@<wbr>lists.example.org</td><td class="sp-clip">[advisory] new report</td><td><span class="tag is-success is-light">accept</span></td><td class="sp-muted sp-small">rspamd · allow list</td><td class="sp-actions"></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:00:18</td><td>t.berg@<wbr>uni.example<br><span class="sp-mono sp-muted sp-small">192.0.2.77</span></td><td>dev@<wbr>lists.example.org</td><td class="sp-clip">Re: [dev] patch series v3</td><td><button type="button" class="tag is-success is-light" popovertarget="why-1">accept</button><div class="sp-sheet" id="why-1" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-success is-light">accept</span><h2>Why</h2><button type="button" popovertarget="why-1" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Verdict</th><td>no action, score 0.40 / 15.00</td></tr><tr><th>Subject</th><td>Re: [dev] patch series v3</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:59:02</td><td>invoice@<wbr>partner-billing.example<br><span class="sp-mono sp-muted sp-small">203.0.113.140</span></td><td>owner@<wbr>lists.example.org</td><td class="sp-clip">Outstanding invoice — action required</td><td><button type="button" class="tag is-success is-light" popovertarget="why-2">accept</button><div class="sp-sheet" id="why-2" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-success is-light">accept</span><h2>Why</h2><button type="button" popovertarget="why-2" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Verdict</th><td>add header, score 9.80 / 15.00</td></tr><tr><th>Symbols</th><td class="sp-mono sp-small">PHISHING(5.00)<br>FORGED_SENDER(3.00)<br>R_SPF_FAIL(1.00)</td></tr><tr><th>Subject</th><td>Outstanding invoice — action required</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:57:41</td><td>promo@<wbr>bulk.example<br><span class="sp-mono sp-muted sp-small">198.51.100.90</span></td><td>sales@<wbr>lists.example.org</td><td class="sp-clip sp-muted">—</td><td><button type="button" class="tag is-danger is-light" popovertarget="why-3">reject</button><div class="sp-sheet" id="why-3" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-danger is-light">reject</span><h2>Why</h2><button type="button" popovertarget="why-3" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>postfix</td></tr><tr><th>Reply</th><td class="sp-mono sp-small">550 5.1.1 <sales@lists.example.org>: Recipient address rejected: User unknown in relay recipient table</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:51:30</td><td>billing@<wbr>partner.example<br><span class="sp-mono sp-muted sp-small">198.51.100.12</span></td><td>owner@<wbr>lists.example.org</td><td class="sp-clip sp-muted">—</td><td><button type="button" class="tag is-danger is-light" popovertarget="why-4">reject</button><div class="sp-sheet" id="why-4" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-danger is-light">reject</span><h2>Why</h2><button type="button" popovertarget="why-4" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Reply</th><td class="sp-mono sp-small">5.7.1 Spam message rejected</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Allow sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:44:02</td><td>hr@<wbr>partner.example<br><span class="sp-mono sp-muted sp-small">192.0.2.31</span></td><td>jobs@<wbr>acme.io</td><td class="sp-clip sp-muted">—</td><td><button type="button" class="tag is-warning is-light" popovertarget="why-5">tempfail</button><div class="sp-sheet" id="why-5" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-warning is-light">tempfail</span><h2>Why</h2><button type="button" popovertarget="why-5" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Reply</th><td class="sp-mono sp-small">4.7.1 Try again later</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Allow sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:30:55</td><td>noreply@<wbr>github.example<br><span class="sp-mono sp-muted sp-small">192.0.2.200</span></td><td>security@<wbr>lists.example.org</td><td class="sp-clip">[advisory] new report</td><td><button type="button" class="tag is-success is-light" popovertarget="why-6">accept</button><div class="sp-sheet" id="why-6" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-success is-light">accept</span><h2>Why</h2><button type="button" popovertarget="why-6" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Verdict</th><td>no action, score -98.10 / 15.00</td></tr><tr><th>Symbols</th><td class="sp-mono sp-small">SELFPOST_ALLOW_FROM(-100.00)<br>R_DKIM_ALLOW(-0.20)</td></tr><tr><th>Subject</th><td>[advisory] new report</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="sp-box-foot"><span>Page 1 of 38</span><a href="#">Older →</a><span class="sp-end">Allow / Deny sender opens <a href="in-filter-lists.html">Filter lists</a> with the entry filled in · global administrator only</span></div>
|
||||
<div class="sp-box-foot"><span>Page 1 of 38</span><a href="#">Older →</a><span class="sp-end">A decision opens its reason · Allow / Deny sender opens <a href="in-filter-lists.html">Filter lists</a> with the entry filled in</span></div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<ul class="sp-list-check">
|
||||
<li><i class="ti ti-x"></i>permanently delete its DKIM signing key;</li>
|
||||
<li><i class="ti ti-x"></i>delete <strong>both applications</strong> (<span class="sp-mono">prod-server</span>, <span class="sp-mono">alerts</span>) with their credentials and sender bindings;</li>
|
||||
<li><i class="ti ti-x"></i>reload OpenDKIM so the domain is no longer signed.</li>
|
||||
<li><i class="ti ti-x"></i>stop signing mail as the domain.</li>
|
||||
</ul>
|
||||
<p class="sp-muted mb-4">The published DKIM record becomes invalid — remove it from DNS unless you plan to re-add the domain. Inbound domains are not touched.</p>
|
||||
<form method="post" action="out-domains.html">
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
<form class="sp-end" method="get" action="#">
|
||||
<div class="select"><select name="domain"><option>All domains</option><option>example.org</option><option>shop.example.org</option><option>notify.acme.io</option></select></div>
|
||||
<div class="select"><select name="app"><option>All applications</option><option>prod-server</option><option>alerts</option></select></div>
|
||||
<div class="select"><select name="status"><option>Any status</option><option>sent</option><option>deferred</option><option>bounced</option><option>rate-limited</option></select></div>
|
||||
<div class="select"><select name="status"><option>Any status</option><option>queued</option><option>sent</option><option>deferred</option><option>bounced</option><option>rejected</option></select></div>
|
||||
<input class="input" type="date" name="from" value="2026-09-14" aria-label="From date">
|
||||
<input class="input" type="date" name="to" value="2026-09-21" aria-label="To date">
|
||||
<button type="submit" class="button"><i class="ti ti-filter"></i>Filter</button>
|
||||
</form>
|
||||
<a class="sp-help" href="help.html#log" title="What the statuses mean"><i class="ti ti-help-circle"></i></a>
|
||||
@@ -25,7 +27,8 @@
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:50</td><td>shop@<wbr>shop.example.org</td><td>anna.lind@<wbr>outlook.com</td><td class="sp-clip">Your order #48212 has shipped</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:58:07</td><td>alerts@<wbr>example.org</td><td>noc@<wbr>example.org</td><td class="sp-clip">[FIRING] disk usage above 90 % on db-2</td><td><span class="tag is-warning is-light">deferred</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:41:19</td><td>no-reply@<wbr>notify.acme.io</td><td>j.doe@<wbr>nonexistent.example</td><td class="sp-clip">Reset your password</td><td><span class="tag is-danger is-light">bounced</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:40:02</td><td>prod@<wbr>example.org</td><td>billing@<wbr>partner.example</td><td class="sp-clip">Invoice 2026-0917</td><td><span class="tag is-light">rate-limited</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:40:55</td><td>shop@<wbr>shop.example.org</td><td>k.berg@<wbr>web.de</td><td class="sp-clip">A message from the director</td><td><span class="tag is-danger is-light">wrong From</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:40:02</td><td>prod@<wbr>example.org</td><td>billing@<wbr>partner.example</td><td class="sp-clip">Invoice 2026-0917</td><td><span class="tag is-danger is-light">rate-limited</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:12:44</td><td>shop@<wbr>shop.example.org</td><td>p.novak@<wbr>seznam.cz</td><td class="sp-clip">We received your return request</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
|
||||
@@ -41,6 +41,6 @@
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Delivery log</h2><div class="sp-end">Lines from <code>mail.log</code> with this queue id</div></div>
|
||||
<pre class="sp-log"><span class="sp-d">13:58:07</span> postfix/smtpd[2114]: 4XcB7k2Jm9z1: client=unknown[198.51.100.7], sasl_username=alerts
|
||||
<span class="sp-d">13:58:07</span> postfix/cleanup[2120]: 4XcB7k2Jm9z1: message-id=<a81f0c@alertmanager>
|
||||
<span class="sp-d">13:58:07</span> opendkim[39]: 4XcB7k2Jm9z1: DKIM-Signature field added (s=sp2026, d=example.org)
|
||||
<span class="sp-d">13:58:07</span> postfix/qmgr[2061]: 4XcB7k2Jm9z1: from=<alerts@example.org>, size=2214, nrcpt=1 (queue active)
|
||||
<span class="sp-d">13:58:09</span> <span class="sp-w">postfix/smtp[2123]: 4XcB7k2Jm9z1: to=<noc@example.org>, relay=mx1.example.org[192.0.2.10]:25, delay=1.9, status=deferred (host mx1.example.org said: 451 4.7.1 Greylisted, try again in 5 minutes)</span></pre>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<a href="health.html"><span class="sp-h-icon"><i class="ti ti-server-cog"></i></span><span><span class="sp-h-name">Processes</span><span class="sp-h-value">5 of 5 running</span><span class="sp-h-sub">up 18 days</span></span></a>
|
||||
<a href="health.html" class="sp-warn"><span class="sp-h-icon"><i class="ti ti-certificate"></i></span><span><span class="sp-h-name">TLS certificate</span><span class="sp-h-value">Expires in 12 days</span><span class="sp-h-sub">renewal has not run</span></span></a>
|
||||
<a href="out-queue.html"><span class="sp-h-icon"><i class="ti ti-stack-2"></i></span><span><span class="sp-h-name">Queues</span><span class="sp-h-value">Both empty</span><span class="sp-h-sub">outbound and inbound</span></span></a>
|
||||
<a href="health.html"><span class="sp-h-icon"><i class="ti ti-plug-connected"></i></span><span><span class="sp-h-name">Milter sockets</span><span class="sp-h-value">3 of 3 answering</span><span class="sp-h-sub">incl. the spam filter</span></span></a>
|
||||
<a href="health.html"><span class="sp-h-icon"><i class="ti ti-plug-connected"></i></span><span><span class="sp-h-name">Milter sockets</span><span class="sp-h-value">5 of 5 answering</span><span class="sp-h-sub">incl. the spam filter</span></span></a>
|
||||
<a href="health.html"><span class="sp-h-icon"><i class="ti ti-arrows-exchange"></i></span><span><span class="sp-h-name">Reverse DNS</span><span class="sp-h-value">Forward = reverse</span><span class="sp-h-sub">203.0.113.25</span></span></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,11 +16,12 @@
|
||||
<div class="field"><label class="label" for="ret">Keep outbound log rows, days</label>
|
||||
<div class="control"><input class="input" id="ret" name="send_log_retention_days" type="number" min="7" max="365" value="30" required></div>
|
||||
<p class="help">The main driver of <code>/data</code> growth. Does not affect <code>mail.log</code> rotation (fourteen daily files).</p></div>
|
||||
<div class="field"><label class="label" for="inret">Keep inbound log rows, days</label>
|
||||
<div class="control"><input class="input" id="inret" name="inbound_spam_log_retention_days" type="number" min="7" max="365" value="90" required></div>
|
||||
<p class="help">The inbound log also never holds more than 10 000 rows: refused mail is logged at the rate strangers knock on port 25.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<div class="box" id="rate-limits">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Sending rate limits</h2><div class="sp-end">Read-only</div><a class="sp-help" href="help.html#limits" title="Levels 1 and 2"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
@@ -31,6 +32,43 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<div class="box" id="notifications">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Notifications</h2><a class="sp-help" href="help.html#notifications" title="What is sent and when"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="notify" checked> Send notification e-mails</label>
|
||||
<p class="help">From <span class="sp-mono">postmaster@mail.example.org</span>, to every user who has an e-mail and has not turned them off under <a href="account.html">Account</a>.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box" id="notification-mail">
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Notification mail</h2><div class="sp-end">DNS records of the server's own name</div><a class="sp-help" href="help.html#notifications" title="What the panel sends and what makes it arrive"><i class="ti ti-help-circle"></i></a></div>
|
||||
|
||||
<div class="sp-record">
|
||||
<div class="sp-record-head"><h3>DKIM</h3><span class="tag is-success is-light">published</span><small>Not a secret · selector <code>sp2026</code></small></div>
|
||||
<div class="sp-row">
|
||||
<div class="field"><label class="label">Host / name</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="sp2026._domainkey.mail.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
||||
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
|
||||
</div>
|
||||
<div class="field"><label class="label">Value to publish</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><textarea class="textarea" rows="5" readonly>v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq7n2Lw0c5vXeR1dYt8uJb3mKs6pHf9aZoWg4iNy2TqCx0lVjE7rUh5sBk1dMa8fPz3oGc6yXw9tIe4nQv2uL…IDAQAB</textarea></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
||||
</div>
|
||||
|
||||
<div class="sp-record">
|
||||
<div class="sp-record-head"><h3>SPF</h3><span class="tag is-warning is-light">missing</span><small>For the server's own name, not for your domains</small></div>
|
||||
<div class="sp-row">
|
||||
<div class="field"><label class="label">Host / name</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="mail.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
||||
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
|
||||
</div>
|
||||
<div class="field"><label class="label">Value to publish</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=spf1 a -all"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
||||
<p class="help">Notifications are sent without it, and a receiver may file them as spam.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons"><button type="submit" class="button is-primary">Save settings</button></div>
|
||||
</form>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker">Server</p>
|
||||
<h1 class="title is-3">System log</h1>
|
||||
<p class="sp-lead">The tail of <code>mail.log</code> — Postfix, OpenDKIM and the milters. For one message, open it from a log page instead.</p>
|
||||
<p class="sp-lead">The tail of <code>mail.log</code>, the log Postfix writes. For one message, open it from a log page instead.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,5 @@
|
||||
<span class="sp-d">Sep 21 13:57:41</span> <span class="sp-e">postfix/smtpd[2119]: NOQUEUE: reject: RCPT from bulk.example[198.51.100.90]: 550 5.1.1 <sales@lists.example.org>: Recipient address rejected: unknown recipient</span>
|
||||
<span class="sp-d">Sep 21 13:44:02</span> <span class="sp-w">postfix/smtp[2101]: 4XcB2m7Hd4z1: to=<jobs@acme.io>, relay=none, status=deferred (connect to 10.0.4.12[10.0.4.12]:2525: Connection timed out)</span>
|
||||
<span class="sp-d">Sep 21 13:41:19</span> <span class="sp-e">postfix/smtp[2098]: 4XcB1c3Rk8z1: to=<j.doe@nonexistent.example>, status=bounced (Host or domain name not found)</span>
|
||||
<span class="sp-d">Sep 21 13:40:02</span> <span class="sp-w">journal-milter[40]: rate limit: app=prod-server 201/200 in 3600s — tempfail at MAIL FROM</span>
|
||||
<span class="sp-d">Sep 21 13:00:00</span> opendkim[39]: key table reloaded (3 domains)</pre>
|
||||
<span class="sp-d">Sep 21 13:40:02</span> <span class="sp-w">journal-milter[40]: rate limit: app=prod-server 201/200 in 3600s — tempfail at MAIL FROM</span></pre>
|
||||
</div>
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker">Server</p>
|
||||
<h1 class="title is-3">System log</h1>
|
||||
<p class="sp-lead">The tail of <code>mail.log</code> — Postfix, OpenDKIM and the milters. For one message, open it from a log page instead.</p>
|
||||
<p class="sp-lead">The tail of <code>mail.log</code>, the log Postfix writes. For one message, open it from a log page instead.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,8 +83,7 @@
|
||||
<span class="sp-d">Sep 21 13:57:41</span> <span class="sp-e">postfix/smtpd[2119]: NOQUEUE: reject: RCPT from bulk.example[198.51.100.90]: 550 5.1.1 <sales@lists.example.org>: Recipient address rejected: unknown recipient</span>
|
||||
<span class="sp-d">Sep 21 13:44:02</span> <span class="sp-w">postfix/smtp[2101]: 4XcB2m7Hd4z1: to=<jobs@acme.io>, relay=none, status=deferred (connect to 10.0.4.12[10.0.4.12]:2525: Connection timed out)</span>
|
||||
<span class="sp-d">Sep 21 13:41:19</span> <span class="sp-e">postfix/smtp[2098]: 4XcB1c3Rk8z1: to=<j.doe@nonexistent.example>, status=bounced (Host or domain name not found)</span>
|
||||
<span class="sp-d">Sep 21 13:40:02</span> <span class="sp-w">journal-milter[40]: rate limit: app=prod-server 201/200 in 3600s — tempfail at MAIL FROM</span>
|
||||
<span class="sp-d">Sep 21 13:00:00</span> opendkim[39]: key table reloaded (3 domains)</pre>
|
||||
<span class="sp-d">Sep 21 13:40:02</span> <span class="sp-w">journal-milter[40]: rate limit: app=prod-server 201/200 in 3600s — tempfail at MAIL FROM</span></pre>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
|
||||
@@ -269,4 +269,49 @@ code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; pad
|
||||
.sp-record-head { flex-wrap: wrap; }
|
||||
.sp-record-head small { margin-left: 0; text-align: left; flex-basis: 100%; }
|
||||
.sp-postmark { display: none; }
|
||||
.sp-cards { display: flex; flex-direction: column; padding-inline: .75rem; }
|
||||
.sp-cards > .column { display: contents; }
|
||||
.sp-cards .box { margin-bottom: 1.5rem; }
|
||||
.sp-cards [data-order="1"] { order: 1; }
|
||||
.sp-cards [data-order="2"] { order: 2; }
|
||||
.sp-cards [data-order="3"] { order: 3; }
|
||||
.sp-cards [data-order="4"] { order: 4; }
|
||||
.sp-cards [data-order="5"] { order: 5; }
|
||||
.sp-cards [data-order="6"] { order: 6; }
|
||||
.sp-cards [data-order="7"] { order: 7; }
|
||||
.sp-cards [data-order="8"] { order: 8; }
|
||||
.sp-cards [data-order="9"] { order: 9; }
|
||||
.sp-cards [data-order="10"] { order: 10; }
|
||||
.sp-cards [data-order="11"] { order: 11; }
|
||||
.sp-cards [data-order="12"] { order: 12; }
|
||||
}
|
||||
|
||||
/* cards: two stacks of boxes that are read in rows - the odd ones on the left,
|
||||
the even ones on the right, each stack closed up so a short box leaves no
|
||||
gap beside a tall one. On a phone the stacks dissolve and the boxes come in
|
||||
the order of their numbers: each box carries data-order, its own number (the
|
||||
rules above, up to twelve), wherever it stands in its stack. */
|
||||
|
||||
/* sheet: what does not fit a table row, opened over the page from the row's
|
||||
tag. It is the browser's own popover (the tag is a button with
|
||||
popovertarget), so it needs no script: Esc, a click outside and the close
|
||||
button all shut it, and focus returns to the tag. It rises from the bottom
|
||||
edge across the whole window - the one component that is not laid out by the
|
||||
page, hence the widths - with its content, a table of name and value, in a
|
||||
column of reading width in the middle. */
|
||||
.sp-sheet {
|
||||
inset: auto 0 0 0; width: auto; margin: 0; padding: 0; border: 0; border-top: 3px solid var(--sp-brick);
|
||||
max-height: 75vh; overflow: auto; background: #fff; color: inherit; box-shadow: 0 -12px 40px rgba(40, 20, 15, .22);
|
||||
}
|
||||
.sp-sheet-in { max-width: 44rem; margin: 0 auto; padding: 0 1.25rem; }
|
||||
.sp-sheet::backdrop { background: rgba(40, 20, 15, .38); }
|
||||
.sp-sheet-head { display: flex; align-items: center; gap: .7rem; padding: 1rem 0 .8rem; border-bottom: 1px solid var(--sp-rule); }
|
||||
.sp-sheet-head h2 { font-size: 1.15rem; font-weight: 600; margin-right: auto; }
|
||||
.sp-sheet-head button { border: 0; background: none; cursor: pointer; font-size: 1.3rem; color: var(--sp-muted); }
|
||||
.sp-sheet-body { padding: .4rem 0 1.4rem; }
|
||||
/* The sheet's content is a table of name and value: the name is a label, set like the dt of facts. */
|
||||
.sp-sheet .table th { font-family: var(--sp-mono); font-size: var(--sp-fs-xs); font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); white-space: nowrap; vertical-align: top; width: 1%; padding-right: 2rem; }
|
||||
.sp-sheet .table td { overflow-wrap: anywhere; }
|
||||
/* A tag that opens a sheet is a button: it keeps the tag's look and says it can
|
||||
be pressed with the dotted underline links to more detail carry. */
|
||||
button.tag { border: 0; cursor: pointer; text-decoration: underline dotted; text-underline-offset: 3px; }
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SelfPost — panel mockups</title>
|
||||
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||
<script>
|
||||
(function () {
|
||||
var map = {
|
||||
login: "login.html",
|
||||
setup: "setup.html",
|
||||
status: "status.html",
|
||||
domains: "domains.html",
|
||||
domain: "domain.html",
|
||||
"domain-delete": "domain-delete.html",
|
||||
deliveries: "deliveries.html",
|
||||
delivery: "delivery.html",
|
||||
"mail-queue": "mail-queue.html",
|
||||
"system-log": "system-log.html",
|
||||
backup: "backup.html",
|
||||
users: "users.html",
|
||||
"user-form": "user-form.html",
|
||||
"user-delete": "user-delete.html",
|
||||
settings: "settings.html",
|
||||
inbound: "inbound.html",
|
||||
"inbound-domain": "inbound-domain.html",
|
||||
"inbound-backup": "inbound-backup.html",
|
||||
"inbound-delete": "inbound-delete.html",
|
||||
dmarc: "dmarc.html",
|
||||
"dmarc-domain": "dmarc-domain.html",
|
||||
"dmarc-report": "dmarc-report.html",
|
||||
"dmarc-report-fail": "dmarc-report-fail.html",
|
||||
help: "help.html"
|
||||
};
|
||||
var h = location.hash.replace(/^#/, "");
|
||||
location.replace((map[h] || "status.html") + location.search);
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<p>Макеты собраны отдельными страницами. Откройте <a href="status.html">Status</a> или <a href="index.html">оглавление</a>.</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,43 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Backup & migration — SelfPost mockups</title>
|
||||
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="mock.css">
|
||||
<link rel="stylesheet" href="system.css">
|
||||
</head>
|
||||
<body class="page-backup" data-page="backup" data-nav="backup" data-title="Backup" data-global-only="1">
|
||||
<main class="stack">
|
||||
<h1>Backup & migration</h1>
|
||||
<div class="pair">
|
||||
<div class="card">
|
||||
<h2>Full backup</h2>
|
||||
<p class="muted">Download a self-contained backup — <code>data/</code> (database, DKIM keys, credentials, Postfix queue), <code>docker-compose.yml</code>, <code>.env</code>, and <code>certs/</code>. Extract into an empty project directory on a new machine, adjust hostname if needed, and start the <strong>same SelfPost version</strong> before first boot. The reverse-proxy vhost is not included.</p>
|
||||
<p class="muted"><strong>The backup file is a secret.</strong> Encrypting it is the simplest way to store it: the download is then a <code>.spbk</code> that only the password opens.</p>
|
||||
<div class="encrypt">
|
||||
<label class="check"><input type="checkbox" checked> <span>Encrypt with a password</span></label>
|
||||
<div class="encrypt-fields">
|
||||
<label>Password</label><input type="password" placeholder="at least 8 characters">
|
||||
<label>Repeat password</label><input type="password">
|
||||
<p class="muted">Keep this password: without it the file cannot be opened.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions-row"><button type="button">Download full backup</button></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Import a domain</h2>
|
||||
<p class="muted">Move a single domain here from another SelfPost instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS record needs no change. The export file is a secret, like a full backup.</p>
|
||||
<label>Domain export file</label>
|
||||
<input type="file">
|
||||
<label>Password</label>
|
||||
<input type="password" autocomplete="off">
|
||||
<p class="muted">Needed for a <code>.spde</code> file. Leave empty for plain <code>.json</code>.</p>
|
||||
<div class="actions-row"><button type="button">Import domain</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script src="shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,91 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Deliveries — SelfPost mockups</title>
|
||||
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="mock.css">
|
||||
<link rel="stylesheet" href="system.css">
|
||||
</head>
|
||||
<body class="page-deliveries" data-page="deliveries" data-nav="deliveries" data-title="Deliveries">
|
||||
<main class="stack">
|
||||
<h1>Deliveries</h1>
|
||||
<form class="toolbar fill" action="#" onsubmit="return false">
|
||||
<div class="field"><label>Domain</label>
|
||||
<select><option>All domains</option><option>example.com</option><option>alerts.example.com</option></select>
|
||||
</div>
|
||||
<div class="field"><label>Application</label>
|
||||
<select><option>All applications</option><option>newsletter</option><option>billing</option></select>
|
||||
</div>
|
||||
<button type="button">Filter</button>
|
||||
</form>
|
||||
<div class="fill">
|
||||
<div class="card">
|
||||
<table class="desk-only">
|
||||
<thead><tr><th>Time</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="time muted">2026-08-15 20:14:02</td>
|
||||
<td>billing@example.com</td>
|
||||
<td>ada@example.net</td>
|
||||
<td class="subject"><span>Invoice #4412</span></td>
|
||||
<td class="status"><span class="st st-warn">deferred</span></td>
|
||||
<td class="actions"><a href="delivery.html">Details</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="time muted">2026-08-15 20:11:40</td>
|
||||
<td>news@example.com</td>
|
||||
<td>list-bounces@example.net</td>
|
||||
<td class="subject"><span>August digest</span></td>
|
||||
<td class="status"><span class="st st-ok">delivered</span></td>
|
||||
<td class="actions"><a href="delivery.html">Details</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="time muted">2026-08-15 19:02:11</td>
|
||||
<td>alerts@alerts.example.com</td>
|
||||
<td>noreply@blocked.example</td>
|
||||
<td class="subject"><span>Disk 92% on web-3</span></td>
|
||||
<td class="status"><span class="st st-error">bounced</span></td>
|
||||
<td class="actions"><a href="delivery.html">Details</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="time muted">2026-08-15 18:44:09</td>
|
||||
<td>news@example.com</td>
|
||||
<td>sam@example.org</td>
|
||||
<td class="subject"><span>August digest</span></td>
|
||||
<td class="status"><span class="st st-ok">delivered</span></td>
|
||||
<td class="actions"><a href="delivery.html">Details</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<ul class="phone-list phone-only">
|
||||
<li>
|
||||
<a href="delivery.html">
|
||||
<span class="st st-warn">deferred</span><span class="when">20:14</span>
|
||||
<span class="meta">billing@example.com → ada@example.net</span>
|
||||
<span class="subj">Invoice #4412</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="delivery.html">
|
||||
<span class="st st-ok">delivered</span><span class="when">20:11</span>
|
||||
<span class="meta">news@example.com → list-bounces@example.net</span>
|
||||
<span class="subj">August digest</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="delivery.html">
|
||||
<span class="st st-error">bounced</span><span class="when">19:02</span>
|
||||
<span class="meta">alerts@alerts.example.com → noreply@blocked.example</span>
|
||||
<span class="subj">Disk 92% on web-3</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="muted">Page 1 of 4 · <a href="deliveries.html">Older →</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script src="shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,77 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Invoice #4412 — SelfPost mockups</title>
|
||||
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="mock.css">
|
||||
<link rel="stylesheet" href="system.css">
|
||||
</head>
|
||||
<body class="page-delivery" data-page="delivery" data-nav="deliveries" data-title="Message">
|
||||
<main class="stack">
|
||||
<div>
|
||||
<h1 class="subject">Invoice #4412</h1>
|
||||
<p class="route">
|
||||
<span class="addr">billing@example.com</span>
|
||||
<span class="arrow">→</span>
|
||||
<span class="addr">ada@example.net</span>
|
||||
<span class="st st-warn">deferred</span>
|
||||
</p>
|
||||
<a class="back" href="deliveries.html">← Back to deliveries</a>
|
||||
</div>
|
||||
<div class="pair">
|
||||
<div class="card">
|
||||
<h2>Message</h2>
|
||||
<div class="facts">
|
||||
<div class="fact"><span class="fact-label">Domain</span><span class="fact-value">example.com</span></div>
|
||||
<div class="fact"><span class="fact-label">Application</span><span class="fact-value">billing</span></div>
|
||||
<div class="fact"><span class="fact-label">Accepted</span><span class="fact-value">2026-08-15 20:14:02 UTC</span></div>
|
||||
<div class="fact"><span class="fact-label">Status reported</span><span class="fact-value">2026-08-15 20:14:08 UTC</span></div>
|
||||
<div class="fact"><span class="fact-label">Queue id</span><span class="fact-value mono">4C3A1E2F1A</span></div>
|
||||
<div class="fact"><span class="fact-label">Journal id</span><span class="fact-value mono">1842</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>History</h2>
|
||||
<ol class="timeline">
|
||||
<li class="event lvl-ok">
|
||||
<p class="event-time">2026-08-15 20:14:02 UTC</p>
|
||||
<p class="event-title"><span class="st st-ok">accepted</span> Received by the relay</p>
|
||||
<p class="event-detail muted">SASL login billing, queued as 4C3A1E2F1A.</p>
|
||||
</li>
|
||||
<li class="event lvl-warn">
|
||||
<p class="event-time">2026-08-15 20:14:08 UTC</p>
|
||||
<p class="event-title"><span class="st st-warn">deferred</span> Receiving MX asked to try later</p>
|
||||
<p class="event-detail muted">Postfix retries: first after 5 minutes, then with increasing gaps up to 1 hour 7 minutes, for up to 5 days. There is no fixed attempt count — a deferred message stays in the queue until it is delivered or that lifetime runs out.</p>
|
||||
</li>
|
||||
<li class="event pending">
|
||||
<p class="event-time">not yet</p>
|
||||
<p class="event-title"><span class="st st-unknown">delivery</span> Waiting on the next retry</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fill">
|
||||
<div class="card">
|
||||
<h2>Delivery log</h2>
|
||||
<table class="log desk-only">
|
||||
<thead><tr><th>Time</th><th>Message</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="time muted">20:14:02</td><td class="log-text">postfix/smtpd[221]: 4C3A1E2F1A: client=203.0.113.40, sasl_username=billing</td></tr>
|
||||
<tr><td class="time muted">20:14:02</td><td class="log-text">postfix/cleanup[224]: 4C3A1E2F1A: message-id=<4412@example.com></td></tr>
|
||||
<tr><td class="time muted">20:14:08</td><td class="log-text">postfix/smtp[230]: 4C3A1E2F1A: to=<ada@example.net>, relay=mx.example.net[198.51.100.20]:25, delay=6, status=deferred (450 4.2.1 mailbox busy)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="phone-only">
|
||||
<p class="muted" style="margin:0.6rem 0 0.2rem">20:14:08</p>
|
||||
<span class="code">status=deferred (450 4.2.1 mailbox busy)</span>
|
||||
<p class="muted" style="margin:0.6rem 0 0.2rem">20:14:02</p>
|
||||
<span class="code">client=203.0.113.40, sasl_username=billing</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script src="shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,88 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>example.com — DMARC — SelfPost mockups</title>
|
||||
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="mock.css">
|
||||
<link rel="stylesheet" href="system.css">
|
||||
</head>
|
||||
<body class="page-dmarc-domain" data-page="dmarc-domain" data-nav="dmarc" data-title="example.com DMARC">
|
||||
<main class="stack">
|
||||
<div>
|
||||
<h1>example.com <span class="tag future">candidate</span></h1>
|
||||
<a class="back g-only" href="dmarc.html">← All DMARC reports</a>
|
||||
<a class="back" href="domain.html">← Back to example.com</a>
|
||||
</div>
|
||||
<div class="pair">
|
||||
<div class="card">
|
||||
<h2>Last 7 days <span class="st st-ok">pass</span></h2>
|
||||
<p>98% aligned. Tightening <code>p=</code> looks reasonable.</p>
|
||||
<p class="muted"><a href="domain.html">Domain DNS and rua=</a></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Third-party senders</h2>
|
||||
<p>google.com / 66.102.0.0/20 failed SPF and DKIM 6 times. Everyone else is this relay.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fill">
|
||||
<div class="card">
|
||||
<h2>Reports</h2>
|
||||
<table class="desk-only">
|
||||
<thead><tr><th>Received</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="time muted">2026-08-15 06:12</td>
|
||||
<td class="muted">google.com</td>
|
||||
<td>14 Aug</td>
|
||||
<td>412</td>
|
||||
<td>6</td>
|
||||
<td class="actions"><a href="dmarc-report.html">View</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="time muted">2026-08-14 06:08</td>
|
||||
<td class="muted">google.com</td>
|
||||
<td>13 Aug</td>
|
||||
<td>390</td>
|
||||
<td>2</td>
|
||||
<td class="actions"><a href="dmarc-report.html">View</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<ul class="phone-list phone-only">
|
||||
<li>
|
||||
<a href="dmarc-report.html">
|
||||
<span class="st st-ok">412 pass</span><span class="when">14 Aug</span>
|
||||
<span class="meta">google.com · 6 fail</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="dmarc-report.html">
|
||||
<span class="st st-ok">390 pass</span><span class="when">13 Aug</span>
|
||||
<span class="meta">google.com · 2 fail</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fill">
|
||||
<div class="card">
|
||||
<h2>Sources · last 7 days</h2>
|
||||
<table class="desk-only">
|
||||
<thead><tr><th>Source</th><th>Pass</th><th>Fail</th><th>Disposition</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="muted">203.0.113.10 (this relay)</td><td>802</td><td>0</td><td><span class="st st-ok">none</span></td></tr>
|
||||
<tr><td class="muted">google.com / 66.102.0.0/20</td><td>0</td><td>8</td><td><span class="st st-warn">none</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<ul class="phone-list phone-only">
|
||||
<li><a href="dmarc-report.html"><span>this relay</span><span class="st st-ok">802 pass</span></a></li>
|
||||
<li><a href="dmarc-report.html"><span>google.com / 66.102.0.0/20</span><span class="st st-warn">8 fail</span></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script src="shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,88 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>google.com report — alerts.example.com — SelfPost mockups</title>
|
||||
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="mock.css">
|
||||
<link rel="stylesheet" href="system.css">
|
||||
</head>
|
||||
<body class="page-dmarc-report-fail" data-page="dmarc-report-fail" data-nav="dmarc" data-title="DMARC report">
|
||||
<main class="stack">
|
||||
<div>
|
||||
<h1 class="subject">google.com · 14 Aug</h1>
|
||||
<p class="route">
|
||||
<span class="addr">alerts.example.com</span>
|
||||
<span class="st st-ok">88 pass</span>
|
||||
<span class="st st-error">19 fail</span>
|
||||
</p>
|
||||
<a class="back" href="dmarc.html">← All DMARC reports</a>
|
||||
</div>
|
||||
<div class="pair">
|
||||
<div class="card">
|
||||
<h2>Report</h2>
|
||||
<div class="facts">
|
||||
<div class="fact"><span class="fact-label">Reporter</span><span class="fact-value">google.com</span></div>
|
||||
<div class="fact"><span class="fact-label">Report id</span><span class="fact-value mono">3178944098765432109</span></div>
|
||||
<div class="fact"><span class="fact-label">Window</span><span class="fact-value">2026-08-14 00:00 – 2026-08-15 00:00 UTC</span></div>
|
||||
<div class="fact"><span class="fact-label">Received</span><span class="fact-value">2026-08-15 04:40 UTC</span></div>
|
||||
<div class="fact"><span class="fact-label">Contact</span><span class="fact-value">noreply-dmarc-support@google.com</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card attn">
|
||||
<h2>Published policy</h2>
|
||||
<div class="facts">
|
||||
<div class="fact"><span class="fact-label">Domain</span><span class="fact-value">alerts.example.com</span></div>
|
||||
<div class="fact"><span class="fact-label">p / sp / pct</span><span class="fact-value mono">none / none / 100</span></div>
|
||||
<div class="fact"><span class="fact-label">adkim / aspf</span><span class="fact-value mono">r / r</span></div>
|
||||
<div class="fact"><span class="fact-label">rua</span><span class="fact-value">dmarc@mail.example.org</span></div>
|
||||
</div>
|
||||
<p class="muted">A third-party source is not in SPF or DKIM. Do not tighten <code>p=</code> until that sender is gone or aligned.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fill">
|
||||
<div class="card">
|
||||
<h2>Records</h2>
|
||||
<table class="desk-only">
|
||||
<thead><tr><th>Source</th><th>Count</th><th>Disposition</th><th>SPF</th><th>DKIM</th><th>Header from</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="muted">203.0.113.10 (this relay)</td>
|
||||
<td>88</td>
|
||||
<td><span class="st st-ok">none</span></td>
|
||||
<td><span class="st st-ok">pass</span></td>
|
||||
<td><span class="st st-ok">pass</span></td>
|
||||
<td>alerts.example.com</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="muted">198.51.100.80 (unknown)</td>
|
||||
<td>19</td>
|
||||
<td><span class="st st-error">none</span></td>
|
||||
<td><span class="st st-error">fail</span></td>
|
||||
<td><span class="st st-error">fail</span></td>
|
||||
<td>alerts.example.com</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<ul class="phone-list phone-only">
|
||||
<li>
|
||||
<div class="item">
|
||||
<span>this relay · 88</span><span class="st st-ok">pass</span>
|
||||
<span class="meta">203.0.113.10 · SPF pass · DKIM pass</span>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="item">
|
||||
<span>unknown · 19</span><span class="st st-error">fail</span>
|
||||
<span class="meta">198.51.100.80 · SPF fail · DKIM fail</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="muted">Parsed from the aggregate XML. Forensic (<code>ruf=</code>) samples are not stored.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script src="shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,88 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>google.com report — example.com — SelfPost mockups</title>
|
||||
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="mock.css">
|
||||
<link rel="stylesheet" href="system.css">
|
||||
</head>
|
||||
<body class="page-dmarc-report" data-page="dmarc-report" data-nav="dmarc" data-title="DMARC report">
|
||||
<main class="stack">
|
||||
<div>
|
||||
<h1 class="subject">google.com · 14 Aug</h1>
|
||||
<p class="route">
|
||||
<span class="addr">example.com</span>
|
||||
<span class="st st-ok">412 pass</span>
|
||||
<span class="st st-warn">6 fail</span>
|
||||
</p>
|
||||
<a class="back" href="dmarc-domain.html">← Back to example.com</a>
|
||||
</div>
|
||||
<div class="pair">
|
||||
<div class="card">
|
||||
<h2>Report</h2>
|
||||
<div class="facts">
|
||||
<div class="fact"><span class="fact-label">Reporter</span><span class="fact-value">google.com</span></div>
|
||||
<div class="fact"><span class="fact-label">Report id</span><span class="fact-value mono">3178944012345678901</span></div>
|
||||
<div class="fact"><span class="fact-label">Window</span><span class="fact-value">2026-08-14 00:00 – 2026-08-15 00:00 UTC</span></div>
|
||||
<div class="fact"><span class="fact-label">Received</span><span class="fact-value">2026-08-15 06:12 UTC</span></div>
|
||||
<div class="fact"><span class="fact-label">Contact</span><span class="fact-value">noreply-dmarc-support@google.com</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Published policy</h2>
|
||||
<div class="facts">
|
||||
<div class="fact"><span class="fact-label">Domain</span><span class="fact-value">example.com</span></div>
|
||||
<div class="fact"><span class="fact-label">p / sp / pct</span><span class="fact-value mono">none / none / 100</span></div>
|
||||
<div class="fact"><span class="fact-label">adkim / aspf</span><span class="fact-value mono">r / r</span></div>
|
||||
<div class="fact"><span class="fact-label">rua</span><span class="fact-value">dmarc@mail.example.org</span></div>
|
||||
</div>
|
||||
<p class="muted"><code>p=none</code> does not affect delivery. Failures here are forwarding through Google, not this relay.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fill">
|
||||
<div class="card">
|
||||
<h2>Records</h2>
|
||||
<table class="desk-only">
|
||||
<thead><tr><th>Source</th><th>Count</th><th>Disposition</th><th>SPF</th><th>DKIM</th><th>Header from</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="muted">203.0.113.10 (this relay)</td>
|
||||
<td>412</td>
|
||||
<td><span class="st st-ok">none</span></td>
|
||||
<td><span class="st st-ok">pass</span></td>
|
||||
<td><span class="st st-ok">pass</span></td>
|
||||
<td>example.com</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="muted">66.102.1.44 (google.com)</td>
|
||||
<td>6</td>
|
||||
<td><span class="st st-warn">none</span></td>
|
||||
<td><span class="st st-error">fail</span></td>
|
||||
<td><span class="st st-error">fail</span></td>
|
||||
<td>example.com</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<ul class="phone-list phone-only">
|
||||
<li>
|
||||
<div class="item">
|
||||
<span>this relay · 412</span><span class="st st-ok">pass</span>
|
||||
<span class="meta">203.0.113.10 · SPF pass · DKIM pass</span>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="item">
|
||||
<span>google.com · 6</span><span class="st st-error">fail</span>
|
||||
<span class="meta">66.102.1.44 · SPF fail · DKIM fail</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="muted">Parsed from the aggregate XML. Forensic (<code>ruf=</code>) samples are not stored.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script src="shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,91 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>DMARC reports — SelfPost mockups</title>
|
||||
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="mock.css">
|
||||
<link rel="stylesheet" href="system.css">
|
||||
</head>
|
||||
<body class="page-dmarc" data-page="dmarc" data-nav="dmarc" data-title="DMARC" data-global-only="1">
|
||||
<main class="stack">
|
||||
<div class="page-head">
|
||||
<h1>DMARC reports <span class="tag future">candidate</span></h1>
|
||||
</div>
|
||||
<p class="muted">Aggregate reports SelfPost accepted for <code>rua=</code>. Forensic (<code>ruf=</code>) is out of scope. Open a report for the parsed XML — not a dashboard.</p>
|
||||
<div class="pair">
|
||||
<div class="card">
|
||||
<h2>Ingest <span class="st st-ok">ok</span></h2>
|
||||
<p>Last report 6 hours ago. Port 25 accepts only the configured report address.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>This week</h2>
|
||||
<p>14 kept, 0 parse failures. Older summaries are pruned.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fill">
|
||||
<div class="card">
|
||||
<h2>Recent reports</h2>
|
||||
<table class="desk-only">
|
||||
<thead><tr><th>Received</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="time muted">2026-08-15 06:12</td>
|
||||
<td><a href="dmarc-domain.html">example.com</a></td>
|
||||
<td class="muted">google.com</td>
|
||||
<td>14 Aug</td>
|
||||
<td>412</td>
|
||||
<td>6</td>
|
||||
<td class="actions"><a href="dmarc-report.html">View</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="time muted">2026-08-15 04:40</td>
|
||||
<td>alerts.example.com</td>
|
||||
<td class="muted">google.com</td>
|
||||
<td>14 Aug</td>
|
||||
<td>88</td>
|
||||
<td>19</td>
|
||||
<td class="actions"><a href="dmarc-report-fail.html">View</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="time muted">2026-08-14 06:08</td>
|
||||
<td><a href="dmarc-domain.html">example.com</a></td>
|
||||
<td class="muted">google.com</td>
|
||||
<td>13 Aug</td>
|
||||
<td>390</td>
|
||||
<td>2</td>
|
||||
<td class="actions"><a href="dmarc-report.html">View</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<ul class="phone-list phone-only">
|
||||
<li>
|
||||
<a href="dmarc-report.html">
|
||||
<span class="st st-ok">412 pass</span><span class="when">06:12</span>
|
||||
<span class="meta">example.com · google.com · 14 Aug</span>
|
||||
<span class="subj">6 fail</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="dmarc-report-fail.html">
|
||||
<span class="st st-error">19 fail</span><span class="when">04:40</span>
|
||||
<span class="meta">alerts.example.com · google.com · 14 Aug</span>
|
||||
<span class="subj">88 pass</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="dmarc-report.html">
|
||||
<span class="st st-ok">390 pass</span><span class="when">14 Aug</span>
|
||||
<span class="meta">example.com · google.com · 13 Aug</span>
|
||||
<span class="subj">2 fail</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="muted">Each row is one aggregate XML SelfPost parsed. Domain names open the roll-up for that sending domain.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script src="shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,33 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Delete example.com — SelfPost mockups</title>
|
||||
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="mock.css">
|
||||
<link rel="stylesheet" href="system.css">
|
||||
</head>
|
||||
<body class="page-domain-delete" data-page="domain-delete" data-nav="domains" data-title="Delete domain">
|
||||
<main class="stack">
|
||||
<div>
|
||||
<h1>Delete example.com</h1>
|
||||
<a class="back" href="domain.html">← Back to example.com</a>
|
||||
</div>
|
||||
<div class="measure">
|
||||
<div class="card">
|
||||
<h2>Confirm deletion</h2>
|
||||
<p>You are about to delete <strong>example.com</strong>. This will:</p>
|
||||
<ul>
|
||||
<li>permanently delete its DKIM signing key;</li>
|
||||
<li>delete <strong>all 2 bound applications</strong>, including their SASL credentials;</li>
|
||||
<li>reload OpenDKIM so the domain is no longer signed.</li>
|
||||
</ul>
|
||||
<p class="muted">This cannot be undone.</p>
|
||||
<div class="actions-row"><button type="button" class="danger">Delete example.com</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script src="shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,260 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>example.com — SelfPost mockups</title>
|
||||
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="mock.css">
|
||||
<link rel="stylesheet" href="system.css">
|
||||
</head>
|
||||
<body class="page-domain" data-page="domain" data-nav="domains" data-title="example.com">
|
||||
<main class="stack">
|
||||
<div>
|
||||
<h1>example.com</h1>
|
||||
<a class="back" href="domains.html">← All domains</a>
|
||||
</div>
|
||||
<div class="measure">
|
||||
<div class="card credential" id="new-credential">
|
||||
<div class="card-head">
|
||||
<h2>New application password</h2>
|
||||
<label class="help-link" for="help-password" title="Why this is shown once">?</label>
|
||||
</div>
|
||||
<p class="muted">Shown <strong>once only</strong> and not stored. Copy it now.</p>
|
||||
<label>Login</label>
|
||||
<div class="code-row"><span class="code">newsletter</span><button type="button" class="copy">Copy</button></div>
|
||||
<label>Password</label>
|
||||
<div class="code-row"><span class="code">xK.9fQ2m-pL7wR</span><button type="button" class="copy">Copy</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fill">
|
||||
<div class="card" id="dns-status">
|
||||
<div class="card-head">
|
||||
<h2>DNS status <span class="st st-ok">ok</span></h2>
|
||||
<label class="help-link" for="help-dns" title="What these checks mean">?</label>
|
||||
</div>
|
||||
<div class="stack">
|
||||
<div class="pair">
|
||||
<div>
|
||||
<label>DKIM <span class="st st-ok">ok</span></label>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Host / name</label>
|
||||
<span class="code">mail._domainkey.example.com</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Type</label>
|
||||
<span class="code">TXT</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label>SPF <span class="st st-ok">ok</span></label>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Host / name</label>
|
||||
<span class="code">example.com</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Type</label>
|
||||
<span class="code">TXT</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pair">
|
||||
<div>
|
||||
<label>DMARC <span class="st st-ok">ok</span></label>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Host / name</label>
|
||||
<span class="code">_dmarc.example.com</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Type</label>
|
||||
<span class="code">TXT</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted">p=none; rua points at SelfPost ingest.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label>Report authorization</label>
|
||||
<p class="muted">Not required (rua= is on a domain SelfPost accepts).</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions-row"><button type="button">Re-check</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pair">
|
||||
<div class="card" id="dkim-spf">
|
||||
<div class="card-head">
|
||||
<h2>DKIM and SPF records</h2>
|
||||
<label class="help-link" for="help-records" title="What to publish">?</label>
|
||||
</div>
|
||||
<p class="check-col-title">DKIM</p>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Host / name</label>
|
||||
<div class="code-row"><span class="code">mail._domainkey.example.com</span><button type="button" class="copy">Copy</button></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Type</label>
|
||||
<span class="code">TXT</span>
|
||||
</div>
|
||||
</div>
|
||||
<label>Value</label>
|
||||
<div class="code-row"><span class="code">v=DKIM1; k=rsa; p=MIIBIjANBgkqh…</span><button type="button" class="copy">Copy</button></div>
|
||||
<p class="check-col-title">SPF</p>
|
||||
<div class="code-row"><span class="code">v=spf1 ip4:203.0.113.10 -all</span><button type="button" class="copy">Copy</button></div>
|
||||
</div>
|
||||
<div class="card" id="dmarc-record">
|
||||
<div class="card-head">
|
||||
<h2>DMARC record</h2>
|
||||
<label class="help-link" for="help-dmarc" title="What this record does">?</label>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Host / name</label>
|
||||
<div class="code-row"><span class="code">_dmarc.example.com</span><button type="button" class="copy">Copy</button></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Type</label>
|
||||
<span class="code">TXT</span>
|
||||
</div>
|
||||
</div>
|
||||
<label>Value <span class="muted">(from Settings)</span></label>
|
||||
<div class="code-row"><span class="code">v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org</span><button type="button" class="copy">Copy</button></div>
|
||||
<p class="muted"><a href="dmarc-domain.html">Open DMARC reports</a> for this domain.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pair">
|
||||
<div class="card" id="connection">
|
||||
<div class="card-head">
|
||||
<h2>Connection settings</h2>
|
||||
<label class="help-link" for="help-connection" title="How to connect">?</label>
|
||||
</div>
|
||||
<label>Server</label>
|
||||
<div class="code-row"><span class="code">mail.example.org</span><button type="button" class="copy">Copy</button></div>
|
||||
<label>Port and encryption</label>
|
||||
<span class="code">465 — SSL/TLS (implicit)
|
||||
587 — STARTTLS (submission)</span>
|
||||
</div>
|
||||
<div class="card" id="add-application">
|
||||
<div class="card-head">
|
||||
<h2>Add an application</h2>
|
||||
<label class="help-link" for="help-apps" title="What an application is">?</label>
|
||||
</div>
|
||||
<form action="#" onsubmit="return false">
|
||||
<label>Login</label>
|
||||
<input placeholder="prod-server" autocomplete="off">
|
||||
<label>Address mode</label>
|
||||
<select><option>Any address of the domain</option><option>Specific addresses (list)</option></select>
|
||||
<div class="actions-row"><button type="button">Create application</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fill">
|
||||
<div class="card" id="applications">
|
||||
<div class="card-head">
|
||||
<h2>Applications</h2>
|
||||
<label class="help-link" for="help-apps" title="Address mode and rate limits">?</label>
|
||||
</div>
|
||||
<ul class="apps">
|
||||
<li class="app-item">
|
||||
<p class="app-login">newsletter</p>
|
||||
<p class="app-addr muted">Any address of the domain — *@example.com</p>
|
||||
<div class="actions">
|
||||
<input class="panel-toggle t-edit" id="edit-1" type="checkbox" checked>
|
||||
<label class="toggle for-edit" for="edit-1">Edit <span class="st st-ok">limit</span></label>
|
||||
<button type="button">New password</button>
|
||||
<button type="button" class="danger">Delete</button>
|
||||
<div class="panel panel-edit">
|
||||
<div class="pair">
|
||||
<div>
|
||||
<p class="check-col-title">Address mode</p>
|
||||
<select><option selected>Any address of the domain</option><option>Specific addresses (list)</option></select>
|
||||
</div>
|
||||
<div>
|
||||
<p class="check-col-title">Trusted-IP override <span class="st st-ok">active</span></p>
|
||||
<textarea rows="1">203.0.113.40</textarea>
|
||||
<div class="field-row equal">
|
||||
<div class="field">
|
||||
<label>Message limit</label>
|
||||
<input value="80">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Window (seconds)</label>
|
||||
<input value="60">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="app-item">
|
||||
<p class="app-login">billing</p>
|
||||
<p class="app-addr muted">Fixed list — invoices@example.com</p>
|
||||
<div class="actions">
|
||||
<button type="button">Edit</button>
|
||||
<button type="button">New password</button>
|
||||
<button type="button" class="danger">Delete</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pair">
|
||||
<div class="card" id="domain-settings-dmarc">
|
||||
<div class="card-head">
|
||||
<h2>DMARC reports</h2>
|
||||
<label class="help-link" for="help-domain-settings" title="Reports and rate limits">?</label>
|
||||
</div>
|
||||
<label>Aggregate reports (rua=)</label>
|
||||
<select><option selected>Same as Settings (dmarc@mail.example.org)</option><option>No aggregate reports</option><option>Custom address</option></select>
|
||||
<div class="actions-row"><button type="button">Save DMARC report settings</button></div>
|
||||
</div>
|
||||
<div class="card" id="domain-settings-limit">
|
||||
<div class="card-head">
|
||||
<h2>Level-2 rate limit <span class="st st-ok">active</span></h2>
|
||||
</div>
|
||||
<div class="field-row equal">
|
||||
<div class="field">
|
||||
<label>Message limit</label>
|
||||
<input value="40">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Window (seconds)</label>
|
||||
<input value="60">
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions-row"><button type="button">Save limit</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pair">
|
||||
<div class="card" id="export-domain">
|
||||
<div class="card-head">
|
||||
<h2>Export domain</h2>
|
||||
<label class="help-link" for="help-export" title="What the file contains">?</label>
|
||||
</div>
|
||||
<p class="muted"><strong>Secret file</strong> — transfer securely, or encrypt as <code>.spde</code>.</p>
|
||||
<div class="encrypt">
|
||||
<label class="check"><input type="checkbox"> <span>Encrypt with a password</span></label>
|
||||
<div class="encrypt-fields">
|
||||
<label>Password</label><input type="password">
|
||||
<label>Repeat password</label><input type="password">
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions-row"><button type="button">Export domain</button></div>
|
||||
</div>
|
||||
<div class="card g-only">
|
||||
<h2>Danger zone</h2>
|
||||
<p class="muted">Deletes the DKIM key and every application on this domain.</p>
|
||||
<div class="actions-row"><a class="danger" href="domain-delete.html">Delete domain</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script src="shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user