Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
403ce93990 | ||
|
|
252e5d1cff | ||
|
|
5af4d6c368 | ||
|
|
ddde177776 | ||
|
|
e69d139487 | ||
|
|
3f0d15c9e3 | ||
|
|
bbd2d83364 | ||
|
|
c5588ebba1 | ||
|
|
f6838e4696 | ||
|
|
36db4223f2 | ||
|
|
80748e6c94 | ||
|
|
c5086510f0 | ||
|
|
5b1a868ccf | ||
|
|
c36bd60022 | ||
|
|
baa053d250 | ||
|
|
ce41a8cf34 | ||
|
|
783bca806a | ||
|
|
088c8a5986 | ||
|
|
b951eae58c | ||
|
|
ea847fea44 | ||
|
|
06ae9ca8d3 | ||
|
|
be85cb71c9 | ||
|
|
ed3cb4aaac | ||
|
|
f37424d9fc | ||
|
|
96db0b7a86 | ||
|
|
e15f8896c5 | ||
|
|
fc53ad61ee | ||
|
|
4ab63d08ae | ||
|
|
ef19c1448e | ||
|
|
b2fbe91de9 | ||
|
|
32c6ae98a1 | ||
|
|
fa696efa8b | ||
|
|
610023ef6a | ||
|
|
2f1ec65b30 | ||
|
|
acdfaaecd7 | ||
|
|
5c654723c2 | ||
|
|
5b979a0540 | ||
|
|
f59f4778c9 | ||
|
|
2b4a158423 | ||
|
|
ef8a82e4c3 | ||
|
|
783dabbea5 | ||
|
|
b6feac2200 | ||
|
|
bd746c61d7 | ||
|
|
3b86f6c3b6 | ||
|
|
8d1af89e1a | ||
|
|
53c83f690c | ||
|
|
5be1504cfc | ||
|
|
f861d78114 | ||
|
|
92678c4157 | ||
|
|
2fa0bef6c2 | ||
|
|
049d8c781c | ||
|
|
57c6fddfc3 | ||
|
|
1e92c5b2a2 | ||
|
|
6bfaa518d7 | ||
|
|
7ef7f75246 | ||
|
|
967d3d95de | ||
|
|
7b8685d038 | ||
|
|
9d8b19d14a | ||
|
|
20d88e1d9c | ||
|
|
60b743e6c2 | ||
|
|
dca7d99c1b | ||
|
|
656c4d4d7f | ||
|
|
0730b2090e | ||
|
|
60e08e802e | ||
|
|
f62fe23826 | ||
|
|
75207f0d1e | ||
|
|
83c488cc48 | ||
|
|
5e5d157496 | ||
|
|
72b09d60a6 | ||
|
|
af9f89b628 | ||
|
|
981b95718e | ||
|
|
8ce050ef7f |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "mockups",
|
||||
"runtimeExecutable": "python",
|
||||
"runtimeArgs": ["-m", "http.server", "8765", "--bind", "127.0.0.1"],
|
||||
"port": 8765
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
description: The panel's design is fixed by accepted mockups; implement it, do not reinterpret it.
|
||||
globs: internal/web/view/**,docs/assets/panel-redesign/**
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Panel design contract
|
||||
|
||||
Source of truth, in this order: the mockups in
|
||||
[docs/assets/panel-redesign/panel/](docs/assets/panel-redesign/index.html),
|
||||
the [components page](docs/assets/panel-redesign/panel/components.html),
|
||||
`panel/outlines.json`, then
|
||||
[docs/plans/panel-redesign.md](docs/plans/panel-redesign.md). Read the plan's
|
||||
**The contract** and **Enforcement** sections before touching a template.
|
||||
|
||||
You are implementing a finished design. You are not designing.
|
||||
|
||||
- Build pages only from Bulma classes in the `BULMA` list of `panel/check.py`
|
||||
and from the `sp-` components on the components page, through their
|
||||
partials. No other class, no hand-written component markup, no inline style.
|
||||
- No selector in `panel.css` that names a page or an id, uses `!important`, or
|
||||
sets a width outside `WIDTH_OK`. If a page looks wrong, the layout choice is
|
||||
wrong — do not patch it locally.
|
||||
- If a screen seems to need a component, state or layout that does not exist:
|
||||
**stop and ask.** Do not approximate it, and do not substitute a look of
|
||||
your own.
|
||||
- Never edit a mockup, `outlines.json`, `check.py`, the vendored Bulma or a
|
||||
guard test to make your work pass. A failing guard means the implementation
|
||||
is wrong. Design changes go mockup-first in a separate `design:` commit on
|
||||
the owner's explicit instruction.
|
||||
- "Done" is evidence, not a description: passing `go test ./internal/web/...`
|
||||
output plus a screenshot of each restyled page from the running container,
|
||||
saved under `docs/assets/panel-redesign/evidence/` beside its mockup's name.
|
||||
Report every difference from the mockup you could not remove; do not
|
||||
describe a different result as the requested one.
|
||||
|
||||
After editing a fragment in `panel/src/`: `python panel/build.py`, then
|
||||
`python panel/check.py` (add `--write` only when the structure change is the
|
||||
point of the commit).
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env bash
|
||||
# GUARD FILE — docs/plans/panel-redesign.md § Enforcement, "design-first".
|
||||
#
|
||||
# Checks every commit of a push or pull request, one by one:
|
||||
#
|
||||
# 1. Design first. A commit that touches the accepted design — the mockups
|
||||
# under docs/assets/panel-redesign/panel/, the guard tests, this script —
|
||||
# touches nothing else under internal/web/. The guards are not adjusted
|
||||
# in the commit they are supposed to judge.
|
||||
# 2. The ratchet. internal/web/view/legacy_pages.txt only shrinks: a commit's
|
||||
# list is a subset of its parent's. A page that was restyled stays held
|
||||
# to its mockup; its name cannot be put back.
|
||||
# 3. No blob from the past. A template a commit adds or changes is not,
|
||||
# whitespace aside, a template that already existed anywhere in the
|
||||
# history before it. Taking an old page out of git is not a redesign.
|
||||
#
|
||||
# Usage: design-first.sh <base> <head> (commits base..head are checked)
|
||||
# Needs the full history: actions/checkout with fetch-depth: 0.
|
||||
set -euo pipefail
|
||||
|
||||
base="${1:?usage: design-first.sh <base> <head>}"
|
||||
head="${2:?usage: design-first.sh <base> <head>}"
|
||||
|
||||
RATCHET=internal/web/view/legacy_pages.txt
|
||||
TEMPLATES=internal/web/view/templates/
|
||||
ZERO=0000000000000000000000000000000000000000
|
||||
|
||||
# A new branch, or a force push whose old tip is gone, has no usable base:
|
||||
# check the tip commit alone rather than nothing.
|
||||
if [ "$base" = "$ZERO" ] || ! git cat-file -e "$base^{commit}" 2>/dev/null; then
|
||||
base="$(git rev-parse "$head^" 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [ -n "$base" ]; then
|
||||
commits="$(git rev-list --reverse --no-merges "$base..$head")"
|
||||
else
|
||||
commits="$(git rev-list --reverse --no-merges "$head")"
|
||||
fi
|
||||
|
||||
is_guard() {
|
||||
case "$1" in
|
||||
docs/assets/panel-redesign/panel/*) return 0 ;;
|
||||
.github/scripts/design-first.sh) return 0 ;;
|
||||
internal/web/guard_*_test.go | internal/web/*/guard_*_test.go) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
entries() { # the ratchet's entries at a commit, sorted; nothing if the file is absent
|
||||
git show "$1:$RATCHET" 2>/dev/null | tr -d '\r' | sed -e 's/#.*//' -e 's/[[:space:]]//g' -e '/^$/d' | sort -u
|
||||
}
|
||||
|
||||
normalised() { # a blob's hash with all whitespace removed
|
||||
git cat-file blob "$1" | tr -d ' \t\r\n' | git hash-object --stdin
|
||||
}
|
||||
|
||||
cache="$(mktemp -d)"
|
||||
trap 'rm -rf "$cache"' EXIT
|
||||
|
||||
past_hash() { # normalised hash of a historical blob, computed once
|
||||
if [ ! -f "$cache/$1" ]; then
|
||||
normalised "$1" > "$cache/$1"
|
||||
fi
|
||||
cat "$cache/$1"
|
||||
}
|
||||
|
||||
fail=0
|
||||
say() {
|
||||
echo "::error::design-first: $*"
|
||||
fail=1
|
||||
}
|
||||
|
||||
for c in $commits; do
|
||||
short="$(git rev-parse --short "$c")"
|
||||
parent="$(git rev-parse --verify --quiet "$c^" || true)"
|
||||
if [ -n "$parent" ]; then
|
||||
changed="$(git diff --name-only --no-renames "$parent" "$c")"
|
||||
else
|
||||
changed="$(git ls-tree -r --name-only "$c")"
|
||||
fi
|
||||
|
||||
# 1. design first
|
||||
guards="" impl=""
|
||||
while IFS= read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
if is_guard "$f"; then
|
||||
guards="$guards $f"
|
||||
elif [ "${f#internal/web/}" != "$f" ]; then
|
||||
impl="$impl $f"
|
||||
fi
|
||||
done <<< "$changed"
|
||||
if [ -n "$guards" ] && [ -n "$impl" ]; then
|
||||
say "$short changes the design or its guards ($(echo $guards | cut -c1-200)) together with the implementation ($(echo $impl | cut -c1-200)). A design change is its own 'design:' commit, made and accepted first."
|
||||
fi
|
||||
|
||||
# 2. the ratchet
|
||||
if [ -n "$parent" ] && grep -qx "$RATCHET" <<< "$changed"; then
|
||||
# Once the file has existed, a parent without it means an empty list.
|
||||
if git cat-file -e "$parent:$RATCHET" 2>/dev/null || [ -n "$(git log -1 --format=%h "$parent" -- "$RATCHET")" ]; then
|
||||
added="$(comm -13 <(entries "$parent") <(entries "$c"))"
|
||||
if [ -n "$added" ]; then
|
||||
say "$short adds to $RATCHET: $(echo $added). The list only shrinks — what was held to the design stays held."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3. no blob from the past
|
||||
if [ -n "$parent" ]; then
|
||||
touched="$(git diff --name-only --no-renames --diff-filter=AM "$parent" "$c" -- "$TEMPLATES")"
|
||||
if [ -n "$touched" ]; then
|
||||
past="$(git log --format= --raw --no-abbrev --no-renames "$parent" -- "$TEMPLATES" | awk '$4 != "'$ZERO'" {print $4}' | sort -u)"
|
||||
while IFS= read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
now="$(normalised "$(git rev-parse "$c:$f")")"
|
||||
for blob in $past; do
|
||||
if [ "$(past_hash "$blob")" = "$now" ]; then
|
||||
say "$short: $f is, whitespace aside, a template that already existed in history (blob ${blob:0:12}). An old template is not a redesigned page."
|
||||
break
|
||||
fi
|
||||
done
|
||||
done <<< "$touched"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
echo "design-first: $(echo "$commits" | grep -c . || true) commit(s) checked, nothing to report"
|
||||
@@ -1,19 +1,19 @@
|
||||
name: release
|
||||
|
||||
# Publishes an immutable, version-tagged image on ghcr.io (spec 10.1).
|
||||
# Publishes an immutable, version-tagged image on ghcr.io.
|
||||
# Ordinary commits and bare git tag pushes do not publish anything. A published
|
||||
# GitHub Release runs this workflow directly (same pattern as gosentry / imap-scrub).
|
||||
# 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 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 then test) is deliberate: it means the bytes that get tagged
|
||||
# are exactly the bytes that passed e2e. Per-arch tags are pushed only so
|
||||
# imagetools can assemble the multi-arch manifest; merge removes them from GHCR
|
||||
# so operators see a single version tag (spec 10.1).
|
||||
# Native per-architecture builds (see docs/development.md), not qemu: running
|
||||
# the full Postfix/OpenDKIM 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
|
||||
# then test) is deliberate: it means the bytes that get tagged are exactly the
|
||||
# bytes that passed e2e. The per-arch tags stay in GHCR next to the version
|
||||
# tag: they are the images the multi-arch manifest points at, and GHCR cannot
|
||||
# drop a tag without deleting the image behind it.
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
@@ -177,28 +177,35 @@ jobs:
|
||||
backoff=$((backoff * 2))
|
||||
done
|
||||
|
||||
- name: Remove per-arch tags from GHCR
|
||||
# Side-effect tags for imagetools assembly only — not part of the public
|
||||
# version surface (deploy/docker-compose.yml pins X.Y.Z, not X.Y.Z-amd64).
|
||||
# imagetools has no "rm" subcommand; delete via the GitHub Packages API.
|
||||
- name: Remove orphaned untagged versions from GHCR
|
||||
# Re-publishing a version moves its tags to new manifests and leaves the
|
||||
# old ones behind as untagged "sha256:..." package versions. Delete only
|
||||
# those that no tagged manifest references: the images behind a
|
||||
# multi-arch tag are package versions too, and deleting one breaks pulls
|
||||
# of that tag. Any failed lookup aborts the step before anything is
|
||||
# deleted.
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${{ needs.prepare.outputs.version }}"
|
||||
image="ghcr.io/${{ github.repository }}"
|
||||
owner="${{ github.repository_owner }}"
|
||||
pkg="${{ github.event.repository.name }}"
|
||||
api="/users/${owner}/packages/container/${pkg}/versions"
|
||||
for suffix in amd64 arm64; do
|
||||
tag="${version}-${suffix}"
|
||||
mapfile -t ids < <(gh api "$api" --paginate \
|
||||
--jq ".[] | select([.metadata.container.tags[]] | index(\"${tag}\")) | .id")
|
||||
if [ "${#ids[@]}" -eq 0 ]; then
|
||||
echo "no GHCR package version for tag ${tag}"
|
||||
versions=$(gh api "${api}?per_page=100" --paginate \
|
||||
--jq '.[] | [.id, .name, (.metadata.container.tags | length)] | @tsv')
|
||||
keep=$(mktemp)
|
||||
while IFS=$'\t' read -r id digest ntags; do
|
||||
[ "$ntags" -gt 0 ] || continue
|
||||
echo "$digest" >> "$keep"
|
||||
raw=$(docker buildx imagetools inspect --raw "${image}@${digest}")
|
||||
jq -r '.manifests[]?.digest' <<<"$raw" >> "$keep"
|
||||
done <<<"$versions"
|
||||
while IFS=$'\t' read -r id digest ntags; do
|
||||
[ "$ntags" -eq 0 ] || continue
|
||||
if grep -qxF "$digest" "$keep"; then
|
||||
continue
|
||||
fi
|
||||
for id in "${ids[@]}"; do
|
||||
echo "deleting GHCR package version ${id} (tag ${tag})"
|
||||
gh api -X DELETE "${api}/${id}"
|
||||
done
|
||||
done
|
||||
echo "deleting orphaned GHCR package version ${id} (${digest})"
|
||||
gh api -X DELETE "${api}/${id}"
|
||||
done <<<"$versions"
|
||||
|
||||
@@ -10,6 +10,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# design-first.sh reads each commit's parent and the template history.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Design first
|
||||
env:
|
||||
BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
HEAD: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
run: bash .github/scripts/design-first.sh "$BASE" "$HEAD"
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# SelfPost build.
|
||||
#
|
||||
# The version is stamped into both binaries and MUST match the Docker image tag
|
||||
# (spec 7.5.A: backup/restore compatibility check). Override on the command line:
|
||||
# (architecture.md § Persistence: backup/restore compatibility check). Override
|
||||
# on the command line:
|
||||
#
|
||||
# make build VERSION=1.3.0
|
||||
#
|
||||
# CGO is disabled to keep the binaries fully static (modernc.org/sqlite is pure
|
||||
# Go, so no cgo is required) — see spec 7.1.
|
||||
# Go, so no cgo is required).
|
||||
|
||||
VERSION ?= dev
|
||||
MODULE := github.com/mixeme/selfpost
|
||||
|
||||
@@ -21,6 +21,22 @@ htmx 2.0.4 (internal/web/view/static/htmx.min.js)
|
||||
Licence: Zero-Clause BSD (0BSD)
|
||||
https://github.com/bigskysoftware/htmx
|
||||
|
||||
Bulma 1.0.4 (internal/web/view/static/bulma.min.css)
|
||||
Copyright (c) 2023 Jeremy Thomas
|
||||
Licence: MIT
|
||||
https://github.com/jgthms/bulma
|
||||
Distributed unmodified.
|
||||
|
||||
Tabler Icons 3.49.0
|
||||
Copyright (c) 2020-2026 Paweł Kuna
|
||||
Licence: MIT
|
||||
https://github.com/tabler/tabler-icons
|
||||
Distributed as two files:
|
||||
internal/web/view/static/tabler-icons.css
|
||||
internal/web/view/static/tabler-icons.woff2
|
||||
Webfont subset to the 57 icons the panel uses, made with fontTools
|
||||
pyftsubset.
|
||||
|
||||
IBM Plex Sans / IBM Plex Mono
|
||||
Copyright © 2017 IBM Corp., with Reserved Font Name "Plex"
|
||||
Licence: SIL Open Font License 1.1
|
||||
|
||||
@@ -4,13 +4,16 @@
|
||||
|
||||
# SelfPost
|
||||
|
||||
Self-hosted outbound SMTP relay with a web control panel, shipped as a single
|
||||
Docker image. Postfix, OpenDKIM, and a small Go panel run together under
|
||||
`supervisord`; you configure domains, DKIM keys, and SASL applications once,
|
||||
then point your apps at the SMTP endpoint.
|
||||
Self-hosted SMTP relay with a web panel — one Docker image, no provider.
|
||||
|
||||
SelfPost sends mail straight to the internet from **your own IP**, with per-domain
|
||||
DKIM signing. It is **outbound only** — no inbound mail, mailboxes, or webmail.
|
||||
Postfix, OpenDKIM, and a small Go panel run together under `supervisord`; you
|
||||
configure domains, DKIM keys, and SASL applications once, then point your apps
|
||||
at the SMTP endpoint.
|
||||
|
||||
SelfPost sends mail straight to the internet from **your own IP**, with
|
||||
per-domain DKIM signing. No mailboxes or webmail. An optional inbound relay
|
||||
(backup-MX / forwarder on port 25) forwards to an upstream; it does not store
|
||||
mail.
|
||||
|
||||
**For:** operators who run their own VPS or home server and want a simple relay
|
||||
they control, without a third-party SMTP provider.
|
||||
@@ -21,7 +24,11 @@ send log and DNS checks in the panel, encrypted backups.
|
||||
## Features
|
||||
|
||||
- Outbound SMTP (465/smtps; optional 587 submission) with per-domain DKIM signing
|
||||
- Web panel — domains, applications, deliveries, mail queue, system log, backup
|
||||
- Optional inbound relay on port 25 (backup-MX / forwarder; off by default)
|
||||
- Optional DMARC aggregate report ingest on port 25 (off by default)
|
||||
- In-panel Help: a page of topics, opened from the `?` in the head of a box
|
||||
- Per-application client IP allow-list for SMTP AUTH (optional)
|
||||
- Web panel grouped by mail direction — Overview, Outbound (domains, log, queue, DMARC reports), Inbound, Server (health, system log, backup, users, settings)
|
||||
- Multi-domain relay — each SASL application is bound to one sending domain
|
||||
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
|
||||
- Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides
|
||||
@@ -78,6 +85,15 @@ For every domain you add in the panel:
|
||||
|
||||
See [Domain-level DNS](docs/guide.md#domain-level-dns-spf-dkim-dmarc) in the operator guide.
|
||||
|
||||
### Per inbound domain (optional)
|
||||
|
||||
Only if you turn on inbound relay (`INBOUND_RELAY_ENABLE=true`):
|
||||
|
||||
- [ ] MX record pointing at `SELFPOST_HOSTNAME` (keep any existing primary MX
|
||||
if this host is backup-MX)
|
||||
|
||||
See [Inbound relay](docs/guide.md#inbound-relay) in the operator guide.
|
||||
|
||||
## Quick start
|
||||
|
||||
> **First boot — create the admin account.** On a fresh container SelfPost prints
|
||||
@@ -96,7 +112,7 @@ docker run --rm -d --name selfpost-try \
|
||||
-e SELFPOST_HOSTNAME=mail.local.test \
|
||||
-e PANEL_COOKIE_SECURE=false \
|
||||
-v selfpost-try-data:/data \
|
||||
ghcr.io/mixeme/selfpost:1.3.0
|
||||
ghcr.io/mixeme/selfpost:2.4.1
|
||||
```
|
||||
|
||||
**Get the setup URL** (pick one):
|
||||
@@ -136,8 +152,10 @@ DNS — lives in the operator guide's [Full
|
||||
deployment](docs/guide.md#full-deployment) section, with proxy-specific
|
||||
commands under [Reverse proxy](docs/guide.md#reverse-proxy-mandatory).
|
||||
|
||||
The compose file always publishes **465** and **587**; Postfix listens on 587
|
||||
only when `SUBMISSION_ENABLE=true` (see [Ports](docs/guide.md#ports)). Bump the
|
||||
The compose file always publishes **465**, **587**, and **25**; Postfix listens
|
||||
on 587 only when `SUBMISSION_ENABLE=true`, and on 25 when
|
||||
`INBOUND_RELAY_ENABLE=true` and/or `DMARC_REPORTS_ENABLE=true` (see
|
||||
[Ports](docs/guide.md#ports)). Bump the
|
||||
pinned image tag deliberately when upgrading, never `:latest` ([why](docs/guide.md#fixed-image-tag)). Optional
|
||||
variables (`TRUSTED_PROXY_CIDR`, rate limits, retention): see [Environment
|
||||
variables](docs/guide.md#environment-variables).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# SelfPost — single Debian-slim image running postfix + opendkim + panel under
|
||||
# supervisord (spec 4). Build from the repository root:
|
||||
# supervisord. Build from the repository root:
|
||||
#
|
||||
# docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .
|
||||
|
||||
@@ -10,7 +10,7 @@ FROM golang:1.26-bookworm AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Version stamped into both binaries; MUST match the image tag (spec 7.5.A).
|
||||
# Version stamped into both binaries; MUST match the image tag.
|
||||
ARG VERSION=dev
|
||||
|
||||
# Module metadata first for layer caching.
|
||||
@@ -52,30 +52,35 @@ RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Unprivileged user for the panel process (spec 7.6.8).
|
||||
# Unprivileged user for the panel process.
|
||||
RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel
|
||||
|
||||
# Shared group bridging the unprivileged services (spec 5.1, 6): 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
|
||||
# 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 \
|
||||
# The id is fixed, and it is the one the group has always had: 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).
|
||||
RUN groupadd --system --gid 998 selfpost \
|
||||
&& usermod -aG selfpost panel \
|
||||
&& usermod -aG selfpost opendkim \
|
||||
&& usermod -aG selfpost postfix
|
||||
|
||||
# The panel reads the mail queue with `postqueue -p` (spec 7.2.11, 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 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 the panel bypass any Postfix restriction that
|
||||
# a local user does not already have through the world-executable sendmail.
|
||||
# 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
|
||||
# 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. 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.
|
||||
RUN usermod -aG postdrop panel
|
||||
|
||||
# Runtime directories: milter sockets and the consolidated persistent root.
|
||||
@@ -107,10 +112,10 @@ COPY --chmod=0755 build/crashexit.py /usr/local/bin/crashexit.py
|
||||
COPY --chmod=0755 build/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
COPY --chmod=0644 build/supervisord.conf /etc/supervisor/supervisord.conf
|
||||
|
||||
# Published submission ports: 465 (smtps, primary) and 587 (submission, optional)
|
||||
# plus the panel on 8080. Outbound delivery dials remote MXs on 25 as a client,
|
||||
# which needs no inbound listener or EXPOSE.
|
||||
EXPOSE 8080 465 587
|
||||
# Published submission ports: 465 (smtps, primary) and 587 (submission, optional),
|
||||
# inbound SMTP on 25 when INBOUND_RELAY_ENABLE=true, plus the panel on 8080.
|
||||
# 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).
|
||||
# Does not verify TLS, DNS, or end-to-end delivery — see docs/guide.md Operations.
|
||||
@@ -118,5 +123,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
|
||||
CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1
|
||||
|
||||
# The entrypoint fixes /data ownership (bind mount) as root, then execs
|
||||
# supervisord, which becomes PID 1 and owns process supervision (spec 4).
|
||||
# supervisord, which becomes PID 1 and owns process supervision.
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
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 (spec 4).
|
||||
lingering "alive" with a dead Postfix/OpenDKIM/panel.
|
||||
|
||||
Communication with supervisord uses the event listener protocol over stdin and
|
||||
stdout, so stdout must carry only protocol tokens.
|
||||
|
||||
@@ -9,14 +9,14 @@ set -e
|
||||
|
||||
# SELFPOST_HOSTNAME is an identity, not a setting with a safe default: it must
|
||||
# simultaneously match the PTR/rDNS record, the certificate CN/SAN, and the
|
||||
# Cyrus SASL realm (spec 5.2 p.3, 8). The panel (main.go saslRealm()) and
|
||||
# postfix-config.sh each fall back independently when it's unset — to
|
||||
# `localhost` and to the container hostname respectively — so accounts get
|
||||
# written under one realm and looked up under another and authentication
|
||||
# silently fails for every application, while HELO also stops matching the
|
||||
# PTR record and mail that does go out lands in spam. No fallback can be
|
||||
# correct, so fail loudly here, before either side of that split has a chance
|
||||
# to run, rather than leave a green panel with broken mail.
|
||||
# Cyrus SASL realm. The panel (main.go saslRealm()) and postfix-config.sh each
|
||||
# fall back independently when it's unset — to `localhost` and to the container
|
||||
# hostname respectively — so accounts get written under one realm and looked up
|
||||
# under another and authentication silently fails for every application, while
|
||||
# HELO also stops matching the PTR record and mail that does go out lands in
|
||||
# spam. No fallback can be correct, so fail loudly here, before either side of
|
||||
# that split has a chance to run, rather than leave a green panel with broken
|
||||
# mail.
|
||||
if [ -z "$SELFPOST_HOSTNAME" ]; then
|
||||
cat >&2 <<'EOF'
|
||||
FATAL: SELFPOST_HOSTNAME is not set.
|
||||
@@ -52,11 +52,11 @@ case "$SELFPOST_HOSTNAME" in
|
||||
;;
|
||||
esac
|
||||
|
||||
# The persistent root /data is a host bind mount (spec 9), so it arrives owned
|
||||
# by the host user (typically root), not by the unprivileged panel user that
|
||||
# actually writes the SQLite database, setup token and DKIM keys (spec 7.6.8).
|
||||
# Fix its ownership here — the one place still running as root — before handing
|
||||
# off to supervisord, which starts the panel as the panel user.
|
||||
# The persistent root /data is a host bind mount, so it arrives owned by the
|
||||
# host user (typically root), not by the unprivileged panel user that actually
|
||||
# writes the SQLite database, setup token and DKIM keys. Fix its ownership here
|
||||
# — 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
|
||||
@@ -69,9 +69,9 @@ chmod 755 /data
|
||||
# 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 -exec chown -R panel:panel {} +
|
||||
find /data -mindepth 1 -maxdepth 1 ! -user panel ! -name log ! -name postfix -exec chown -R panel:panel {} +
|
||||
|
||||
# DKIM key tree (spec 6, 9). The panel (user `panel`) generates keys and writes
|
||||
# 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:
|
||||
@@ -89,7 +89,7 @@ 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 {} +
|
||||
|
||||
# Application SASL accounts (spec 5.1, 9). The panel (user `panel`) writes the
|
||||
# Application SASL accounts. The panel (user `panel`) writes the
|
||||
# sasldb2 via saslpasswd2; Postfix (user `postfix`) reads it to authenticate SMTP
|
||||
# clients. Share it through the `selfpost` group the same way as the DKIM tree:
|
||||
# setgid directory so new files inherit the group, and the database itself
|
||||
@@ -99,13 +99,29 @@ chown -R panel:selfpost /data/sasl
|
||||
chmod 2750 /data/sasl
|
||||
[ -e /data/sasl/sasldb2 ] && chmod 0640 /data/sasl/sasldb2
|
||||
|
||||
# Postfix sender_login_maps (spec 5.1). The panel writes it; Postfix reads it.
|
||||
# Ensure the file exists (empty is fine) before Postfix starts so a reload that
|
||||
# references it never fails on a missing file, and keep it group-readable.
|
||||
mkdir -p /data/postfix
|
||||
# Postfix state under /data (architecture.md § Persistence). The panel writes
|
||||
# sender_login_maps; Postfix owns the on-disk queue tree under queue/.
|
||||
mkdir -p /data/postfix/queue
|
||||
[ -e /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps
|
||||
chown -R panel:selfpost /data/postfix
|
||||
# Inbound relay maps (written by the panel when INBOUND_RELAY_ENABLE=true).
|
||||
# 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
|
||||
chown panel:selfpost /data/postfix
|
||||
chmod 2750 /data/postfix
|
||||
|
||||
# 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 panel:selfpost /data/antispam
|
||||
chmod 0755 /data/antispam
|
||||
chown panel:selfpost /data/postfix/sender_login_maps
|
||||
chmod 0640 /data/postfix/sender_login_maps
|
||||
|
||||
# Delivery log (architecture.md § Log tailer). postlogd writes it as user
|
||||
@@ -125,18 +141,23 @@ chown -R postfix:selfpost /data/log
|
||||
chmod 2750 /data/log
|
||||
find /data/log -type f -exec chmod 0640 {} +
|
||||
|
||||
# Milter socket directories (spec 5 p.3, 7.3). 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.
|
||||
# Milter socket directories. Postfix (user `postfix`) must actually CONNECT to
|
||||
# the milter sockets — OpenDKIM's and the panel's — 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
|
||||
chown panel:selfpost /run/selfpost
|
||||
chmod 2750 /run/opendkim /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 stays - it is how the downtime of a restart is told.
|
||||
rm -f /run/opendkim/*.sock /run/selfpost/*.sock
|
||||
|
||||
# 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
|
||||
@@ -144,4 +165,22 @@ 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
|
||||
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
|
||||
# postdrop and reach the showq socket and the maildrop directory through that
|
||||
# group alone. With the wrong group `postqueue -p` run by the panel answers
|
||||
# "Connect to the Postfix showq service: Permission denied" and the panel cannot
|
||||
# read the queue (the Queue card and the Outbound queue page).
|
||||
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.
|
||||
chown root /data/postfix/queue /data/postfix/queue/pid
|
||||
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
#!/bin/sh
|
||||
# Periodic logrotate for /data/log/mail.log (spec 9, 10). Rotation renames the
|
||||
# file, recreates it (`create 0640 postfix selfpost`, matching a cold container
|
||||
# Periodic logrotate for /data/log/mail.log. Rotation renames the file,
|
||||
# recreates it (`create 0640 postfix selfpost`, matching a cold container
|
||||
# start), then runs `postfix reload` (the same mechanism `postfix logrotate`
|
||||
# uses): postlogd keeps writing to the renamed inode until reload, and the
|
||||
# panel's log-tailer holds its own descriptor on that inode, so nothing
|
||||
# written 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.
|
||||
# panel's log-tailer holds its own descriptor on that inode, so nothing written
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# OpenDKIM — per-domain signing (spec 6).
|
||||
# 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 (spec 6.2, 6.5).
|
||||
# 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
|
||||
|
||||
@@ -1,28 +1,72 @@
|
||||
#!/bin/sh
|
||||
# Generate the outbound-relay Postfix configuration (spec 5, 5.1, 5.2).
|
||||
# Generate the outbound-relay Postfix configuration.
|
||||
#
|
||||
# Run as root from entrypoint.sh on every container start, BEFORE supervisord
|
||||
# starts the Postfix wrapper. Like the rest of SelfPost's runtime state it is
|
||||
# re-derived from the environment on each start rather than persisted, so a
|
||||
# fresh, restarted or restored container always ends up with the same config
|
||||
# (the only panel-edited Postfix file, sender_login_maps, lives under /data and
|
||||
# is generated separately by the panel — spec 5.1).
|
||||
# is generated separately by the panel).
|
||||
#
|
||||
# main.cf is written with `postconf -e`, the master.cf submission services with
|
||||
# `postconf -M`/`-P`. No user input is interpolated: every value here comes from
|
||||
# a fixed literal or a container environment variable (spec 7.6.3).
|
||||
# a fixed literal or a container environment variable.
|
||||
set -eu
|
||||
|
||||
# --- environment knobs (spec 8) ----------------------------------------------
|
||||
# Sets one -o option of the smtp/inet (port 25) service by patching its stanza.
|
||||
#
|
||||
# A master.cf -o value must not contain whitespace: master splits the line on
|
||||
# it, so "-o name=check_recipient_access texthash:/x,reject" reaches smtpd as
|
||||
# the option "name=check_recipient_access" plus a stray argument, and smtpd dies
|
||||
# at start with "fatal: unexpected command-line argument" - port 25 then
|
||||
# answers nobody. (postconf -P refuses such values for the same reason.) Postfix
|
||||
# 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.
|
||||
master_cf_set_smtp_inet_o() {
|
||||
param="$1"
|
||||
value="$2"
|
||||
case "$value" in
|
||||
*[[:space:]]*)
|
||||
echo "FATAL: master.cf -o ${param} would contain whitespace: ${value}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
awk -v p="$param" -v v="$value" '
|
||||
/^smtp[ \t]+inet[ \t]/ && !done {
|
||||
print
|
||||
print " -o " p "=" v
|
||||
done = 1
|
||||
in_smtp_inet = 1
|
||||
next
|
||||
}
|
||||
in_smtp_inet && /^[ \t]+-o / {
|
||||
line = $0
|
||||
sub(/^[ \t]+-o /, "", line)
|
||||
eq = index(line, "=")
|
||||
on = substr(line, 1, eq - 1)
|
||||
if (on == p) {
|
||||
next
|
||||
}
|
||||
}
|
||||
in_smtp_inet && /^[^ \t#-]/ {
|
||||
in_smtp_inet = 0
|
||||
}
|
||||
{ print }
|
||||
' /etc/postfix/master.cf > /etc/postfix/master.cf.new
|
||||
mv /etc/postfix/master.cf.new /etc/postfix/master.cf
|
||||
}
|
||||
|
||||
# --- environment knobs -------------------------------------------------------
|
||||
# Server hostname: used as HELO name AND, crucially, as the Cyrus SASL realm the
|
||||
# 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.
|
||||
HOSTNAME_VALUE="${SELFPOST_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}"
|
||||
|
||||
# TLS material supplied by the reverse-proxy through a read-only bind mount
|
||||
# (spec 5.2). The relay requires TLS on 465; if these files are absent the
|
||||
# master still starts but TLS handshakes on 465 fail until they appear.
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
@@ -44,25 +88,57 @@ if [ -f "$TLS_CERT_SRC" ] && [ -f "$TLS_KEY_SRC" ]; then
|
||||
TLS_KEY="$TLS_INTERNAL_DIR/privkey.pem"
|
||||
fi
|
||||
|
||||
# Level-1 rate limit (native Postfix anvil, spec 5 p.5 / 7.4). Conservative
|
||||
# defaults, sensible during IP warm-up (spec 10).
|
||||
# Level-1 rate limit (native Postfix anvil). Conservative defaults, sensible
|
||||
# during IP warm-up.
|
||||
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.
|
||||
# Milter sockets: OpenDKIM (signing, strict), the panel's sender check
|
||||
# (strict) and its journal-milter (monitoring, fail-open). Fixed container
|
||||
# paths, matched by postfix-wrapper.sh.
|
||||
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}"
|
||||
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}"
|
||||
|
||||
# Persistent panel-managed sender map (spec 5.1); texthash needs no postmap, so
|
||||
# the unprivileged panel can rewrite it and just ask for a reload.
|
||||
# Persistent panel-managed sender map; texthash needs no postmap, so the
|
||||
# unprivileged panel can rewrite it and just ask for a reload.
|
||||
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.
|
||||
QUEUE_DIR="${POSTFIX_QUEUE_DIR:-/data/postfix/queue}"
|
||||
SASLDB_PATH="${SASL_DB_PATH:-/data/sasl/sasldb2}"
|
||||
|
||||
# Optional submission service on 587 (spec 5 p.1: off by default, enabled only
|
||||
# when a client library needs STARTTLS on 587 instead of implicit TLS on 465).
|
||||
# Optional submission service on 587 (off by default, enabled only when a client
|
||||
# library needs STARTTLS on 587 instead of implicit TLS on 465).
|
||||
SUBMISSION_ENABLE="${SUBMISSION_ENABLE:-false}"
|
||||
|
||||
# Optional inbound relay (backup-MX / forwarder). Off by default: port 25 does
|
||||
# not accept mail, Postfix inbound maps are not referenced, and the panel UI is
|
||||
# absent. When true, smtp inet on 25 accepts only relay_domains + known
|
||||
# recipients (docs/plans/inbound-relay.md).
|
||||
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}"
|
||||
TRANSPORT_MAP="${POSTFIX_TRANSPORT_MAPS:-/data/postfix/transport}"
|
||||
RELAY_RECIPIENTS_MAP="${POSTFIX_RELAY_RECIPIENTS:-/data/postfix/relay_recipients}"
|
||||
TLS_POLICY_MAP="${POSTFIX_TLS_POLICY_MAPS:-/data/postfix/tls_policy}"
|
||||
|
||||
# Optional DMARC aggregate ingest (plans/dmarc-reports.md). Off by default:
|
||||
# port 25 does not accept report mail and the panel ingest maps stay empty.
|
||||
DMARC_REPORTS_ENABLE="${DMARC_REPORTS_ENABLE:-false}"
|
||||
DMARC_RATE_LIMIT_MESSAGES_PER_IP="${DMARC_RATE_LIMIT_MESSAGES_PER_IP:-20}"
|
||||
DMARC_MESSAGE_SIZE_LIMIT="${DMARC_MESSAGE_SIZE_LIMIT:-5242880}"
|
||||
DMARC_RECIPIENTS_MAP="${POSTFIX_DMARC_RECIPIENTS:-/data/postfix/dmarc_recipients}"
|
||||
DMARC_TRANSPORT_MAP="${POSTFIX_DMARC_TRANSPORT:-/data/postfix/dmarc_transport}"
|
||||
DMARC_RELAY_DOMAINS_MAP="${POSTFIX_DMARC_RELAY_DOMAINS:-/data/postfix/dmarc_relay_domains}"
|
||||
|
||||
# Delivery log, written by postlogd and read by the panel's log-tailer. It lives
|
||||
# under the persistent /data (not the ephemeral /var/log) so the delivery lines
|
||||
# for messages still marked "queued" survive a container recreate — without
|
||||
@@ -96,13 +172,13 @@ postconf -e \
|
||||
"alias_maps=" \
|
||||
"alias_database="
|
||||
|
||||
# Outbound delivery: straight to the recipient MX, opportunistic TLS (spec 5 p.2).
|
||||
# Outbound delivery: straight to the recipient MX, opportunistic TLS.
|
||||
postconf -e \
|
||||
"smtp_tls_security_level=may" \
|
||||
"smtp_tls_note_starttls_offer=yes"
|
||||
|
||||
# TLS server material shared by every inbound service (spec 5.2). auth_only
|
||||
# guarantees credentials are never accepted before TLS is up on any port.
|
||||
# TLS server material shared by every inbound service. auth_only guarantees
|
||||
# credentials are never accepted before TLS is up on any port.
|
||||
postconf -e \
|
||||
"smtpd_tls_cert_file=${TLS_CERT}" \
|
||||
"smtpd_tls_key_file=${TLS_KEY}" \
|
||||
@@ -110,11 +186,11 @@ postconf -e \
|
||||
"smtpd_tls_auth_only=yes" \
|
||||
"smtpd_tls_loglevel=1"
|
||||
|
||||
# SASL: Cyrus with the local sasldb2 the panel maintains (spec 5.1). 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.
|
||||
# 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.
|
||||
postconf -e \
|
||||
"smtpd_sasl_auth_enable=yes" \
|
||||
"smtpd_sasl_type=cyrus" \
|
||||
@@ -124,42 +200,67 @@ postconf -e \
|
||||
"smtpd_sasl_tls_security_options=noanonymous" \
|
||||
"broken_sasl_auth_clients=yes"
|
||||
|
||||
# Sender binding (spec 5.1 p.3, 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). texthash resolves the
|
||||
# full address first, then the "@domain" wildcard, so both address modes work
|
||||
# from the same map.
|
||||
#
|
||||
# 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.
|
||||
postconf -e \
|
||||
"smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}"
|
||||
"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
|
||||
# (spec 5 p.1/p.4, 5.1). This is what makes an open relay impossible.
|
||||
# permit_mynetworks anywhere — authorisation is by credentials, never by
|
||||
# network. This is what makes an open relay impossible.
|
||||
postconf -e \
|
||||
"smtpd_helo_required=yes" \
|
||||
"smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
|
||||
"smtpd_recipient_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
|
||||
"smtpd_sender_restrictions=reject_sender_login_mismatch, permit"
|
||||
|
||||
# Level-1 rate limit by client IP (spec 5 p.5). Backstop that keeps working even
|
||||
# if the journal-milter (level 2) is down.
|
||||
# Level-1 rate limit by client IP. Backstop that keeps working even if the
|
||||
# journal-milter (level 2) is down.
|
||||
postconf -e \
|
||||
"smtpd_client_message_rate_limit=${RATE_MSGS}" \
|
||||
"anvil_rate_time_unit=${RATE_WINDOW}s"
|
||||
|
||||
# Milter chain (spec 5 p.3, 7.3). 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. 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.
|
||||
#
|
||||
# The sender check comes first and is strict as well: it holds the From: line
|
||||
# against the login, which is what OpenDKIM then chooses the key by, so a
|
||||
# message it could not judge must not reach the signer. While the panel does
|
||||
# not answer on that socket, mail is deferred (docs/security.md).
|
||||
#
|
||||
# non_smtpd_milters is the chain for mail submitted locally with sendmail(1) -
|
||||
# the panel's own notifications, sent as postmaster@ the hostname. OpenDKIM
|
||||
# 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).
|
||||
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:${OPENDKIM_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \
|
||||
"non_smtpd_milters={ unix:${OPENDKIM_SOCK}, default_action=tempfail }"
|
||||
|
||||
# Bounded milter timeouts (spec 7.3): 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
|
||||
# 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.
|
||||
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,
|
||||
# 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
|
||||
@@ -170,8 +271,8 @@ postconf -e \
|
||||
"milter_content_timeout=${MILTER_CONTENT_TIMEOUT:-30s}"
|
||||
|
||||
# --- master.cf: inbound submission services ----------------------------------
|
||||
# smtps (465, implicit/wrapper TLS) — the primary, always-on submission service
|
||||
# (spec 5 p.1). chroot=n so smtpd can read the sasldb2 and sender map under /data
|
||||
# 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.
|
||||
postconf -M "smtps/inet=smtps inet n - n - - smtpd"
|
||||
postconf -P \
|
||||
@@ -179,10 +280,10 @@ postconf -P \
|
||||
"smtps/inet/smtpd_sasl_auth_enable=yes" \
|
||||
"smtps/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"
|
||||
|
||||
# submission (587, STARTTLS) — optional (spec 5 p.1). 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.
|
||||
# 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.
|
||||
if [ "${SUBMISSION_ENABLE}" = "true" ]; then
|
||||
postconf -M "submission/inet=submission inet n - n - - smtpd"
|
||||
postconf -P \
|
||||
@@ -193,18 +294,202 @@ else
|
||||
postconf -MX "submission/inet" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Disable chroot for every service (spec 5 p.2). Debian ships the smtp delivery
|
||||
# agent and others chrooted to /var/spool/postfix, where they cannot read
|
||||
# /etc/resolv.conf — so outbound MX lookups fail with "Host not found" and mail
|
||||
# never leaves. 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.
|
||||
# --- 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
|
||||
# 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
|
||||
# configured domains/recipients; DMARC accepts only allow-listed report addresses.
|
||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||
RELAY_DOMAINS_SETTING=""
|
||||
TRANSPORT_SETTING=""
|
||||
RECIPIENT_RESTRICTIONS="reject"
|
||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
|
||||
for f in "$RELAY_DOMAINS_MAP" "$TRANSPORT_MAP" "$RELAY_RECIPIENTS_MAP" "$TLS_POLICY_MAP"; do
|
||||
[ -e "$f" ] || : > "$f"
|
||||
done
|
||||
RELAY_DOMAINS_SETTING="texthash:${RELAY_DOMAINS_MAP}"
|
||||
TRANSPORT_SETTING="texthash:${TRANSPORT_MAP}"
|
||||
RECIPIENT_RESTRICTIONS="reject_unauth_destination,reject_unlisted_recipient"
|
||||
fi
|
||||
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||
for f in "$DMARC_RECIPIENTS_MAP" "$DMARC_TRANSPORT_MAP" "$DMARC_RELAY_DOMAINS_MAP"; do
|
||||
[ -e "$f" ] || : > "$f"
|
||||
done
|
||||
ln -sf /usr/local/bin/panel /usr/local/bin/dmarc-ingest
|
||||
# flags=, user= and argv= are arguments of pipe(8), not -o parameters:
|
||||
# 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.
|
||||
# ${recipient} is expanded by pipe(8), not by the shell: the command
|
||||
# learns which report address the message came to from its argument.
|
||||
# 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.
|
||||
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}"
|
||||
postconf -e "export_environment=TZ MAIL_CONFIG LANG ${DMARC_INGEST_ENV}"
|
||||
if [ -n "$RELAY_DOMAINS_SETTING" ]; then
|
||||
RELAY_DOMAINS_SETTING="${RELAY_DOMAINS_SETTING} texthash:${DMARC_RELAY_DOMAINS_MAP}"
|
||||
TRANSPORT_SETTING="${TRANSPORT_SETTING} texthash:${DMARC_TRANSPORT_MAP}"
|
||||
else
|
||||
RELAY_DOMAINS_SETTING="texthash:${DMARC_RELAY_DOMAINS_MAP}"
|
||||
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.
|
||||
RECIPIENT_RESTRICTIONS="check_recipient_access,texthash:${DMARC_RECIPIENTS_MAP},${RECIPIENT_RESTRICTIONS}"
|
||||
else
|
||||
postconf -MX "dmarc-ingest/unix" 2>/dev/null || true
|
||||
postconf -X dmarc-ingest_destination_recipient_limit export_environment 2>/dev/null || true
|
||||
fi
|
||||
postconf -e \
|
||||
"relay_domains=${RELAY_DOMAINS_SETTING}" \
|
||||
"transport_maps=${TRANSPORT_SETTING}" \
|
||||
"smtpd_reject_unlisted_recipient=yes"
|
||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
|
||||
# With the relay on, Postfix refuses every recipient of a relay domain
|
||||
# that relay_recipient_maps does not list (smtpd_reject_unlisted_recipient
|
||||
# above) - and it does so after the restriction list, whatever
|
||||
# check_recipient_access answered. The DMARC report addresses live on a
|
||||
# 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.
|
||||
RELAY_RECIPIENT_SETTING="texthash:${RELAY_RECIPIENTS_MAP}"
|
||||
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||
RELAY_RECIPIENT_SETTING="${RELAY_RECIPIENT_SETTING} texthash:${DMARC_RECIPIENTS_MAP}"
|
||||
fi
|
||||
postconf -e \
|
||||
"relay_recipient_maps=${RELAY_RECIPIENT_SETTING}" \
|
||||
"smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}"
|
||||
else
|
||||
postconf -e \
|
||||
"relay_recipient_maps=" \
|
||||
"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
|
||||
# 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: the optional antispam milter, then - with the inbound relay on - the
|
||||
# panel's inbound journal milter, or nothing at all.
|
||||
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
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
case "${INBOUND_ANTISPAM_MILTER_ACTION}" in
|
||||
accept|tempfail) ;;
|
||||
*)
|
||||
echo "FATAL: INBOUND_ANTISPAM_MILTER_ACTION must be accept or tempfail, got: ${INBOUND_ANTISPAM_MILTER_ACTION}" >&2
|
||||
exit 1
|
||||
;;
|
||||
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.
|
||||
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}"
|
||||
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||
PORT25_RATE="${DMARC_RATE_LIMIT_MESSAGES_PER_IP}"
|
||||
PORT25_SIZE="${DMARC_MESSAGE_SIZE_LIMIT}"
|
||||
fi
|
||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||
PORT25_RATE="${INBOUND_RATE_LIMIT_MESSAGES_PER_IP}"
|
||||
if [ "${INBOUND_MESSAGE_SIZE_LIMIT}" -gt "${DMARC_MESSAGE_SIZE_LIMIT}" ]; then
|
||||
PORT25_SIZE="${INBOUND_MESSAGE_SIZE_LIMIT}"
|
||||
else
|
||||
PORT25_SIZE="${DMARC_MESSAGE_SIZE_LIMIT}"
|
||||
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.
|
||||
postconf -M "smtp/inet=smtp inet n - n - - smtpd"
|
||||
postconf -P \
|
||||
"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=" \
|
||||
"smtp/inet/smtpd_sender_restrictions=" \
|
||||
"smtp/inet/smtpd_client_restrictions=" \
|
||||
"smtp/inet/smtpd_relay_restrictions=reject_unauth_destination" \
|
||||
"smtp/inet/smtpd_client_message_rate_limit=${PORT25_RATE}" \
|
||||
"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}"
|
||||
else
|
||||
postconf -MX "smtp/inet" 2>/dev/null || true
|
||||
postconf -MX "dmarc-ingest/unix" 2>/dev/null || true
|
||||
postconf -X dmarc-ingest_destination_recipient_limit export_environment 2>/dev/null || true
|
||||
postconf -e \
|
||||
"relay_domains=" \
|
||||
"transport_maps=" \
|
||||
"relay_recipient_maps=" \
|
||||
"smtp_tls_policy_maps="
|
||||
fi
|
||||
|
||||
# Disable chroot for every service. Debian ships the smtp delivery agent and
|
||||
# others chrooted to /var/spool/postfix, where they cannot read /etc/resolv.conf
|
||||
# — so outbound MX lookups fail with "Host not found" and mail never leaves.
|
||||
# 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.
|
||||
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 (spec 5.1). PLAIN/LOGIN
|
||||
# passwords straight from the panel-maintained sasldb2. PLAIN/LOGIN
|
||||
# only — both are safe because TLS is mandatory before auth on every port.
|
||||
mkdir -p /etc/postfix/sasl
|
||||
cat > /etc/postfix/sasl/smtpd.conf <<EOF
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
#!/bin/sh
|
||||
# Postfix start wrapper (spec 4): solves the cold-start race where Postfix would
|
||||
# try to reach the milter sockets before they are listening.
|
||||
# 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
|
||||
# It blocks until the milter sockets — OpenDKIM, the panel's sender check and
|
||||
# journal-milter and, with the inbound relay on, its inbound 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.
|
||||
#
|
||||
# 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 OpenDKIM 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}"
|
||||
TIMEOUT="${MILTER_WAIT_TIMEOUT:-30}"
|
||||
INTERVAL=1
|
||||
|
||||
SOCKS="$OPENDKIM_SOCK $JOURNAL_SOCK $SENDER_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 +38,5 @@ 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"
|
||||
echo "postfix-wrapper: milter sockets ready, starting postfix"
|
||||
exec postfix start-fg
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
; SelfPost process supervision (spec 4).
|
||||
; 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.
|
||||
; the sender check's and the journal-milter's sockets), then the Postfix
|
||||
; wrapper — which additionally blocks until the milter sockets are ready
|
||||
; before starting Postfix.
|
||||
;
|
||||
; If any managed process exhausts its restart retries (FATAL), the crashexit
|
||||
; event listener brings the whole container down so Docker's restart policy can
|
||||
@@ -18,8 +19,8 @@ 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 (spec 6.2). It is
|
||||
; still unreachable by world; only root and the two SelfPost services are in the
|
||||
; 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.
|
||||
[unix_http_server]
|
||||
@@ -67,15 +68,15 @@ stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; One-shot reload helper (spec 5.2, 7.2.12). 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 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.
|
||||
; 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
|
||||
; 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.
|
||||
[program:postfix-reload]
|
||||
command=/usr/sbin/postfix reload
|
||||
autostart=false
|
||||
@@ -88,7 +89,7 @@ stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; Periodic TLS-certificate refresh (spec 5.2 p.4). Runs a daily `postfix reload`
|
||||
; Periodic TLS-certificate refresh. Runs a daily `postfix reload`
|
||||
; so certificates the reverse-proxy renews in the read-only mount are picked up.
|
||||
; 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.
|
||||
@@ -103,10 +104,10 @@ stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; Periodic logrotate for /data/log/mail.log (spec 9, 10: 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, 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.
|
||||
[program:logrotate]
|
||||
command=/usr/local/bin/logrotate-loop.sh
|
||||
priority=400
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// The panel reads the inbound filter's two variables with the semantics of
|
||||
// build/postfix-config.sh: the action is accept unless it says tempfail, and a
|
||||
// value the script would refuse is not guessed at.
|
||||
func TestAntispamActionMirrorsTheBuildScript(t *testing.T) {
|
||||
for raw, want := range map[string]string{
|
||||
"": "accept", "accept": "accept", "tempfail": "tempfail",
|
||||
"reject": "", "Accept": "", "TEMPFAIL": "", " accept": "",
|
||||
} {
|
||||
if got := antispamAction(raw); got != want {
|
||||
t.Errorf("antispamAction(%q) = %q, want %q", raw, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigReadsTheInboundFilter(t *testing.T) {
|
||||
t.Setenv("INBOUND_RELAY_ENABLE", "true")
|
||||
t.Setenv("INBOUND_ANTISPAM_MILTER", "inet:antispam:11332")
|
||||
t.Setenv("INBOUND_ANTISPAM_MILTER_ACTION", "tempfail")
|
||||
cfg := loadConfig()
|
||||
if !cfg.inboundEnabled || cfg.inboundAntispamMilter != "inet:antispam:11332" || cfg.inboundAntispamAction != "tempfail" {
|
||||
t.Errorf("config = %+v", cfg)
|
||||
}
|
||||
|
||||
t.Setenv("INBOUND_ANTISPAM_MILTER", "")
|
||||
t.Setenv("INBOUND_ANTISPAM_MILTER_ACTION", "")
|
||||
cfg = loadConfig()
|
||||
if cfg.inboundAntispamMilter != "" || cfg.inboundAntispamAction != "accept" {
|
||||
t.Errorf("unset filter: milter %q, action %q", cfg.inboundAntispamMilter, cfg.inboundAntispamAction)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dmarc"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
const maxIngestStdin = 10 << 20 // 10 MiB
|
||||
|
||||
func runDMARCIngestMode() error {
|
||||
cfg := loadConfig()
|
||||
if !cfg.dmarcEnabled {
|
||||
return fmt.Errorf("dmarc ingest invoked but DMARC_REPORTS_ENABLE is not true")
|
||||
}
|
||||
st, err := store.Open(cfg.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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 !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.
|
||||
if incrErr := st.IncrDMARCParseFailures(); incrErr != nil {
|
||||
log.Printf("dmarc ingest: record failure: %v", incrErr)
|
||||
}
|
||||
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. postlog is no use here: pipe(8) starts
|
||||
// the command without the group that may write to Postfix's log socket.
|
||||
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).
|
||||
// Without it the check that a report came to its own domain's address is
|
||||
// skipped.
|
||||
func ingestRecipient(args []string) string {
|
||||
for _, a := range args {
|
||||
if a == "-dmarc-ingest" {
|
||||
continue
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(a))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isDMARCIngestInvocation() bool {
|
||||
if len(os.Args) > 1 && os.Args[1] == "-dmarc-ingest" {
|
||||
return true
|
||||
}
|
||||
return filepath.Base(os.Args[0]) == "dmarc-ingest"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIngestRecipient(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
// As pipe(8) starts it: the symlink name, then ${recipient}.
|
||||
{[]string{"dmarc-reports+Example.org@mail.example.org"}, "dmarc-reports+example.org@mail.example.org"},
|
||||
{[]string{"-dmarc-ingest", "dmarc-reports+example.org@mail.example.org"}, "dmarc-reports+example.org@mail.example.org"},
|
||||
{[]string{"-dmarc-ingest"}, ""},
|
||||
{nil, ""},
|
||||
} {
|
||||
if got := ingestRecipient(tc.args); got != tc.want {
|
||||
t.Errorf("ingestRecipient(%q) = %q, want %q", tc.args, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,14 @@ var documentedPublic = []string{
|
||||
"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",
|
||||
}
|
||||
|
||||
// documentedInternal matches architecture.md § Configuration "Internal env vars".
|
||||
@@ -24,6 +32,7 @@ var documentedInternal = []string{
|
||||
"SELFPOST_SETUP_TOKEN_FILE",
|
||||
"PANEL_HTTP_ADDR",
|
||||
"JOURNAL_MILTER_SOCKET",
|
||||
"SENDER_CHECK_SOCKET",
|
||||
"MAIL_LOG",
|
||||
"PANEL_COOKIE_SECURE",
|
||||
"OPENDKIM_SOCKET",
|
||||
@@ -33,6 +42,15 @@ var documentedInternal = []string{
|
||||
"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",
|
||||
@@ -53,6 +71,7 @@ var loadConfigKeys = []string{
|
||||
"SELFPOST_DATA_DIR",
|
||||
"PANEL_HTTP_ADDR",
|
||||
"JOURNAL_MILTER_SOCKET",
|
||||
"SENDER_CHECK_SOCKET",
|
||||
"MAIL_LOG",
|
||||
"SEND_LOG_RETENTION_DAYS",
|
||||
"SELFPOST_DB_PATH",
|
||||
@@ -70,6 +89,11 @@ var loadConfigKeys = []string{
|
||||
"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
|
||||
@@ -82,10 +106,27 @@ var buildScriptKeys = []string{
|
||||
"RATE_LIMIT_WINDOW_SECONDS",
|
||||
"OPENDKIM_SOCKET",
|
||||
"JOURNAL_MILTER_SOCKET",
|
||||
"SENDER_CHECK_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",
|
||||
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
|
||||
"github.com/mixeme/selfpost/internal/app"
|
||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||
"github.com/mixeme/selfpost/internal/dmarc"
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/inbound"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web"
|
||||
@@ -19,10 +21,15 @@ import (
|
||||
// mailStack is the panel's domain and application services plus the on-disk
|
||||
// mail-path adapters they write through.
|
||||
type mailStack struct {
|
||||
Domains *domain.Service
|
||||
Apps *app.Service
|
||||
pf *postfix.Postfix
|
||||
odk *domain.OpenDKIM
|
||||
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 {
|
||||
@@ -30,7 +37,16 @@ func newMailStack(cfg config, st *store.Store) *mailStack {
|
||||
odk := domain.NewOpenDKIM(cfg.opendkimDir)
|
||||
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
|
||||
domains := domain.NewService(st, odk, apps, cfg.dkimSelectorDef)
|
||||
return &mailStack{Domains: domains, Apps: apps, pf: pf, odk: odk}
|
||||
// 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,
|
||||
Lists: inbound.NewLists(st, cfg.antispamDir),
|
||||
pf: pf, odk: odk, inboundEnabled: cfg.inboundEnabled, dmarcEnabled: cfg.dmarcEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
// Resync rebuilds OpenDKIM's tables and Postfix's sender map from SQLite and
|
||||
@@ -42,6 +58,21 @@ func (m *mailStack) Resync() error {
|
||||
if err := m.Apps.Resync(); err != nil {
|
||||
return fmt.Errorf("postfix resync: %w", err)
|
||||
}
|
||||
if m.inboundEnabled {
|
||||
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 {
|
||||
return fmt.Errorf("dmarc maps resync: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -66,22 +97,36 @@ func resyncAfterRestore(cfg config, st *store.Store, testNoReload bool) error {
|
||||
// the environment describes it, with nothing bound to a port yet.
|
||||
func newPanel(cfg config, st *store.Store) (*web.Server, error) {
|
||||
ms := newMailStack(cfg, st)
|
||||
return web.New(st, ms.Domains, ms.Apps, web.Config{
|
||||
Hostname: cfg.hostname,
|
||||
CookieSecure: cfg.cookieSecure,
|
||||
SubmissionEnabled: cfg.submissionEnabled,
|
||||
MailLogPath: cfg.mailLog,
|
||||
DataDir: cfg.dataDir,
|
||||
DBPath: cfg.dbPath,
|
||||
Version: buildinfo.Version,
|
||||
TrustedProxyCIDRs: cfg.trustedProxies,
|
||||
TLSCertFile: cfg.tlsCertFile,
|
||||
OpenDKIMSocket: cfg.opendkimSocket,
|
||||
JournalSocket: cfg.journalSocket,
|
||||
SessionIdleDays: cfg.sessionIdleDays,
|
||||
DNSResolvers: cfg.dnsResolvers,
|
||||
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
|
||||
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
|
||||
// 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).
|
||||
retryPolicy := postfix.LoadRetryPolicy()
|
||||
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,
|
||||
MailLogPath: cfg.mailLog,
|
||||
DataDir: cfg.dataDir,
|
||||
DBPath: cfg.dbPath,
|
||||
DeployRoot: cfg.deployRoot,
|
||||
Version: buildinfo.Version,
|
||||
TrustedProxyCIDRs: cfg.trustedProxies,
|
||||
TLSCertFile: cfg.tlsCertFile,
|
||||
OpenDKIMSocket: cfg.opendkimSocket,
|
||||
JournalSocket: cfg.journalSocket,
|
||||
SenderSocket: cfg.senderSocket,
|
||||
SessionIdleDays: cfg.sessionIdleDays,
|
||||
DNSResolvers: cfg.dnsResolvers,
|
||||
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
|
||||
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
|
||||
RetryPolicy: retryPolicy,
|
||||
InboundEnabled: cfg.inboundEnabled,
|
||||
InboundAntispamMilter: cfg.inboundAntispamMilter,
|
||||
InboundAntispamAction: cfg.inboundAntispamAction,
|
||||
DMARCEnabled: cfg.dmarcEnabled,
|
||||
SendLogRetentionEnvDefault: cfg.retentionDays,
|
||||
|
||||
InboundSpamLogRetentionEnvDefault: cfg.inboundSpamLogRetentionDays,
|
||||
}, cfg.setupTokenPath)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,21 +16,52 @@ 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
|
||||
}
|
||||
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".
|
||||
if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
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
|
||||
@@ -37,9 +69,7 @@ func serveJournal(ctx context.Context, cfg config, st *store.Store) error {
|
||||
// it (connecting to a Unix socket needs write permission on the node).
|
||||
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
|
||||
}
|
||||
|
||||
@@ -23,11 +23,22 @@ import (
|
||||
"github.com/mixeme/selfpost/internal/backup"
|
||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/inbound"
|
||||
"github.com/mixeme/selfpost/internal/logtail"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if isDMARCIngestInvocation() {
|
||||
log.SetFlags(log.LstdFlags | log.LUTC)
|
||||
log.SetPrefix("dmarc-ingest: ")
|
||||
if err := runDMARCIngestMode(); err != nil {
|
||||
log.Printf("ingest failed: %v", err)
|
||||
os.Exit(75) // EX_TEMPFAIL — ask Postfix to defer
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
showVersion := flag.Bool("version", false, "print version and exit")
|
||||
flag.Parse()
|
||||
|
||||
@@ -49,6 +60,8 @@ func main() {
|
||||
type config struct {
|
||||
httpAddr string
|
||||
journalSocket string
|
||||
// The sender check's socket; the default mirrors build/postfix-config.sh.
|
||||
senderSocket string
|
||||
mailLog string
|
||||
retentionDays int
|
||||
|
||||
@@ -78,6 +91,23 @@ type config struct {
|
||||
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
|
||||
// what Postfix does when it is down. Read-only: build/postfix-config.sh is
|
||||
// what attaches it; the panel only says so. See antispamAction.
|
||||
inboundAntispamMilter string
|
||||
inboundAntispamAction string
|
||||
// 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 {
|
||||
@@ -85,6 +115,7 @@ 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"),
|
||||
// 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.
|
||||
@@ -138,9 +169,35 @@ func loadConfig() config {
|
||||
saslDBPath: envDefault("SASL_DB_PATH", filepath.Join(dataDir, "sasl", "sasldb2")),
|
||||
saslRealm: saslRealm(),
|
||||
postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")),
|
||||
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
// antispamAction reads INBOUND_ANTISPAM_MILTER_ACTION the way
|
||||
// build/postfix-config.sh does: empty means accept, and accept and tempfail are
|
||||
// the only other values it lets the container start with. Anything else is
|
||||
// returned as "" — the panel says nothing about the action rather than guess.
|
||||
func antispamAction(raw string) string {
|
||||
switch raw {
|
||||
case "", "accept":
|
||||
return "accept"
|
||||
case "tempfail":
|
||||
return "tempfail"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -241,10 +298,54 @@ func run() error {
|
||||
if err := resyncAfterRestore(cfg, st, false); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if cfg.dmarcEnabled {
|
||||
ms := newMailStack(cfg, st)
|
||||
ms.pf.SetReloadHook(func() error { return nil })
|
||||
if err := ms.DMARC.Resync(); err != nil {
|
||||
log.Printf("dmarc maps bootstrap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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, 3)
|
||||
// Room for every role to report a failure without blocking: seven at most.
|
||||
errc := make(chan error, 7)
|
||||
|
||||
roles := []struct {
|
||||
name string
|
||||
@@ -252,7 +353,29 @@ 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) }},
|
||||
{"log-tailer", func(ctx context.Context) error { return logtail.Run(ctx, cfg.mailLog, st, cfg.retentionDays) }},
|
||||
{"sender-check", func(ctx context.Context) error { return serveSenderCheck(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)
|
||||
if err != nil {
|
||||
log.Printf("send-log retention: %v", err)
|
||||
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,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"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)
|
||||
|
||||
// OpenDKIM's tables are rebuilt when a domain is added or deleted. On the
|
||||
// first start of a version that signs the server's own name they do not
|
||||
// have its line yet. A failure here costs the signature, not the message.
|
||||
if rebuilt, err := ms.Domains.EnsureServerSigned(); err != nil {
|
||||
log.Printf("notifier: sign as %s: %v", cfg.hostname, err)
|
||||
} else if rebuilt {
|
||||
log.Printf("notifier: OpenDKIM now signs mail of %s", cfg.hostname)
|
||||
}
|
||||
|
||||
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.CheckCertificate(cfg.tlsCertFile) },
|
||||
Queue: postfix.QueueMessages,
|
||||
}, notify.NewMailer(cfg.hostname), cfg.hostname)
|
||||
return n.Run(ctx)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/logtail"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// runAutoRateLimitRecalc recomputes auto-mode level-2 limits on the same
|
||||
// interval as send-log retention pruning (6 hours).
|
||||
func runAutoRateLimitRecalc(ctx context.Context, cfg config, st *store.Store) {
|
||||
recalc := func() {
|
||||
retention := cfg.retentionDays
|
||||
if days, err := st.GetSendLogRetentionDays(cfg.retentionDays); err == nil {
|
||||
retention = days
|
||||
}
|
||||
l1Max := cfg.rateLimitMessagesPerIP
|
||||
if l1Max <= 0 {
|
||||
l1Max = 100
|
||||
}
|
||||
l1Window := cfg.rateLimitWindowSeconds
|
||||
if l1Window <= 0 {
|
||||
l1Window = 3600
|
||||
}
|
||||
n, err := st.RecalcAllAutoRateLimits(retention, l1Max, l1Window)
|
||||
if err != nil {
|
||||
log.Printf("auto rate-limit recalc: %v", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("auto rate-limit recalc: updated %d limit(s)", n)
|
||||
}
|
||||
}
|
||||
|
||||
recalc()
|
||||
t := time.NewTicker(logtail.RetentionInterval())
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
recalc()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
)
|
||||
|
||||
// Restoring a SelfPost backup is not a code path in the panel: the operator
|
||||
// extracts the archive into the /data bind mount and starts the image, and the
|
||||
// extracts the archive into a project directory and starts the image, and the
|
||||
// panel is expected to come up on it (architecture.md § Persistence). Nothing
|
||||
// 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
|
||||
@@ -37,9 +37,10 @@ const (
|
||||
|
||||
// restored is the outcome of a full backup-and-restore round trip.
|
||||
type restored struct {
|
||||
panel http.Handler // panel booted on the restored data directory
|
||||
dataDir string // the restored /data
|
||||
session *http.Cookie // a session opened before the backup was taken
|
||||
panel http.Handler // panel booted on the restored data directory
|
||||
deployRoot string // the restored project directory
|
||||
dataDir string // the restored /data
|
||||
session *http.Cookie // a session opened before the backup was taken
|
||||
}
|
||||
|
||||
// restoreFromOwnBackup runs the operator's path end to end: seed a panel that
|
||||
@@ -50,7 +51,7 @@ type restored struct {
|
||||
func restoreFromOwnBackup(t *testing.T, password string) restored {
|
||||
t.Helper()
|
||||
|
||||
live := seedPanelData(t)
|
||||
live := seedPanelProject(t)
|
||||
panel := bootPanel(t, live)
|
||||
session := signIn(t, panel)
|
||||
archive := downloadBackup(t, panel, session, password)
|
||||
@@ -58,7 +59,12 @@ func restoreFromOwnBackup(t *testing.T, password string) restored {
|
||||
target := t.TempDir()
|
||||
extract(t, archive, target)
|
||||
|
||||
return restored{panel: bootPanel(t, target), dataDir: target, session: session}
|
||||
return restored{
|
||||
panel: bootPanel(t, target),
|
||||
deployRoot: target,
|
||||
dataDir: filepath.Join(target, "data"),
|
||||
session: session,
|
||||
}
|
||||
}
|
||||
|
||||
// The panel has to come up on the restored directory and show the state that
|
||||
@@ -68,7 +74,7 @@ func restoreFromOwnBackup(t *testing.T, password string) restored {
|
||||
func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
|
||||
r := restoreFromOwnBackup(t, "")
|
||||
|
||||
body := getPage(t, r.panel, "/deliveries", signIn(t, r.panel))
|
||||
body := getPage(t, r.panel, "/outbound/log", signIn(t, r.panel))
|
||||
for _, want := range []string{restoreDomain, restoreSubject} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the restored panel's send log does not show %q:\n%s", want, body)
|
||||
@@ -91,6 +97,12 @@ func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
|
||||
t.Errorf("%s = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range []string{backup.ComposeFileName, backup.EnvFileName} {
|
||||
if _, err := os.Stat(filepath.Join(r.deployRoot, name)); err != nil {
|
||||
t.Errorf("the restored project directory has no %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The one-time setup link is closed by the presence of a panel user, and the
|
||||
@@ -116,7 +128,7 @@ func TestARestoreDoesNotReopenTheSetupLink(t *testing.T) {
|
||||
func TestARestoredPanelHonoursSessionsFromTheArchive(t *testing.T) {
|
||||
r := restoreFromOwnBackup(t, "")
|
||||
|
||||
rec := request(t, r.panel, http.MethodGet, "/deliveries", nil, r.session)
|
||||
rec := request(t, r.panel, http.MethodGet, "/outbound/log", nil, r.session)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("a session from before the backup = %d on the restored panel, want 200", rec.Code)
|
||||
}
|
||||
@@ -129,7 +141,7 @@ func TestARestoredPanelHonoursSessionsFromTheArchive(t *testing.T) {
|
||||
func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) {
|
||||
r := restoreFromOwnBackup(t, "a-long-enough-password")
|
||||
|
||||
body := getPage(t, r.panel, "/deliveries", signIn(t, r.panel))
|
||||
body := getPage(t, r.panel, "/outbound/log", signIn(t, r.panel))
|
||||
if !strings.Contains(body, restoreSubject) {
|
||||
t.Errorf("the panel restored from an encrypted backup lost the send log:\n%s", body)
|
||||
}
|
||||
@@ -138,8 +150,9 @@ func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) {
|
||||
// A restore boot runs one Resync from SQLite. If the archive's Postfix map
|
||||
// drifted from the database, that step puts it back before mail flows.
|
||||
func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) {
|
||||
dataDir := seedPanelData(t)
|
||||
cfg := panelConfig(t, dataDir)
|
||||
deployRoot := seedPanelProject(t)
|
||||
dataDir := filepath.Join(deployRoot, "data")
|
||||
cfg := panelConfig(t, deployRoot)
|
||||
|
||||
mapPath := filepath.Join(dataDir, "postfix", "sender_login_maps")
|
||||
if err := os.WriteFile(mapPath, []byte("stale map\n"), 0o640); err != nil {
|
||||
@@ -191,12 +204,14 @@ func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) {
|
||||
// stays put on a mismatch: the operator's next move is to start the image the
|
||||
// backup names, and it has to be there when they do.
|
||||
func TestPanelRefusesADataDirectoryRestoredFromAnotherVersion(t *testing.T) {
|
||||
live := seedPanelData(t)
|
||||
deployRoot := seedPanelProject(t)
|
||||
dataDir := filepath.Join(deployRoot, "data")
|
||||
var archive bytes.Buffer
|
||||
if err := backup.Create(&archive, backup.Params{
|
||||
DataDir: live,
|
||||
DBPath: filepath.Join(live, "selfpost.db"),
|
||||
Version: "9.9.9",
|
||||
DataDir: dataDir,
|
||||
DBPath: filepath.Join(dataDir, "selfpost.db"),
|
||||
Version: "9.9.9",
|
||||
DeployRoot: deployRoot,
|
||||
}); err != nil {
|
||||
t.Fatalf("create backup: %v", err)
|
||||
}
|
||||
@@ -218,13 +233,15 @@ func TestPanelRefusesADataDirectoryRestoredFromAnotherVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// seedPanelData builds the /data tree of a panel that has been in use: an
|
||||
// administrator, a sending domain with an application and one logged message,
|
||||
// and the daemon state the mail path needs (a DKIM key, the SASL database and
|
||||
// Postfix's sender map).
|
||||
func seedPanelData(t *testing.T) string {
|
||||
// seedPanelProject builds an operator project tree: data/ with a panel that has
|
||||
// been in use, plus docker-compose.yml, .env, and certs/ for full backups.
|
||||
func seedPanelProject(t *testing.T) string {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
deployRoot := t.TempDir()
|
||||
dataDir := filepath.Join(deployRoot, "data")
|
||||
if err := os.MkdirAll(dataDir, 0o750); err != nil {
|
||||
t.Fatalf("mkdir data: %v", err)
|
||||
}
|
||||
|
||||
st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
|
||||
if err != nil {
|
||||
@@ -268,16 +285,33 @@ func seedPanelData(t *testing.T) string {
|
||||
t.Fatalf("write %s: %v", full, err)
|
||||
}
|
||||
}
|
||||
return dataDir
|
||||
|
||||
writeDeployFile(t, filepath.Join(deployRoot, backup.ComposeFileName), "services:\n selfpost:\n image: test\n")
|
||||
writeDeployFile(t, filepath.Join(deployRoot, backup.EnvFileName), "SELFPOST_HOSTNAME=mail.example.ru\n")
|
||||
writeDeployFile(t, filepath.Join(deployRoot, backup.CertsDirName, "fullchain.pem"), "CERT")
|
||||
writeDeployFile(t, filepath.Join(deployRoot, backup.CertsDirName, "privkey.pem"), "KEY")
|
||||
return deployRoot
|
||||
}
|
||||
|
||||
// panelConfig resolves the panel's own configuration for a data directory, so
|
||||
func writeDeployFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// panelConfig resolves the panel's own configuration for a project directory, so
|
||||
// the test finds the files where the running binary would look for them rather
|
||||
// than where it put them. Cookies are marked insecure for the same reason the
|
||||
// e2e stand does it: the test client speaks plain HTTP.
|
||||
func panelConfig(t *testing.T, dataDir string) config {
|
||||
func panelConfig(t *testing.T, deployRoot string) config {
|
||||
t.Helper()
|
||||
dataDir := filepath.Join(deployRoot, "data")
|
||||
t.Setenv("SELFPOST_DATA_DIR", dataDir)
|
||||
t.Setenv("SELFPOST_DEPLOY_ROOT", deployRoot)
|
||||
t.Setenv("PANEL_COOKIE_SECURE", "false")
|
||||
t.Setenv("SELFPOST_HOSTNAME", "mail.example.ru")
|
||||
// MAIL_LOG's default is an absolute path, not one derived from the data
|
||||
@@ -288,13 +322,13 @@ func panelConfig(t *testing.T, dataDir string) config {
|
||||
|
||||
// bootPanel performs the startup sequence run() performs, in the same order,
|
||||
// and returns the panel's HTTP handler.
|
||||
func bootPanel(t *testing.T, dataDir string) http.Handler {
|
||||
func bootPanel(t *testing.T, deployRoot string) http.Handler {
|
||||
t.Helper()
|
||||
cfg := panelConfig(t, dataDir)
|
||||
cfg := panelConfig(t, deployRoot)
|
||||
|
||||
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
|
||||
if err != nil {
|
||||
t.Fatalf("the panel refused to start on %s: %v", dataDir, err)
|
||||
t.Fatalf("the panel refused to start on %s: %v", deployRoot, err)
|
||||
}
|
||||
if restored {
|
||||
if _, err := os.Stat(cfg.manifestPath); err == nil {
|
||||
@@ -350,7 +384,7 @@ func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password
|
||||
form.Set("password", password)
|
||||
form.Set("password_confirm", password)
|
||||
}
|
||||
rec := request(t, h, http.MethodPost, "/backup", strings.NewReader(form.Encode()), session)
|
||||
rec := request(t, h, http.MethodPost, "/server/backup", strings.NewReader(form.Encode()), session)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("download a backup = %d, want 200:\n%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -380,8 +414,8 @@ func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password
|
||||
return plain
|
||||
}
|
||||
|
||||
// extract unpacks a backup archive into dir, as `tar -xzf` does onto the /data
|
||||
// bind mount before the image is started.
|
||||
// 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()
|
||||
gz, err := gzip.NewReader(bytes.NewReader(archive))
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"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, 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.
|
||||
go func() {
|
||||
now := time.Now()
|
||||
mark := cfg.senderSocket + ".alive"
|
||||
if err := st.StartSenderCheckStats(now); err != nil {
|
||||
log.Printf("sender-check: statistics: %v", err)
|
||||
}
|
||||
if down := senderCheckDowntime(mark, now, postfixStarted); down > 0 {
|
||||
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); err != nil {
|
||||
log.Printf("sender-check: statistics: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
keepAlive(ctx, mark, st)
|
||||
}()
|
||||
|
||||
// The two tables the mail path itself goes by: whom Postfix lets send as
|
||||
// what, and what OpenDKIM signs.
|
||||
bind := milter.NewBindings(postfix.New(cfg.postfixDir).SenderLoginMapsPath())
|
||||
signers := milter.NewSigners(domain.NewOpenDKIM(cfg.opendkimDir).SigningTablePath())
|
||||
return milter.ServeSender(ctx, ln, bind, signers, st)
|
||||
}
|
||||
|
||||
// senderCheckDowntime is for how long the check was not serving before now
|
||||
// while Postfix was: the time since the mark at path was last set, 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.
|
||||
func senderCheckDowntime(path string, now time.Time, postfixStarted func() (time.Time, bool)) time.Duration {
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
from := fi.ModTime()
|
||||
started, running := postfixStarted()
|
||||
if !running {
|
||||
return 0
|
||||
}
|
||||
if started.After(from) {
|
||||
from = started
|
||||
}
|
||||
if d := now.Sub(from); d > 0 {
|
||||
return d
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// postfixStarted asks supervisord since when Postfix has been running.
|
||||
func postfixStarted() (time.Time, bool) {
|
||||
procs, err := health.Processes()
|
||||
if err != nil {
|
||||
// Said aloud: the downtime of this start is then not counted.
|
||||
log.Printf("sender-check: statistics: when Postfix started is not known: %v", err)
|
||||
return time.Time{}, false
|
||||
}
|
||||
for _, p := range procs {
|
||||
if p.Name != "postfix" || p.State != "RUNNING" {
|
||||
continue
|
||||
}
|
||||
if up, ok := health.Uptime(p.Detail); ok {
|
||||
return time.Now().Add(-up), true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// 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,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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)
|
||||
mark := filepath.Join(t.TempDir(), "sender.sock.alive")
|
||||
set := func(at time.Time) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(mark, nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chtimes(mark, at, at); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
postfix := func(started time.Time, running bool) func() (time.Time, bool) {
|
||||
return func() (time.Time, bool) { return started, running }
|
||||
}
|
||||
|
||||
if d := senderCheckDowntime(mark, now, postfix(now.Add(-time.Hour), true)); d != 0 {
|
||||
t.Errorf("no mark: %s", d)
|
||||
}
|
||||
set(now.Add(-40 * time.Second))
|
||||
if d := senderCheckDowntime(mark, now, postfix(now.Add(-time.Hour), true)); d != 40*time.Second {
|
||||
t.Errorf("Postfix up throughout: %s, want 40s", d)
|
||||
}
|
||||
if d := senderCheckDowntime(mark, now, postfix(now.Add(-10*time.Second), true)); d != 10*time.Second {
|
||||
t.Errorf("Postfix started within the gap: %s, want 10s", d)
|
||||
}
|
||||
if d := senderCheckDowntime(mark, now, postfix(time.Time{}, false)); d != 0 {
|
||||
t.Errorf("Postfix not running: %s", d)
|
||||
}
|
||||
set(now.Add(time.Minute)) // a clock set back
|
||||
if d := senderCheckDowntime(mark, now, 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)
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,9 @@
|
||||
//
|
||||
// docker exec <container> selfpost-backup > selfpost-backup.tar.gz
|
||||
//
|
||||
// Use -o to write to a file instead. The resulting archive contains DKIM private
|
||||
// keys, the admin password hash and SASL credentials — treat it as a secret
|
||||
// (architecture.md § Persistence).
|
||||
// Use -o to write to a file instead. The resulting archive is self-contained:
|
||||
// data/, docker-compose.yml, .env, and certs/ when present — treat it as a
|
||||
// secret (architecture.md § Persistence).
|
||||
//
|
||||
// Given a password (SELFPOST_BACKUP_PASSWORD or -password-file, never an
|
||||
// argument, which would show up in the process list) the archive is written as
|
||||
@@ -87,9 +87,13 @@ func run(outPath, password string) error {
|
||||
}
|
||||
|
||||
if err := backup.Create(sink, backup.Params{
|
||||
DataDir: dataDir,
|
||||
DBPath: dbPath,
|
||||
Version: buildinfo.Version,
|
||||
DataDir: dataDir,
|
||||
DBPath: dbPath,
|
||||
Version: buildinfo.Version,
|
||||
DeployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
|
||||
OnWarn: func(msg string) {
|
||||
fmt.Fprintf(os.Stderr, "selfpost-backup: %s\n", msg)
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -9,13 +9,18 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/backup"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// seedDataDir builds the minimum /data tree a backup can be taken from.
|
||||
func seedDataDir(t *testing.T) string {
|
||||
// seedProject builds the minimum operator project tree a backup can be taken from.
|
||||
func seedProject(t *testing.T) (deployRoot, dataDir string) {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
deployRoot = t.TempDir()
|
||||
dataDir = filepath.Join(deployRoot, "data")
|
||||
if err := os.MkdirAll(dataDir, 0o750); err != nil {
|
||||
t.Fatalf("mkdir data: %v", err)
|
||||
}
|
||||
st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
@@ -26,16 +31,31 @@ func seedDataDir(t *testing.T) string {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatalf("close store: %v", err)
|
||||
}
|
||||
writeFile(t, filepath.Join(deployRoot, backup.ComposeFileName), "services:\n selfpost:\n image: test\n")
|
||||
writeFile(t, filepath.Join(deployRoot, backup.EnvFileName), "SELFPOST_HOSTNAME=mail.example.com\n")
|
||||
writeFile(t, filepath.Join(deployRoot, backup.CertsDirName, "fullchain.pem"), "CERT")
|
||||
writeFile(t, filepath.Join(deployRoot, backup.CertsDirName, "privkey.pem"), "KEY")
|
||||
t.Setenv("SELFPOST_DATA_DIR", dataDir)
|
||||
t.Setenv("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db"))
|
||||
return dataDir
|
||||
t.Setenv("SELFPOST_DEPLOY_ROOT", deployRoot)
|
||||
return deployRoot, dataDir
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// An encrypted backup is only worth having if the container it came from can
|
||||
// hand it back as an ordinary archive during a restore, so the two halves of
|
||||
// the CLI are tested as the one round trip an operator actually performs.
|
||||
func TestEncryptedBackupRoundTrip(t *testing.T) {
|
||||
seedDataDir(t)
|
||||
seedProject(t)
|
||||
dir := t.TempDir()
|
||||
encrypted := filepath.Join(dir, "backup.spbk")
|
||||
plain := filepath.Join(dir, "backup.tar.gz")
|
||||
@@ -81,7 +101,12 @@ func TestEncryptedBackupRoundTrip(t *testing.T) {
|
||||
}
|
||||
names[hdr.Name] = true
|
||||
}
|
||||
for _, want := range []string{"manifest.json", "selfpost.db"} {
|
||||
for _, want := range []string{
|
||||
backup.DataArchivePrefix + backup.ManifestName,
|
||||
backup.DataArchivePrefix + "selfpost.db",
|
||||
backup.ComposeFileName,
|
||||
backup.EnvFileName,
|
||||
} {
|
||||
if !names[want] {
|
||||
t.Errorf("decrypted archive has no %s (entries: %v)", want, names)
|
||||
}
|
||||
@@ -91,7 +116,7 @@ func TestEncryptedBackupRoundTrip(t *testing.T) {
|
||||
// Without a password the CLI keeps producing the plain archive that existing
|
||||
// backup scripts consume.
|
||||
func TestUnencryptedBackupStaysPlain(t *testing.T) {
|
||||
seedDataDir(t)
|
||||
seedProject(t)
|
||||
out := filepath.Join(t.TempDir(), "backup.tar.gz")
|
||||
if err := run(out, ""); err != nil {
|
||||
t.Fatalf("create backup: %v", err)
|
||||
|
||||
@@ -11,6 +11,34 @@ SELFPOST_HOSTNAME=mail.example.com
|
||||
# primary 465/smtps listener.
|
||||
SUBMISSION_ENABLE=false
|
||||
|
||||
# Optional inbound relay (backup-MX / forwarder). When true, Postfix accepts
|
||||
# mail on port 25 only for domains configured in the panel and forwards them
|
||||
# to the upstream you set. Off by default — the outbound path is unchanged.
|
||||
INBOUND_RELAY_ENABLE=false
|
||||
|
||||
# Optional DMARC aggregate report ingest. When true, Postfix accepts mail on port
|
||||
# 25 only for report addresses configured in the panel (global default and/or
|
||||
# per-domain SelfPost-hosted addresses) and pipes them to the ingest worker.
|
||||
DMARC_REPORTS_ENABLE=false
|
||||
|
||||
# Coarse per-client-IP limit and message size for DMARC ingest on port 25.
|
||||
# DMARC_RATE_LIMIT_MESSAGES_PER_IP=20
|
||||
# DMARC_MESSAGE_SIZE_LIMIT=5242880
|
||||
|
||||
# Optional milter on the inbound listener only (not 465/587). Empty = off.
|
||||
# Example with deploy/antispam/docker-compose.antispam.yml:
|
||||
# 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
|
||||
|
||||
|
||||
# Level-1 backstop rate limit (anvil) — see docs/guide.md "Rate limiting".
|
||||
RATE_LIMIT_MESSAGES_PER_IP=100
|
||||
RATE_LIMIT_WINDOW_SECONDS=3600
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Optional anti-spam sidecar for inbound relay (docs/plans/inbound-relay.md).
|
||||
#
|
||||
# SelfPost does not ship or start an anti-spam engine. This fragment brings up
|
||||
# rspamd next to SelfPost and points the inbound smtpd milter at it. Merge it
|
||||
# only when you want filtering on port 25:
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f antispam/docker-compose.antispam.yml up -d
|
||||
#
|
||||
# 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
|
||||
# does not block backup-MX. Set INBOUND_ANTISPAM_MILTER_ACTION=tempfail to
|
||||
# defer instead.
|
||||
|
||||
services:
|
||||
selfpost:
|
||||
environment:
|
||||
INBOUND_RELAY_ENABLE: "${INBOUND_RELAY_ENABLE:-true}"
|
||||
INBOUND_ANTISPAM_MILTER: "${INBOUND_ANTISPAM_MILTER:-inet:antispam:11332}"
|
||||
INBOUND_ANTISPAM_MILTER_ACTION: "${INBOUND_ANTISPAM_MILTER_ACTION:-accept}"
|
||||
depends_on:
|
||||
- antispam
|
||||
|
||||
antispam:
|
||||
image: rspamd/rspamd:3.11
|
||||
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,45 @@
|
||||
# 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. 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.
|
||||
use = ["x-spamd-result", "x-rspamd-action", "selfpost-signature"];
|
||||
|
||||
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,63 @@
|
||||
# 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.
|
||||
#
|
||||
# Deny wins over allow, whichever entries match. A denied sender is refused
|
||||
# before anything else is looked at (a prefilter). 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.
|
||||
#
|
||||
# A domain entry is that domain, not its subdomains.
|
||||
#
|
||||
# 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";
|
||||
prefilter = true;
|
||||
action = "reject";
|
||||
message = "Refused by this server's deny list";
|
||||
description = "Client address on the deny list";
|
||||
}
|
||||
SELFPOST_DENY_FROM {
|
||||
type = "from";
|
||||
map = "/etc/rspamd/selfpost/deny_from.map";
|
||||
prefilter = true;
|
||||
action = "reject";
|
||||
message = "Refused by this server's deny list";
|
||||
description = "Sender address on the deny list";
|
||||
}
|
||||
SELFPOST_DENY_DOMAIN {
|
||||
type = "from";
|
||||
filter = "email:domain";
|
||||
map = "/etc/rspamd/selfpost/deny_domain.map";
|
||||
prefilter = true;
|
||||
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";
|
||||
map = "/etc/rspamd/selfpost/allow_from.map";
|
||||
score = -100.0;
|
||||
description = "Sender address on the allow list";
|
||||
}
|
||||
SELFPOST_ALLOW_DOMAIN {
|
||||
type = "from";
|
||||
filter = "email:domain";
|
||||
map = "/etc/rspamd/selfpost/allow_domain.map";
|
||||
score = -100.0;
|
||||
description = "Sender domain on the allow list";
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
# Example host Apache vhost for the SelfPost panel (spec 10.3, default
|
||||
# scenario). Install on the host running Apache — NOT inside the SelfPost
|
||||
# container. Requires mod_ssl and mod_proxy/mod_proxy_http enabled
|
||||
# (`a2enmod ssl proxy proxy_http`).
|
||||
# Example host Apache vhost for the SelfPost panel (default scenario). Install
|
||||
# on the host running Apache — NOT inside the SelfPost container. Requires
|
||||
# mod_ssl and mod_proxy/mod_proxy_http enabled (`a2enmod ssl proxy proxy_http`).
|
||||
#
|
||||
# Certificates: obtain with the Apache certbot plugin against THIS vhost
|
||||
# (`certbot --apache -d mail.example.com`). Certbot edits this file in place
|
||||
# to 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
|
||||
# (`certbot --apache -d mail.example.com`). Certbot edits this file in place to
|
||||
# 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 (spec 10 p.4) — no separate cert just for the panel.
|
||||
# consumers — no separate cert just for the panel.
|
||||
|
||||
<VirtualHost *:443>
|
||||
ServerName mail.example.com
|
||||
@@ -26,9 +25,9 @@
|
||||
ProxyPass "/" "http://127.0.0.1:8080/"
|
||||
ProxyPassReverse "/" "http://127.0.0.1:8080/"
|
||||
|
||||
# The panel is plain HTTP behind the proxy; it never terminates TLS
|
||||
# itself (spec 10 p.2). Cookies are still marked Secure by the panel
|
||||
# (spec 7.6.6) because the browser only ever sees this HTTPS vhost.
|
||||
# The panel is plain HTTP behind the proxy; it never terminates TLS itself.
|
||||
# Cookies are still marked Secure by the panel because the browser only ever
|
||||
# sees this HTTPS vhost.
|
||||
</VirtualHost>
|
||||
|
||||
<VirtualHost *:80>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
|
||||
# Alternative reverse-proxy: Caddy (spec 10.3) — the simplest option, fully
|
||||
# automatic ACME with no separate certbot container. Caddy writes certificates
|
||||
# as plain PEM under its own data directory, which this fragment bind-mounts
|
||||
# to the host so SelfPost can read the same files.
|
||||
# Alternative reverse-proxy: Caddy — the simplest option, fully automatic ACME
|
||||
# with no separate certbot container. Caddy writes certificates as plain PEM
|
||||
# under its own data directory, which this fragment bind-mounts to the host so
|
||||
# SelfPost can read the same files.
|
||||
#
|
||||
# NOTE (spec 10.3 explicitly flags this): Caddy's on-disk cert path includes
|
||||
# NOTE: Caddy's on-disk cert path includes
|
||||
# the ACME CA's name as a path segment, e.g.
|
||||
# <data>/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>/<hostname>.crt
|
||||
# This has been stable across recent Caddy releases but is an internal
|
||||
@@ -23,6 +23,7 @@ services:
|
||||
ports: !override
|
||||
- "465:465"
|
||||
- "587:587"
|
||||
- "25:25"
|
||||
environment:
|
||||
# Caddy names certificate files after the hostname, not
|
||||
# fullchain.pem/privkey.pem like certbot — override the base file's
|
||||
@@ -31,6 +32,7 @@ services:
|
||||
TLS_KEY_FILE: /etc/postfix/tls/<hostname>.key
|
||||
volumes: !override
|
||||
- ./data:/data
|
||||
- .:/selfpost-deploy:ro
|
||||
# Replace <hostname> with your actual mail/panel hostname, matching
|
||||
# the Caddyfile below and SELFPOST_HOSTNAME.
|
||||
- ./caddy/caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>:/etc/postfix/tls:ro
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
|
||||
# SelfPost — default deployment, reverse-proxy = Apache (spec 10, 10.5).
|
||||
# SelfPost — default deployment, reverse-proxy = Apache.
|
||||
#
|
||||
# This file only runs SelfPost itself. Apache is assumed to already be
|
||||
# installed on the HOST (the target audience for this project typically runs
|
||||
# Apache there already — spec 10.5) 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 this compose file mounts.
|
||||
# This file only runs SelfPost itself. Apache is assumed to already be installed
|
||||
# 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
|
||||
# this compose file mounts.
|
||||
#
|
||||
# Usage:
|
||||
# 1. Copy this file (and .env.example as .env) next to your own ./data and
|
||||
@@ -15,32 +15,40 @@
|
||||
# TLS paths are fixed in this file to match that bind mount, not .env.
|
||||
# 3. docker compose up -d
|
||||
#
|
||||
# The image tag below is FIXED on purpose (spec 10 p.10, 7.5.A): 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 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.
|
||||
|
||||
services:
|
||||
selfpost:
|
||||
image: ghcr.io/mixeme/selfpost:1.3.0
|
||||
image: ghcr.io/mixeme/selfpost:2.4.1
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
||||
# Path Postfix reads inside the container — matches the certs bind mount
|
||||
# below. Point these at your reverse-proxy's PEM output (spec 10 p.2).
|
||||
# below. Point these at your reverse-proxy's PEM output.
|
||||
TLS_CERT_FILE: /etc/postfix/tls/fullchain.pem
|
||||
TLS_KEY_FILE: /etc/postfix/tls/privkey.pem
|
||||
# Set to true to also publish RFC 6409 submission (587/STARTTLS)
|
||||
# alongside the primary 465/smtps listener (spec 5).
|
||||
# Set to true to also publish RFC 6409 submission (587/STARTTLS) alongside
|
||||
# the primary 465/smtps listener.
|
||||
SUBMISSION_ENABLE: "${SUBMISSION_ENABLE:-false}"
|
||||
# Level-1 backstop rate limit (anvil, spec 5.5, 7.4); per-domain/app
|
||||
# limits (level 2) are configured later from the panel itself.
|
||||
INBOUND_RELAY_ENABLE: "${INBOUND_RELAY_ENABLE:-false}"
|
||||
DMARC_REPORTS_ENABLE: "${DMARC_REPORTS_ENABLE:-false}"
|
||||
DMARC_RATE_LIMIT_MESSAGES_PER_IP: "${DMARC_RATE_LIMIT_MESSAGES_PER_IP:-20}"
|
||||
DMARC_MESSAGE_SIZE_LIMIT: "${DMARC_MESSAGE_SIZE_LIMIT:-5242880}"
|
||||
INBOUND_ANTISPAM_MILTER: "${INBOUND_ANTISPAM_MILTER:-}"
|
||||
INBOUND_ANTISPAM_MILTER_ACTION: "${INBOUND_ANTISPAM_MILTER_ACTION:-accept}"
|
||||
INBOUND_RATE_LIMIT_MESSAGES_PER_IP: "${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}"
|
||||
INBOUND_MESSAGE_SIZE_LIMIT: "${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}"
|
||||
# Level-1 backstop rate limit (anvil); per-domain/app limits (level 2) are
|
||||
# configured later from the panel itself.
|
||||
RATE_LIMIT_MESSAGES_PER_IP: "${RATE_LIMIT_MESSAGES_PER_IP:-100}"
|
||||
RATE_LIMIT_WINDOW_SECONDS: "${RATE_LIMIT_WINDOW_SECONDS:-3600}"
|
||||
# How long the send log keeps rows before the background sweep deletes
|
||||
# them (spec 7.3, 9) — the main driver of /data growth over time.
|
||||
# them — the main driver of /data growth over time.
|
||||
SEND_LOG_RETENTION_DAYS: "${SEND_LOG_RETENTION_DAYS:-90}"
|
||||
# Sliding idle timeout for the panel login session, in days (spec 7.6.6).
|
||||
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
|
||||
# report what the internet sees rather than what this host's own stub
|
||||
@@ -51,30 +59,36 @@ services:
|
||||
# client spoof its rate-limit key.
|
||||
TRUSTED_PROXY_CIDR: "${TRUSTED_PROXY_CIDR:-}"
|
||||
volumes:
|
||||
# All persistent state lives under /data (spec 9): SQLite DB, DKIM keys,
|
||||
# sasldb2, sender map, setup token. Back this up (panel button or the
|
||||
# selfpost-backup CLI) before you touch it directly.
|
||||
# Postfix's mail.log is here too (./data/log, rotated daily, 14 files
|
||||
# kept) so the delivery lines that resolve a queued send-log row survive
|
||||
# a container recreate; it is diagnostics and is left out of backups.
|
||||
# All persistent state lives under /data: SQLite DB, DKIM keys, sasldb2,
|
||||
# sender map, setup token. Back this up (panel button or the
|
||||
# selfpost-backup CLI) before you touch it directly. Postfix's mail.log is
|
||||
# here too (./data/log, rotated daily, 14 files kept) so the delivery
|
||||
# lines that resolve a queued send-log row survive a container recreate;
|
||||
# it is diagnostics and is left out of backups.
|
||||
- ./data:/data
|
||||
# Read-only: SelfPost only ever reads certificates, never manages them
|
||||
# (spec 10 p.2). Point this at wherever your reverse-proxy/certbot
|
||||
# writes PEM files, e.g. /etc/letsencrypt on the host.
|
||||
# Read-only project root for full backups: docker-compose.yml, .env, and
|
||||
# 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:/etc/postfix/tls:ro
|
||||
ports:
|
||||
# 465 (smtps, primary) and optionally 587 (submission/STARTTLS) are
|
||||
# published directly — mail traffic bypasses Apache entirely, it only
|
||||
# ever proxies the panel's HTTP(S) (spec 10 p.2-3). The panel itself
|
||||
# (8080) is intentionally NOT published here: Apache reaches it over
|
||||
# the host network at 127.0.0.1:8080 (see the vhost fragment), so the
|
||||
# panel is never directly reachable from the internet without TLS.
|
||||
# published directly — mail traffic bypasses Apache entirely, it only ever
|
||||
# proxies the panel's HTTP(S). The panel itself (8080) is intentionally
|
||||
# NOT published here: Apache reaches it over the host network at
|
||||
# 127.0.0.1:8080 (see the vhost fragment), so the panel is never directly
|
||||
# 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".
|
||||
- "587:587"
|
||||
# 25 is mapped even when INBOUND_RELAY_ENABLE=false; Postfix listens only
|
||||
# when the variable is true.
|
||||
- "25:25"
|
||||
- "127.0.0.1:8080:8080"
|
||||
# Hardening (spec 10 p.6). SelfPost's entrypoint still needs to run as
|
||||
# Hardening. SelfPost's entrypoint still needs to run as
|
||||
# root very briefly to fix /data ownership and normalise permissions
|
||||
# under the shared `selfpost` group (see build/entrypoint.sh) before
|
||||
# supervisord drops the panel to an unprivileged user — so this cannot be
|
||||
@@ -82,7 +96,7 @@ services:
|
||||
# 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:
|
||||
# - NET_BIND_SERVICE — bind 465/587 (and 25 outbound) below 1024;
|
||||
# - 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
|
||||
# dirs/files while still root (owner-check bypass);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
# Alternative reverse-proxy: nginx (spec 10.3). PEM files land on the host
|
||||
# exactly like the Apache+certbot scenario — nginx and certbot are close
|
||||
# cousins here, both writing/reading plain PEM under /etc/letsencrypt.
|
||||
# Alternative reverse-proxy: nginx. PEM files land on the host exactly like the
|
||||
# Apache+certbot scenario — nginx and certbot are close cousins here, both
|
||||
# writing/reading plain PEM under /etc/letsencrypt.
|
||||
#
|
||||
# This is a FRAGMENT, not a full replacement for ../docker-compose.yml: it
|
||||
# adds an nginx + certbot pair and removes the panel's host port publish (nginx
|
||||
@@ -23,13 +23,15 @@ services:
|
||||
ports: !override
|
||||
- "465:465"
|
||||
- "587:587"
|
||||
- "25:25"
|
||||
# No host publish for 8080 here: nginx reaches it over the compose
|
||||
# network at selfpost:8080 instead (see nginx.conf.example).
|
||||
volumes: !override
|
||||
- ./data:/data
|
||||
# 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 (spec 10.3).
|
||||
- .:/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
|
||||
|
||||
nginx:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# nginx vhost for the SelfPost panel (spec 10.3). Proxies HTTPS to the panel
|
||||
# over the compose network; mail (465/587) is published directly by the
|
||||
# selfpost container and never touches nginx.
|
||||
# nginx vhost for the SelfPost panel. Proxies HTTPS to the panel over the
|
||||
# compose network; mail (465/587) is published directly by the selfpost
|
||||
# container and never touches nginx.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
# Alternative reverse-proxy: Traefik (spec 10.3). Traefik does NOT write plain
|
||||
# PEM files — everything (cert + key) lives bundled inside acme.json. SelfPost
|
||||
# needs separate PEM files for Postfix, so an extraction step is required; see
|
||||
# Alternative reverse-proxy: Traefik. Traefik does NOT write plain PEM files —
|
||||
# everything (cert + key) lives bundled inside acme.json. SelfPost needs
|
||||
# separate PEM files for Postfix, so an extraction step is required; see
|
||||
# extract-cert.sh in this directory and the cron/systemd-timer note below.
|
||||
#
|
||||
# Merge with the base file — run this from the deploy/ directory (Compose
|
||||
@@ -15,8 +15,10 @@ services:
|
||||
ports: !override
|
||||
- "465:465"
|
||||
- "587:587"
|
||||
- "25:25"
|
||||
volumes: !override
|
||||
- ./data:/data
|
||||
- .:/selfpost-deploy:ro
|
||||
# Populated by extract-cert.sh from traefik's acme.json — see below.
|
||||
- ./traefik/extracted-certs:/etc/postfix/tls:ro
|
||||
labels:
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#!/bin/sh
|
||||
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json
|
||||
# (spec 10.3: "Traefik keeps certificates in acme.json, 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.
|
||||
# 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
|
||||
|
||||
@@ -27,13 +27,13 @@ Managed programs ([build/supervisord.conf](../build/supervisord.conf)):
|
||||
| Program | User | Priority | Role |
|
||||
|---|---|---|---|
|
||||
| `opendkim` | root → `opendkim` (`UserID` in opendkim.conf) | 100 | DKIM signing milter |
|
||||
| `panel` | panel | 200 | HTTP UI + journal-milter + log-tailer goroutine |
|
||||
| `postfix` | root (wrapper) | 300 | MTA — started only after both milter sockets exist |
|
||||
| `panel` | panel | 200 | HTTP UI + sender check + 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 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 |
|
||||
| `logrotate` | root | 400 | Periodic `mail.log` rotation |
|
||||
|
||||
Start order: OpenDKIM → panel (opens journal-milter socket) → Postfix wrapper
|
||||
Start order: OpenDKIM → panel (opens the sender check's and the journal-milter's sockets) → Postfix wrapper
|
||||
polls unix sockets (timeout `MILTER_WAIT_TIMEOUT`, default 30s) then
|
||||
`postfix start-fg`.
|
||||
|
||||
@@ -50,18 +50,104 @@ 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)
|
||||
|
||||
Internet ──► Postfix smtp inet :25 (when INBOUND_RELAY_ENABLE=true
|
||||
│ and/or DMARC_REPORTS_ENABLE=true)
|
||||
│
|
||||
├─► optional antispam milter (inbound relay only)
|
||||
├─► DMARC aggregate ingest (pipe → dmarc-ingest)
|
||||
└─► smtp:[upstream]:port (inbound relay only;
|
||||
transport_maps; no local delivery)
|
||||
```
|
||||
|
||||
The smtp/inet listener is **absent** when both flags are off (`postconf -MX
|
||||
smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
|
||||
`smtp unix` client; it is not the same service.
|
||||
|
||||
### Postfix ([build/postfix-config.sh](../build/postfix-config.sh))
|
||||
|
||||
- **465/smtps** — implicit TLS, SASL required; primary listener.
|
||||
- **587/submission** — only when `SUBMISSION_ENABLE=true`; STARTTLS with
|
||||
`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, 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`).
|
||||
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, 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 is a prefilter `reject`, an allow entry a symbol
|
||||
weighing -100. Allow is deliberately not a prefilter `accept`: with two
|
||||
prefilters rspamd lets the one that ran first decide, and deny then won or
|
||||
lost by rule order (seen on rspamd 3.11). A refusal by the deny list
|
||||
overrides any score, so deny wins whichever entries match.
|
||||
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`,
|
||||
`transport`, `relay_recipients`, `tls_policy`), written atomically by
|
||||
[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
|
||||
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.
|
||||
- **Chroot disabled** for all services (DNS/TLS inside container).
|
||||
@@ -73,26 +159,198 @@ Client ──TLS+SASL──► Postfix (465 smtps, optional 587 submission)
|
||||
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 - but the
|
||||
`From:` header is not held against the login, so an application can still
|
||||
have a message signed as the server ([security.md](security.md) § Accepted
|
||||
risks). 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, three roles:
|
||||
One process, five roles (six with the inbound relay on):
|
||||
|
||||
1. **HTTP server** — `:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse
|
||||
proxy only.
|
||||
2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
|
||||
proxy only. On start it runs `postconf -h` 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
|
||||
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. **sender check** — unix socket `SENDER_CHECK_SOCKET`, first in the chain
|
||||
and **strict** (`default_action=tempfail`): § Sender check below.
|
||||
3. **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 with trusted IPs
|
||||
raises the limit for those IPs only and skips the domain check (guide § Rate
|
||||
limiting). The level-2 count is the stored send-log rows plus the messages
|
||||
ceilings apply to every client IP; an application ceiling **overrides** the
|
||||
domain one for that login, higher or lower (guide § Rate limiting). Since
|
||||
1.9.0 the client IP allow-list is a separate, restrictive control on the
|
||||
application (`applications.auth_ip_restrict`), checked here at `MAIL FROM`
|
||||
and fail-open like the rest of this milter — not an authentication
|
||||
boundary. The level-2 count is the stored send-log rows plus the messages
|
||||
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
|
||||
4. **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)).
|
||||
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.
|
||||
5. **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.
|
||||
6. **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), OpenDKIM (tempfail),
|
||||
then the journal (accept on failure). Mail submitted locally with
|
||||
`sendmail(1)` — the notifier's — passes OpenDKIM alone (`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 and one lookup: the address, the address without its
|
||||
`+extension` (`recipient_delimiter`, which `build/postfix-config.sh` sets
|
||||
for that reason), then the domain. Postfix compares logins without regard
|
||||
to case and the check as written, which is how the panel writes them.
|
||||
- **How the header is read.** Exactly one `From:` field (none, or more than
|
||||
one, is refused), parsed as an address list that must hold exactly one
|
||||
address. Then a cruder second look at the raw value: OpenDKIM finds the
|
||||
address with a parser of its own, and wherever the two could read a line
|
||||
differently - a display name, a comment, a quoted string - the other
|
||||
address would have to be spelled out in it. So no `@name` may appear there
|
||||
that is another name the server signs for; those names are read from
|
||||
OpenDKIM's own `SigningTable`; the display name is looked at once more
|
||||
as decoded, since a mail program shows `=?utf-8?q?director=40example.org?=`
|
||||
as an address though no `@` is written in it. And, needing no knowledge of
|
||||
that parser:
|
||||
outside quoted strings the line has exactly one `@`. A folded line is
|
||||
unfolded first, and a display name in a charset Go does not convert is
|
||||
passed through unread - only the address is judged.
|
||||
- **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`, so a new container
|
||||
starts 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. 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 - a client brings a message when it
|
||||
connects. The reject line does not name the milter and the journal milter's
|
||||
level-2 refusal reads the same, hence the warning. Both go into
|
||||
`sender_check_days`, a
|
||||
row per UTC day kept a year, and Server › Health shows today, 30 days and
|
||||
12 months. The second figure also sees 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:
|
||||
while the tailer catches up, a warning no later than the last one counted
|
||||
is passed over.
|
||||
- **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 row goes with it.
|
||||
- **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. If one
|
||||
recipient's copy fails and another's goes out, the subject is said.
|
||||
- **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
|
||||
|
||||
@@ -115,7 +373,7 @@ 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.
|
||||
|
||||
**Read offset is persisted** (`logtail_state` table, migration `0003`): the
|
||||
**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
|
||||
@@ -139,70 +397,292 @@ 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.
|
||||
|
||||
**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, about a second per million
|
||||
lines, with the connection taken meanwhile.
|
||||
- **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 (the larger of `maximal_queue_lifetime` and
|
||||
`bounce_queue_lifetime`) 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:** six to ten lines of about 200 bytes per message — more than the row
|
||||
itself. 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
|
||||
`/system-log`) and `QueueLines` (the lines carrying one queue-id, for
|
||||
`/deliveries/{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.
|
||||
|
||||
---
|
||||
|
||||
## Panel HTTP surface
|
||||
|
||||
Canonical routes: [internal/web/web.go](../internal/web/web.go). Authenticated
|
||||
unless noted. Routes marked **global** return **404** for domain administrators
|
||||
(`requireGlobal()` in
|
||||
[internal/web/handlers/authz.go](../internal/web/handlers/authz.go)). The table
|
||||
below is a summary — HTMX fragment endpoints
|
||||
(`/status/fragment`, `/deliveries/rows`, `/mail-queue/body`,
|
||||
`/system-log/body`, …) and every POST variant live in `web.go`.
|
||||
Canonical routes: [internal/web/web.go](../internal/web/web.go). The router is
|
||||
two flat `http.ServeMux`es built by `muxes()` — one pattern per route, method
|
||||
included (`GET /outbound/log/{id}`), nothing mounted as a sub-router, and
|
||||
building them only takes method values, so the route guard test can run it on
|
||||
a bare `Server` and ask which pattern answers a path:
|
||||
|
||||
| Route | Purpose |
|
||||
- **public** — `/healthz` (liveness), `/license` (the embedded `LICENSE`),
|
||||
`/static/`, `/setup/`, `/login`, `/logout`;
|
||||
- **authenticated** — everything else, behind `RequireAuth`: a request without
|
||||
a live session is redirected to `/login`.
|
||||
|
||||
Both sit inside `secure()`, which sets the security headers and runs the origin
|
||||
check on every state-changing request (see below).
|
||||
|
||||
### Routes
|
||||
|
||||
A path reads like the menu: `/<group>/<page>`, an entity under its list, an
|
||||
action under the thing it changes. Every state change is a `POST`; a `GET`
|
||||
only reads (the delete confirmation at `GET …/delete` renders a form).
|
||||
|
||||
| Group | Routes |
|
||||
|---|---|
|
||||
| `/healthz` | Liveness (no auth) |
|
||||
| `/license` | Embedded `LICENSE` text (no auth) |
|
||||
| `/setup/*` | One-time admin bootstrap |
|
||||
| `/login`, `/logout` | Session auth |
|
||||
| `/account` | 308 redirect to `/settings` (pre-1.2.3 route, kept as a compat shim) |
|
||||
| `/status`, `/status/*` | **Global.** Process, cert, socket, PTR checks; machine CPU/memory/network |
|
||||
| `/domains` | Domain list; `POST /domains` (add domain) is **global** |
|
||||
| `/domains/{id}`, `/domains/{id}/*` | Assigned-domain detail for domain-admins; delete domain is **global** |
|
||||
| `/domains/import` | **Global.** Domain import (`POST`; form on the Backup page) |
|
||||
| `/deliveries`, `/deliveries/{id}` | Send log with filters; scoped to assigned domains for domain-admins |
|
||||
| `/mail-queue`, `/mail-queue/*` | **Global.** Postfix queue view |
|
||||
| `/system-log`, `/system-log/*` | **Global.** `mail.log` tail |
|
||||
| `/reload` | **Global.** `POST` — reload OpenDKIM + Postfix maps |
|
||||
| `/backup`, `/backup/*` | **Global.** Full backup download (page also hosts the import form) |
|
||||
| `/settings` | Username/password for any user; DMARC report default is **global** only |
|
||||
| `/users`, `/users/*` | **Global.** Panel user CRUD |
|
||||
| Home | `GET /` → `/overview`, or for a user with the *domain* role to the first list they reach (`/outbound/domains`, or `/inbound/domains` when that is all) |
|
||||
| Overview | `GET /overview`, `GET /overview/fragment` |
|
||||
| Outbound › Domains | `GET, POST /outbound/domains` · `/outbound/domains/{id}` · `POST …/dns-recheck` · `GET, POST …/delete` · `GET …/settings` · `POST …/settings/reports`, `…/ratelimit`, `…/ratelimit/recalc`, `…/export` |
|
||||
| Outbound › Applications | `GET, POST /outbound/domains/{id}/applications/new` · `GET, POST …/applications/{aid}` · `POST …/applications/{aid}/password`, `…/{aid}/ratelimit/recalc`, `…/{aid}/delete` |
|
||||
| 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` |
|
||||
| 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) |
|
||||
|
||||
HTMX polling refreshes monitoring fragments (5 s while the operator is active on
|
||||
the page, 30 s when the tab is visible but idle, none when hidden — scheduled in
|
||||
`panel.js` via `data-poll`, not `hx-trigger="every …"`); polling does not extend
|
||||
session idle timeout (only non-`HX-Request` GET and mutating requests count as
|
||||
activity).
|
||||
An application is addressed under its domain, and an application id that does
|
||||
not belong to the domain in the path is a 404. The application form is one
|
||||
`POST`: sender, client IPs and rate limit are validated and saved together or
|
||||
not at all, also at creation. The page that shows a new password is the
|
||||
response to the `POST` that created or regenerated it; it has no `GET` path and
|
||||
carries `Cache-Control: no-store`.
|
||||
|
||||
The pre-2.0 paths are not served and not redirected (`/status`, `/domains/…`,
|
||||
`/inbound/{id}/…`, `/deliveries`, `/mail-queue`, `/system-log`, `/backup`,
|
||||
`/users/…`, `/settings`, `/reload`, `/dmarc/…`, and `/account` as a redirect):
|
||||
each answers 404 once signed in. `TestRoutesFollowNavigation`
|
||||
([internal/web/guard_routes_test.go](../internal/web/guard_routes_test.go))
|
||||
holds the table: every path is registered with its methods, every menu entry
|
||||
points at a registered path, every old path answers 404, and no template
|
||||
contains an old path.
|
||||
|
||||
### Who may open what
|
||||
|
||||
Authorization is by tree where the tree is the boundary, and by object where it
|
||||
is not.
|
||||
|
||||
- **`/server/` is guarded once.** Routes under it are registered through
|
||||
`server()` in `web.go`, which wraps the handler in `globalOnly` (404 for
|
||||
anyone but the global role — the answer a missing page gets, so the panel does
|
||||
not confirm the page exists) and panics at start-up for a pattern outside
|
||||
`/server/`. The handlers also call `requireGlobal()` themselves.
|
||||
- **Overview and the Outbound queue** are global-only but not under `/server/`,
|
||||
so their handlers call `requireGlobal()`.
|
||||
- **Outbound is checked per domain.** `lookupDomain` resolves `{id}` and answers
|
||||
404 for a missing domain and for one the principal may not reach alike
|
||||
([handlers/handlers_domains.go](../internal/web/handlers/handlers_domains.go));
|
||||
application routes go on to check the application belongs to that domain.
|
||||
Lists and the log are filtered by assignment in the query
|
||||
(`ListDomainsForUser`), not in the template. Adding and deleting a domain is
|
||||
`requireGlobal()`.
|
||||
- **Inbound is checked per inbound domain**, separately from outbound
|
||||
([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.
|
||||
- **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.
|
||||
- **Account and Help** are open to every signed-in user.
|
||||
- The principal ([web/auth/principal.go](../internal/web/auth/principal.go))
|
||||
carries the role, the assigned outbound and inbound domain ids and the two
|
||||
*All* flags; `CanAccessDomain` and `CanAccessInboundDomain` are the only
|
||||
predicates. The reach, not the role, decides which menu groups exist.
|
||||
|
||||
Routes marked global are tested per path for a user with the *domain* role,
|
||||
including another tenant's domain id
|
||||
([handlers/handlers_delegation_test.go](../internal/web/handlers/handlers_delegation_test.go),
|
||||
[authz_test.go](../internal/web/handlers/authz_test.go)).
|
||||
|
||||
### Polled fragments
|
||||
|
||||
A polled page answers its refresh at `<page>/fragment` (`/overview/fragment`,
|
||||
`/outbound/log/fragment`, `/server/health/fragment`, …). The fragment is the
|
||||
same `{{define}}` block that renders the page's own box, so the first paint and
|
||||
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:
|
||||
it is the start-up `postconf -h` snapshot (see [Panel
|
||||
binary](#panel-binary-cmdpanel)), not a live re-read.
|
||||
|
||||
### View engine and component kit
|
||||
|
||||
Pages are rendered by [internal/web/view](../internal/web/view/view.go). There
|
||||
is one layout and one way to render.
|
||||
|
||||
- **One layout.** Every page is parsed with `layout.html` (the shell: brick
|
||||
navbar with the wordmark, the perforated edge, the strip of sibling pages,
|
||||
user menu, footer) and `components.html` (the partials), then its own file.
|
||||
`layout_signed_out` is the same document without navigation, used for sign-in
|
||||
and setup; `Engine.Render` picks it when the page data names no user. The
|
||||
page's file defines `content`; `pageFiles` in `view.go` lists the files of
|
||||
each page, and fragments are listed in `fragmentFiles` and rendered by
|
||||
`RenderFragment` with no layout.
|
||||
- **Typed page data.** A page's data is a struct that embeds `Meta` (title,
|
||||
user, the viewer's reach, the menu group and page it sits in) and carries
|
||||
the page's boxes and rows as fields, built by a constructor in the `view`
|
||||
package (`NewOutDomain`, `NewHelp`, …) that the handler fills. The shell is
|
||||
derived from `Meta` and the feature flags (`SetInboundEnabled`,
|
||||
`SetDMARCEnabled`) in one place, `Engine.shell`, so which entries the menu
|
||||
has is decided once. No template reads a `map[string]any`; the one map left
|
||||
in the handlers is the health sampler's, which is turned into a typed
|
||||
`Health` before rendering.
|
||||
- **The component kit.** [components.html](../internal/web/view/templates/components.html)
|
||||
holds one `{{define}}` per partial (page head, postmark, box and its head and
|
||||
foot, flash, health card, DNS record, facts, side menu, timeline, log pane,
|
||||
empty state, confirm list, help topic, form rows) and is the only file that
|
||||
writes their `sp-` markup. [components.go](../internal/web/view/components.go)
|
||||
is the typed input of each (`Head`, `Box`, `Record`, `Fact`, `SideMenu`, …),
|
||||
so a page passes a struct, never a `dict`. Template functions: `status_tag`
|
||||
(a status → Bulma's tag classes, once), `wbr_at` (a break after the `@` in a
|
||||
table cell). A page that needs a component the kit lacks is a design change
|
||||
made in the mockups first
|
||||
([plans/panel-redesign.md](plans/panel-redesign.md) § The contract).
|
||||
- **Help.** [help.go](../internal/web/view/help.go) is the Help page's data:
|
||||
topics grouped by section of the panel. A box's head links to its topic with
|
||||
`Box.Help` (`/help#<topic>`), and the page lists only the
|
||||
sections the viewer's menu shows.
|
||||
- **Assets.** [static/](../internal/web/view/static) is embedded
|
||||
(`//go:embed`): vendored `bulma.min.css` and a Tabler icon subset (checksummed),
|
||||
`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).
|
||||
|
||||
**Guard tests** hold the design in place; they run in `go test ./...` and CI.
|
||||
They read their rules from the accepted mockups
|
||||
([docs/assets/panel-redesign/panel/](assets/panel-redesign/index.html)) rather
|
||||
than keep a copy: `TestPanelClassVocabulary` (only the Bulma subset and the
|
||||
`sp-` classes of `panel.css`; every `sp-` class defined, used and shown on the
|
||||
components page), `TestPanelCSSIsTheMockupStylesheet`, `TestPanelCSSContract`
|
||||
(no `!important`, no id or page selector, type floor, widths),
|
||||
`TestTemplatesUseComponents` (no hand-written component markup in a page),
|
||||
`TestPanelOutlinesMatchMockups` and `TestPanelPageStructure` (each rendered page
|
||||
has the component skeleton of its mockup), `TestRedesignedPagesLoadOnlyTheKit`,
|
||||
`TestVendoredAssetsArePinned`, `TestStaticHoldsOnlyTheKnownAssets`,
|
||||
`TestRoutesFollowNavigation`, `TestNoTemplateUsesInlineScriptOrStyle`. The
|
||||
ratchet list `internal/web/view/legacy_pages.txt` is empty and can only shrink;
|
||||
the CI step
|
||||
[design-first](../.github/scripts/design-first.sh) rejects a commit that
|
||||
changes a guard together with the code it judges, and a template that is a
|
||||
byte-for-byte return of one from history. The design contract and the process
|
||||
are in [plans/panel-redesign.md](plans/panel-redesign.md) § Enforcement.
|
||||
|
||||
### Security headers, CSP and scripts
|
||||
|
||||
`secure()` ([internal/web/security.go](../internal/web/security.go)) puts these
|
||||
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
|
||||
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
|
||||
`data-confirm` prompt on destructive forms, conditional field visibility,
|
||||
adaptive polling), both optional: every form works as a native HTML `POST`
|
||||
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).
|
||||
There are no CSRF tokens — see [security.md](security.md).
|
||||
|
||||
### Errors shown to users
|
||||
|
||||
A validation refusal is shown as the text the validator wrote (on the form,
|
||||
next to the field, or in a notice above the first box). Anything else — a
|
||||
database error, `saslpasswd2`, a Postfix reload — is logged
|
||||
and the page gets a fixed sentence saying so; the error text never reaches the
|
||||
browser. A template error renders a bare `500` rather than a half-written page
|
||||
(`Engine.Render` buffers).
|
||||
|
||||
### Sessions
|
||||
|
||||
Stored in SQLite (`sessions` table, migration `0002`): cookie holds a random
|
||||
token; the database stores **SHA-256 of the token**, not the token itself — a
|
||||
stolen DB or backup archive does not alone grant login, but a browser that still
|
||||
holds the cookie works after process restart, redeploy, or full backup restore.
|
||||
Stored in SQLite (`sessions` table of the baseline, `0001_init.sql`): the cookie
|
||||
holds a random token; the database stores **SHA-256 of the token**, not the
|
||||
token itself — a stolen DB or backup archive does not alone grant login, but a
|
||||
browser that still holds the cookie works after process restart, redeploy, or
|
||||
full backup restore.
|
||||
|
||||
- **A session belongs to a user by id** (`sessions.user_id`, `ON DELETE
|
||||
CASCADE`). Deleting a user ends their sessions; renaming one keeps them;
|
||||
`RequireAuth` loads the user on every request, so a session whose user is
|
||||
gone is a redirect to `/login`.
|
||||
- **Idle timeout** — sliding window, `PANEL_SESSION_IDLE_DAYS` (default 7); no
|
||||
absolute cap (regular use keeps the session alive indefinitely).
|
||||
- **Renewal** — DB `last_seen` and cookie `Max-Age` update at most once per hour
|
||||
(`renewThreshold` in [internal/web/auth/session.go](../internal/web/auth/session.go)).
|
||||
- **Password change on `/settings`** — changing your own password deletes
|
||||
every other session for that user; the current session stays active
|
||||
- **Renewal** — the DB `expires_at` and the cookie `Max-Age` update at most once per
|
||||
hour (`renewThreshold` in
|
||||
[internal/web/auth/session.go](../internal/web/auth/session.go)).
|
||||
- **Password change on Account** — deletes every other session of that user;
|
||||
the current one stays active
|
||||
([internal/store/sessions.go](../internal/store/sessions.go),
|
||||
[handlers_settings.go](../internal/web/handlers/handlers_settings.go)).
|
||||
A global administrator resetting another user's password on `/users` updates
|
||||
the hash but does not delete that user's existing sessions.
|
||||
[handlers_account.go](../internal/web/handlers/handlers_account.go)).
|
||||
- **A password set by a global user on the user form** deletes all of that
|
||||
user's sessions (`DestroyUserSessions`); set for oneself, it keeps the
|
||||
current one
|
||||
([handlers_users.go](../internal/web/handlers/handlers_users.go),
|
||||
`endSessionsAfterPasswordSet`).
|
||||
|
||||
Restoring an **older** backup also restores session rows: a session removed
|
||||
after that backup was taken can become valid again if the browser still holds
|
||||
@@ -215,7 +695,7 @@ the cookie and the restored row's `expires_at` has not passed.
|
||||
Multi-store writes that must land in more than one place (SQLite row,
|
||||
`sasldb2` entry, Postfix map, OpenDKIM table) go through a service, which is
|
||||
also where the rollback of a partial failure lives. Handlers may call
|
||||
`store` directly for single-table reads and simple writes (sessions, admin,
|
||||
`store` directly for single-table reads and simple writes (sessions, users,
|
||||
send-log queries); the first-run setup-token file is read and written in
|
||||
`web` itself. The adapters below the services are the only code that knows
|
||||
about Postfix, OpenDKIM, DNS or the log file, which is what makes them
|
||||
@@ -242,9 +722,11 @@ flowchart TB
|
||||
subgraph services ["Services — multi-store operations + rollback"]
|
||||
domainSvc["internal/domain"]
|
||||
appSvc["internal/app"]
|
||||
inboundSvc["internal/inbound"]
|
||||
dmarcSvc["internal/dmarc"]
|
||||
end
|
||||
subgraph persistence ["Persistence"]
|
||||
store["internal/store — SQLite, embedded migrations"]
|
||||
store["internal/store — SQLite, embedded schema"]
|
||||
end
|
||||
subgraph adapters ["Adapters — the only infrastructure-aware code"]
|
||||
postfix["internal/postfix"]
|
||||
@@ -263,20 +745,27 @@ flowchart TB
|
||||
web --> store
|
||||
web --> domainSvc
|
||||
web --> appSvc
|
||||
web --> inboundSvc
|
||||
web --> dmarcSvc
|
||||
web --> backupPkg
|
||||
web --> dnscheck
|
||||
web --> health
|
||||
web --> secretfile
|
||||
domainSvc --> store
|
||||
appSvc --> store
|
||||
inboundSvc --> store
|
||||
dmarcSvc --> store
|
||||
milterPkg --> store
|
||||
logtail --> store
|
||||
domainSvc --> postfix
|
||||
appSvc --> postfix
|
||||
inboundSvc --> postfix
|
||||
dmarcSvc --> postfix
|
||||
```
|
||||
|
||||
The three roles inside the `panel` process (HTTP server, journal-milter,
|
||||
log-tailer goroutine) share one binary and one SQLite handle on purpose — see
|
||||
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
|
||||
[Panel binary](#panel-binary-cmdpanel) for why, and *Persistence* below for the
|
||||
single-connection trade-off that follows from it.
|
||||
|
||||
@@ -286,37 +775,101 @@ single-connection trade-off that follows from it.
|
||||
|
||||
| Path | Contents |
|
||||
|---|---|
|
||||
| `selfpost.db` | SQLite: domains, apps, admin, sessions, send log, L2 limits, log-tailer offset |
|
||||
| `selfpost.db` | SQLite panel state (users, sessions, domains, apps, send log, L2 limits, log-tailer offset, inbound relay, DMARC reports) — see [Database](#database) and [schema-migrations.md](schema-migrations.md) |
|
||||
| `setup-token` | First-run setup token file |
|
||||
| `opendkim/` | DKIM keys + tables |
|
||||
| `sasl/sasldb2` | Application SASL credentials |
|
||||
| `postfix/sender_login_maps` | Login → From binding |
|
||||
| `postfix/relay_domains` | Inbound domains accepted on port 25 |
|
||||
| `postfix/transport` | Inbound next-hop `smtp:[host]:port` |
|
||||
| `postfix/relay_recipients` | Inbound recipient allow-list or `@domain` catch-all |
|
||||
| `postfix/tls_policy` | TLS policy for inbound next hops |
|
||||
| `postfix/dmarc_recipients` | Allow-listed DMARC aggregate report addresses |
|
||||
| `postfix/dmarc_transport` | Pipe transport for report ingest |
|
||||
| `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) |
|
||||
|
||||
Not in `/data`: TLS certificates (reverse-proxy mount), Postfix queue
|
||||
(transit mail not migrated by design).
|
||||
Not in `/data`: TLS certificates for the panel (reverse-proxy mount) — though
|
||||
full backups also archive the operator's `./certs` PEM files when present.
|
||||
|
||||
**Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90);
|
||||
### Database
|
||||
|
||||
The schema is an embedded baseline, `internal/store/migrations/0001_init.sql`,
|
||||
and the migrations after it (`0002_inbound_spam_log.sql`), 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. 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:
|
||||
|
||||
- **`users`** — `role` is `global` or `domain` (a reach, not a rank); `email` is
|
||||
the user's own address, edited on Account and set by the global role on the
|
||||
user form; `all_domains` / `all_inbound_domains` widen a *domain* user to
|
||||
every domain of that direction, including ones added later (with a flag set
|
||||
the assignment rows are ignored).
|
||||
- **`user_domains`** and **`user_inbound_domains`** — the outbound and inbound
|
||||
domains assigned to a *domain* user. Separate tables: the same name on both
|
||||
is two assignments, and a sending domain gives no inbound access or the other
|
||||
way round.
|
||||
- **`sessions`** — `token_hash`, `user_id` (`ON DELETE CASCADE`), `expires_at`.
|
||||
Keyed by user id, not by name.
|
||||
- **`domains`** — `dmarc_rua` is the whole of a domain's report-address choice:
|
||||
`''` is no reports (a new domain), the address SelfPost derives for the
|
||||
domain and the server's hostname is the hosted choice, anything else is an
|
||||
address typed for the domain. Nothing refers to a user, and deleting one
|
||||
changes no domain.
|
||||
- **`settings`** — key/value, what holds for the instance whoever is signed in
|
||||
(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`.
|
||||
|
||||
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.
|
||||
|
||||
**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).
|
||||
|
||||
**Restore:** panel button or `selfpost-backup` CLI — SQLite snapshot + tar of
|
||||
`/data` tree, minus `log/`, the setup token and any `tls/`; version check on
|
||||
restore. On the first successful boot after restore, the panel runs one
|
||||
**Resync** — OpenDKIM's tables and Postfix's sender map are re-derived from
|
||||
SQLite and both daemons are reloaded, so drift between the extracted archive
|
||||
and the database is healed before mail flows (same step as `POST /reload` on
|
||||
demand). Stopped-container `tar` of `./data` is safe (see guide).
|
||||
**Restore:** panel button or `selfpost-backup` CLI — self-contained archive:
|
||||
`data/` (SQLite snapshot + tree minus `log/`, the setup token and any `tls/`
|
||||
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
|
||||
`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
|
||||
`tar` of `./data` alone remains possible for state-only copies (see guide).
|
||||
|
||||
**Optional encryption** of the two secret-bearing downloads
|
||||
([internal/secretfile](../internal/secretfile/secretfile.go)): password →
|
||||
scrypt → AES-256-GCM over 64 KiB chunks, each authenticated with the header,
|
||||
its counter and an end-of-stream flag (so truncation and reordering fail to
|
||||
open). Full backup `.tar.gz` → `.spbk` (SelfPost backup), domain export
|
||||
`.json` → `.spde` (SelfPost domain export); the plain forms remain the
|
||||
default. Domain import detects the envelope by magic bytes; an encrypted full
|
||||
backup is converted back with `selfpost-backup -decrypt` before restore.
|
||||
`.json` → `.spde` (SelfPost domain export). In the panel the *Encrypt with a
|
||||
password* box is ticked by default on both downloads and the plain form is
|
||||
what an unticked box gives; the `selfpost-backup` CLI stays plain unless it is
|
||||
given a password. Domain import detects the envelope by magic bytes; an
|
||||
encrypted full backup is converted back with `selfpost-backup -decrypt` before
|
||||
restore.
|
||||
|
||||
---
|
||||
|
||||
@@ -340,7 +893,8 @@ 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`), `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`
|
||||
@@ -350,7 +904,19 @@ unsupported rather than as a missing doc:
|
||||
`POSTFIX_DIR` (`/data/postfix`), `POSTFIX_SENDER_LOGIN_MAPS`
|
||||
(`/data/postfix/sender_login_maps` — read by Postfix config only; the panel
|
||||
always writes `<POSTFIX_DIR>/sender_login_maps`, so overriding this env alone
|
||||
desyncs the map Postfix reads from the file the panel maintains).
|
||||
desyncs the map Postfix reads from the file the panel maintains),
|
||||
`POSTFIX_RELAY_DOMAINS` (`/data/postfix/relay_domains`),
|
||||
`POSTFIX_TRANSPORT_MAPS` (`/data/postfix/transport`),
|
||||
`POSTFIX_RELAY_RECIPIENTS` (`/data/postfix/relay_recipients`),
|
||||
`POSTFIX_TLS_POLICY_MAPS` (`/data/postfix/tls_policy`) — same desync if
|
||||
overridden without matching the panel writer in
|
||||
[internal/postfix/inbound.go](../internal/postfix/inbound.go),
|
||||
`POSTFIX_DMARC_RECIPIENTS` (`/data/postfix/dmarc_recipients`),
|
||||
`POSTFIX_DMARC_TRANSPORT` (`/data/postfix/dmarc_transport`),
|
||||
`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).
|
||||
- **Milter and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`),
|
||||
`MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`),
|
||||
`MILTER_WAIT_TIMEOUT` (`30` seconds).
|
||||
|
||||
|
Before Width: | Height: | Size: 854 KiB |
@@ -0,0 +1,164 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,37 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,136 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,206 @@
|
||||
/* 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; }
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,36 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,168 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,80 @@
|
||||
/* 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); }
|
||||
@@ -0,0 +1,200 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,38 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,117 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,98 @@
|
||||
/* 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; }
|
||||
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 600 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 68 KiB |
@@ -0,0 +1,22 @@
|
||||
/* Cover sheet for the three directions. No library on purpose: it must not
|
||||
look like any one of them. */
|
||||
body { margin: 0; background: var(--sp-paper); color: var(--sp-ink); font: 16px/1.55 var(--sp-sans); }
|
||||
main { max-width: 68rem; margin: 0 auto; padding: 3rem 1.5rem 4rem; }
|
||||
h1 { font-size: 2rem; font-weight: 600; margin: .2rem 0 1rem; text-wrap: balance; }
|
||||
h2 { font-size: 1.15rem; font-weight: 600; margin: 2.6rem 0 .8rem; }
|
||||
h3 { font-size: 1.4rem; font-weight: 600; margin: .1rem 0 .6rem; }
|
||||
.kicker { font-family: var(--sp-mono); font-size: .75rem; letter-spacing: .1em; text-transform: uppercase; color: var(--sp-brick); margin: 0; }
|
||||
.lead { max-width: 46rem; color: var(--sp-ink-2); }
|
||||
ul { padding-left: 1.2rem; max-width: 52rem; }
|
||||
li { margin-bottom: .5rem; }
|
||||
a { color: var(--sp-brick); }
|
||||
code { font-family: var(--sp-mono); font-size: .85em; background: var(--sp-well); padding: .1em .35em; border-radius: 3px; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); gap: 1.2rem; }
|
||||
.cards section { background: var(--sp-sheet); border: 1px solid var(--sp-rule); border-top: 3px solid var(--sp-brick); padding: 1.3rem 1.4rem; display: flex; flex-direction: column; }
|
||||
.cards p { margin: 0 0 1rem; }
|
||||
.links { margin-top: auto !important; margin-bottom: 0 !important; display: flex; gap: 1.1rem; font-weight: 600; }
|
||||
.scroll { overflow-x: auto; }
|
||||
table { border-collapse: collapse; width: 100%; background: var(--sp-sheet); border: 1px solid var(--sp-rule); font-size: .93rem; }
|
||||
th, td { text-align: left; vertical-align: top; padding: .6rem .9rem; border-bottom: 1px solid var(--sp-rule); }
|
||||
thead th { font-family: var(--sp-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); font-weight: 400; }
|
||||
tbody th { font-weight: 600; white-space: nowrap; }
|
||||
@@ -0,0 +1,138 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Panel redesign · three directions</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>
|
||||
|
||||
<h2>Screen map</h2>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><th>Signed out</th><td><a href="panel/login.html">Sign in</a> · <a href="panel/login-no-admin.html">sign in before an administrator exists</a> · <a href="panel/setup.html">Create administrator</a></td></tr>
|
||||
<tr><th>Overview</th><td><a href="panel/overview.html">Overview</a> — postmark verdict, six health cards, both domain lists</td></tr>
|
||||
<tr><th>Outbound</th><td><a href="panel/out-domains.html">Domains</a> · <a href="panel/out-domain.html">domain</a> ·
|
||||
<a href="panel/out-domain-settings.html">domain settings</a> · <a href="panel/out-app.html">application form</a> ·
|
||||
<a href="panel/out-app-created.html">password shown once</a> · <a href="panel/out-domain-delete.html">delete domain</a><br>
|
||||
<a href="panel/out-log.html">Log</a> · <a href="panel/out-message.html">message</a> ·
|
||||
<a href="panel/out-queue.html">Queue</a> · <a href="panel/dmarc.html">DMARC reports</a> ·
|
||||
<a href="panel/dmarc-domain.html">by domain</a> · <a href="panel/dmarc-report.html">one report</a></td></tr>
|
||||
<tr><th>Inbound</th><td><a href="panel/in-domains.html">Domains</a> · <a href="panel/in-domain.html">domain</a> ·
|
||||
<a href="panel/in-domain-delete.html">delete domain</a> · <a href="panel/in-log.html">Log</a> ·
|
||||
<a href="panel/in-queue.html">Queue</a> (empty state)<br>
|
||||
<a href="panel/in-quarantine.html">Quarantine</a> · <a href="panel/in-quarantine-message.html">held message</a> ·
|
||||
<a href="panel/in-filter-lists.html">Filter lists</a> — roadmap items, drawn as their target look</td></tr>
|
||||
<tr><th>Server</th><td><a href="panel/health.html">Health</a> · <a href="panel/preflight.html">Preflight</a> (roadmap) · <a href="panel/system-log.html">System log</a> ·
|
||||
<a href="panel/backup.html">Backup</a> · <a href="panel/users.html">Users</a> · <a href="panel/user.html">user form</a> ·
|
||||
<a href="panel/user-delete.html">delete user</a> · <a href="panel/settings.html">Settings</a></td></tr>
|
||||
<tr><th>User menu</th><td><a href="panel/account.html">Account</a> · <a href="panel/help.html">Help</a></td></tr>
|
||||
<tr><th>Contract</th><td><a href="panel/components.html">Components</a> — every building block in every state ·
|
||||
<a href="../../plans/panel-redesign.md">the plan</a> · <code>panel/check.py</code> lints the mockups and writes <code>panel/outlines.json</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>What changed against today's 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>
|
||||
<li><strong>Every page is one of three layouts</strong>: a list (form on top, table below), a detail page (sticky side menu +
|
||||
numbered boxes), or a form page (boxes in equal columns). No page picks its own width.</li>
|
||||
<li><strong>Own password is Account</strong>, instance defaults are Settings. Export sits with the domain, import with Backup,
|
||||
and each links to the other.</li>
|
||||
<li><strong>Queues are tables</strong>, not a <code>postqueue -p</code> dump — this needs <code>postqueue -j</code> parsing.
|
||||
The inbound queue is the same queue filtered by recipient domain.</li>
|
||||
<li><strong>Confirm pages say what will be lost</strong> as a list, beside a hint for the usual alternative (export before delete).</li>
|
||||
<li>Own components on top of Bulma: postmark, perforated edge, numbered box head, health card, DNS record block, facts grid,
|
||||
timeline, log pane. The mockup's <code>theme.css</code> is about 200 lines.</li>
|
||||
<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>
|
||||
@@ -0,0 +1,115 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Account · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.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>
|
||||
<nav class="navbar is-primary" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
|
||||
<div class="navbar-menu is-active">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="overview.html">Overview</a>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="out-domains.html">Outbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
|
||||
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="in-domains.html">Inbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
|
||||
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
|
||||
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="health.html">Server</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
|
||||
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
|
||||
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
|
||||
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
|
||||
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
|
||||
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item has-dropdown is-hoverable sp-current">
|
||||
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
|
||||
<div class="navbar-dropdown is-right">
|
||||
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
|
||||
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
|
||||
<hr class="navbar-divider">
|
||||
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sp-perf" aria-hidden="true"></div>
|
||||
|
||||
<main>
|
||||
<div class="container sp-page">
|
||||
<div class="sp-head">
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker">Signed in as admin · global</p>
|
||||
<h1 class="title is-3">Account</h1>
|
||||
<p class="sp-lead">Who you are to this panel. Applications keep their own logins and passwords and are not affected.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Profile</h2></div>
|
||||
<div class="sp-box-body">
|
||||
<form class="sp-form" method="post" action="#">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Password</h2></div>
|
||||
<div class="sp-box-body">
|
||||
<form class="sp-form" method="post" action="#">
|
||||
<div class="field"><label class="label" for="cur">Current password</label><div class="control"><input class="input" id="cur" name="current_password" type="password" autocomplete="current-password" required></div></div>
|
||||
<div class="sp-pair">
|
||||
<div class="field"><label class="label" for="new">New password</label><div class="control"><input class="input" id="new" name="new_password" type="password" autocomplete="new-password" required></div></div>
|
||||
<div class="field"><label class="label" for="new2">Repeat</label><div class="control"><input class="input" id="new2" name="new_password_confirm" type="password" autocomplete="new-password" required></div></div>
|
||||
</div>
|
||||
<p class="help mb-3">Changing it signs out every other session of this account; this one stays signed in.</p>
|
||||
<button type="submit" class="button">Change password</button>
|
||||
</form>
|
||||
</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>
|
||||
@@ -0,0 +1,55 @@
|
||||
/* Rendered-page audit for the panel: what check.py cannot see because it needs
|
||||
a browser. Open any page of the mockups (or of the running panel), paste this
|
||||
file into the console, then:
|
||||
|
||||
await panelAudit(["overview.html", "out-domain.html", ...]) // same-origin URLs
|
||||
await panelAudit() // the page you are on
|
||||
|
||||
It loads each page in a 1440px and a 1280px frame and reports
|
||||
- text under the 13px floor (the postmark's imprint excepted),
|
||||
- text under 4.5:1 against the ground it actually sits on,
|
||||
- anything wider than its box, and tables that have to scroll,
|
||||
- health-card values that got clipped.
|
||||
An empty `problems` list is the pass. Attach the output to the step's evidence
|
||||
(docs/plans/panel-redesign.md, Enforcement). */
|
||||
async function panelAudit(urls = [location.href], widths = [1440, 1280]) {
|
||||
const lum = (c) => {
|
||||
const [r, g, b] = c.map((v) => ((v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
};
|
||||
const rgba = (s) => { const m = s.match(/[\d.]+/g).map(Number); return { rgb: m.slice(0, 3), a: m.length > 3 ? m[3] : 1 }; };
|
||||
const ground = (el, win) => {
|
||||
for (let e = el; e; e = e.parentElement) { const b = rgba(win.getComputedStyle(e).backgroundColor); if (b.a > 0.5) return b.rgb; }
|
||||
return [255, 255, 255];
|
||||
};
|
||||
const problems = new Set();
|
||||
let texts = 0;
|
||||
for (const url of urls) for (const width of widths) {
|
||||
const f = document.createElement("iframe");
|
||||
f.width = width; f.height = 900; f.src = url;
|
||||
document.body.appendChild(f);
|
||||
await new Promise((r) => (f.onload = r));
|
||||
await f.contentDocument.fonts.ready;
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
const d = f.contentDocument, win = f.contentWindow, at = `${width} ${url.split("/").pop()}`;
|
||||
const walker = d.createTreeWalker(d.body, NodeFilter.SHOW_TEXT);
|
||||
for (let n; (n = walker.nextNode()); ) {
|
||||
const text = n.textContent.trim(), el = n.parentElement;
|
||||
if (!text || el.closest(".sp-log")) continue; // the log pane has its own dark ground and palette
|
||||
texts++;
|
||||
const cs = win.getComputedStyle(el), what = `${el.tagName.toLowerCase()}.${[...el.classList].join(".")} "${text.slice(0, 24)}"`;
|
||||
if (parseFloat(cs.fontSize) < 12.9 && !el.closest(".sp-postmark")) problems.add(`${at}: ${parseFloat(cs.fontSize)}px ${what}`);
|
||||
const fg = rgba(cs.color).rgb, bg = ground(el, win), a = lum(fg), b = lum(bg);
|
||||
const ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
const large = parseFloat(cs.fontSize) >= 24 || (parseFloat(cs.fontSize) >= 18.6 && +cs.fontWeight >= 600);
|
||||
if (ratio < (large ? 3 : 4.5)) problems.add(`${at}: contrast ${ratio.toFixed(2)} ${what}`);
|
||||
}
|
||||
if (d.documentElement.scrollWidth > win.innerWidth) problems.add(`${at}: the page scrolls sideways`);
|
||||
for (const e of d.querySelectorAll(".box, .sp-head, .sp-box-head")) if (e.scrollWidth > e.clientWidth + 1) problems.add(`${at}: overflow in .${e.className}`);
|
||||
for (const e of d.querySelectorAll(".table-container")) if (e.scrollWidth > e.clientWidth + 1) problems.add(`${at}: a table scrolls by ${e.scrollWidth - e.clientWidth}px`);
|
||||
for (const e of d.querySelectorAll(".sp-h-name, .sp-h-value, .sp-h-sub")) if (e.scrollWidth > e.clientWidth + 1) problems.add(`${at}: clipped "${e.textContent}"`);
|
||||
for (const i of d.querySelectorAll("i.ti")) if (win.getComputedStyle(i, "::before").content === "none") problems.add(`${at}: missing icon ${i.className}`);
|
||||
f.remove();
|
||||
}
|
||||
return { pages: urls.length, widths, textsChecked: texts, problems: [...problems] };
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Backup · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.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>
|
||||
<nav class="navbar is-primary" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
|
||||
<div class="navbar-menu is-active">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="overview.html">Overview</a>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="out-domains.html">Outbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
|
||||
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="in-domains.html">Inbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
|
||||
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
|
||||
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable sp-current">
|
||||
<a class="navbar-link" href="health.html">Server</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
|
||||
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
|
||||
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
|
||||
<a class="navbar-item is-active" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
|
||||
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
|
||||
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
|
||||
<div class="navbar-dropdown is-right">
|
||||
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
|
||||
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
|
||||
<hr class="navbar-divider">
|
||||
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sp-perf" aria-hidden="true"></div>
|
||||
<div class="sp-subnav"><div class="container"><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html" aria-current="page"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></div></div>
|
||||
<main>
|
||||
<div class="container sp-page">
|
||||
<div class="sp-head">
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker">Server</p>
|
||||
<h1 class="title is-3">Backup</h1>
|
||||
<p class="sp-lead">Take the whole instance with you, or bring one domain in from another SelfPost. Both files are secrets.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
<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), <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">
|
||||
<div class="field"><label class="label" for="pw">Password</label><div class="control"><input class="input" id="pw" type="password" placeholder="at least 12 characters"></div></div>
|
||||
<div class="field"><label class="label" for="pw2">Repeat password</label><div class="control"><input class="input" id="pw2" type="password"></div></div>
|
||||
</div>
|
||||
<p class="help mb-3">Keep this password: SelfPost does not store it, and the file cannot be opened without it.</p>
|
||||
<button type="submit" class="button is-primary"><i class="ti ti-download"></i>Download full backup</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<div class="box" id="import">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Import a domain</h2><a class="sp-help" href="help.html#backup" title="Moving a domain to another instance"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="mb-3">A domain export from another instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS needs no change. The export itself is made on the <a href="out-domain-settings.html">domain's settings</a>.</p>
|
||||
<form class="sp-form" method="post" action="out-domain.html" enctype="multipart/form-data">
|
||||
<div class="field"><label class="label">Domain export file</label>
|
||||
<div class="file has-name is-fullwidth"><label class="file-label"><input class="file-input" type="file" name="file" accept=".json,.spde"><span class="file-cta"><i class="ti ti-upload"></i><span class="file-label ml-2">Choose a file…</span></span><span class="file-name">example.org.spde</span></label></div></div>
|
||||
<div class="field"><label class="label" for="ipw">Password</label><div class="control"><input class="input" id="ipw" name="import_password" type="password" autocomplete="off"></div>
|
||||
<p class="help">Asked only for an encrypted <code>.spde</code> file.</p></div>
|
||||
<button type="submit" class="button is-primary"><i class="ti ti-file-import"></i>Import domain</button>
|
||||
</form>
|
||||
</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>
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Wrap every fragment in src/ with the panel shell and write it next to this file.
|
||||
|
||||
The mockups are static HTML, but thirty pages share one navbar; keeping that
|
||||
navbar in one place is the only reason this script exists. Run it from
|
||||
anywhere: python docs/assets/panel-redesign/panel/build.py
|
||||
|
||||
A fragment starts with one meta comment:
|
||||
<!-- title: Outbound domains | section: outbound | page: domains -->
|
||||
section/page pick the highlighted entries; "shell: bare" skips the navbar
|
||||
(sign-in, setup).
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
HERE = pathlib.Path(__file__).parent
|
||||
|
||||
NAV = [
|
||||
("overview", "Overview", "overview.html", "ti-activity-heartbeat", []),
|
||||
("outbound", "Outbound", "out-domains.html", "ti-send", [
|
||||
("domains", "Domains", "out-domains.html", "ti-world-upload"),
|
||||
("log", "Log", "out-log.html", "ti-list-details"),
|
||||
("queue", "Queue", "out-queue.html", "ti-stack-2"),
|
||||
("dmarc", "DMARC reports", "dmarc.html", "ti-report-analytics"),
|
||||
]),
|
||||
("inbound", "Inbound", "in-domains.html", "ti-inbox", [
|
||||
("domains", "Domains", "in-domains.html", "ti-world-download"),
|
||||
("log", "Log", "in-log.html", "ti-list-details"),
|
||||
("queue", "Queue", "in-queue.html", "ti-stack-2"),
|
||||
("quarantine", "Quarantine", "in-quarantine.html", "ti-mail-pause"),
|
||||
("filter-lists", "Filter lists", "in-filter-lists.html", "ti-filter"),
|
||||
]),
|
||||
("server", "Server", "health.html", "ti-server-2", [
|
||||
("health", "Health", "health.html", "ti-heartbeat"),
|
||||
("preflight", "Preflight", "preflight.html", "ti-checklist"),
|
||||
("system-log", "System log", "system-log.html", "ti-file-text"),
|
||||
("backup", "Backup", "backup.html", "ti-archive"),
|
||||
("users", "Users", "users.html", "ti-users"),
|
||||
("settings", "Settings", "settings.html", "ti-settings"),
|
||||
]),
|
||||
]
|
||||
|
||||
HEAD = """<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{title} · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.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>
|
||||
"""
|
||||
|
||||
FOOT = ('<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a>'
|
||||
' · <a href="#">Source</a> · No warranty</footer>')
|
||||
|
||||
|
||||
def navbar(section, page):
|
||||
out = ['<nav class="navbar is-primary" aria-label="main navigation">', ' <div class="container">',
|
||||
' <div class="navbar-brand"><a class="navbar-item" href="overview.html">'
|
||||
'<img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>',
|
||||
' <div class="navbar-menu is-active">', ' <div class="navbar-start">']
|
||||
for key, label, href, _icon, pages in NAV:
|
||||
cur = " sp-current" if key == section else ""
|
||||
if not pages:
|
||||
out.append(f' <a class="navbar-item{cur}" href="{href}">{label}</a>')
|
||||
continue
|
||||
out.append(f' <div class="navbar-item has-dropdown is-hoverable{cur}">')
|
||||
out.append(f' <a class="navbar-link" href="{href}">{label}</a>')
|
||||
out.append(' <div class="navbar-dropdown">')
|
||||
for pkey, plabel, phref, picon in pages:
|
||||
act = " is-active" if key == section and pkey == page else ""
|
||||
out.append(f' <a class="navbar-item{act}" href="{phref}"><i class="ti {picon}"></i>{plabel}</a>')
|
||||
out.append(' </div>')
|
||||
out.append(' </div>')
|
||||
out += [' </div>', ' <div class="navbar-end">',
|
||||
' <div class="navbar-item has-dropdown is-hoverable%s">' % (" sp-current" if section == "user" else ""),
|
||||
' <a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>',
|
||||
' <div class="navbar-dropdown is-right">',
|
||||
' <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>',
|
||||
' <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>',
|
||||
' <hr class="navbar-divider">',
|
||||
# Signing out changes state, so it is a POST: a form with a button, not a link.
|
||||
' <form method="post" action="login.html"><button type="submit" class="navbar-item">'
|
||||
'<i class="ti ti-logout"></i>Sign out</button></form>',
|
||||
' </div>', ' </div>', ' </div>', ' </div>', ' </div>', '</nav>',
|
||||
'<div class="sp-perf" aria-hidden="true"></div>']
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def subnav(section, page):
|
||||
for key, _label, _href, _icon, pages in NAV:
|
||||
if key == section and pages:
|
||||
cur = ' aria-current="page"'
|
||||
links = "".join(
|
||||
f'<a href="{phref}"{cur if pkey == page else ""}><i class="ti {picon}"></i>{plabel}</a>'
|
||||
for pkey, plabel, phref, picon in pages)
|
||||
return f'<div class="sp-subnav"><div class="container">{links}</div></div>'
|
||||
return ""
|
||||
|
||||
|
||||
def build(path):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
m = re.match(r"\s*<!--(.*?)-->\s*", text, re.S)
|
||||
meta = dict(part.split(":", 1) for part in m.group(1).split("|"))
|
||||
meta = {k.strip(): v.strip() for k, v in meta.items()}
|
||||
body = text[m.end():]
|
||||
html = HEAD.format(title=meta["title"])
|
||||
if meta.get("shell") == "bare":
|
||||
html += f"<body>\n{body}\n</body>\n</html>\n"
|
||||
else:
|
||||
section, page = meta.get("section", ""), meta.get("page", "")
|
||||
html += (f"<body>\n{navbar(section, page)}\n{subnav(section, page)}\n<main>\n"
|
||||
f' <div class="container sp-page">\n{body}\n {FOOT}\n </div>\n</main>\n</body>\n</html>\n')
|
||||
(HERE / path.name).write_text(html, encoding="utf-8", newline="\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pages = sorted((HERE / "src").glob("*.html"))
|
||||
for p in pages:
|
||||
build(p)
|
||||
print(f"built {len(pages)} pages")
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Design-contract lint for the panel mockups, and the source of outlines.json.
|
||||
|
||||
The contract (docs/plans/panel-redesign.md) is only worth something if a
|
||||
machine can say "no". This script is that machine for the mockups; the same
|
||||
rules are ported to a Go test over internal/web templates when the redesign is
|
||||
implemented, and that test compares rendered pages with outlines.json.
|
||||
|
||||
python docs/assets/panel-redesign/panel/check.py # lint
|
||||
python docs/assets/panel-redesign/panel/check.py --write # lint + rewrite outlines.json
|
||||
|
||||
Exit code 1 on any violation.
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
from html.parser import HTMLParser
|
||||
|
||||
HERE = pathlib.Path(__file__).parent
|
||||
VOID = {"input", "img", "br", "hr", "meta", "link"}
|
||||
|
||||
# The subset of Bulma the panel uses. Reaching for a Bulma class outside this
|
||||
# list is a design decision, not an implementation detail: add it here and to
|
||||
# the components page in the same change, or do without it.
|
||||
BULMA = set("""
|
||||
box button buttons checkbox column columns control field file file-cta file-input file-label
|
||||
file-name has-addons has-icons-left has-name has-text-danger help icon input label menu menu-label
|
||||
menu-list notification progress select table table-container tag textarea title
|
||||
is-3 is-4 is-5 is-6 is-7 is-9 is-active is-danger is-expanded is-fullwidth is-gapless is-half
|
||||
is-hidden is-hoverable is-left is-light is-primary is-small is-success is-text is-warning
|
||||
mb-1 mb-2 mb-3 mb-4 ml-2 mt-4 mt-5
|
||||
""".split())
|
||||
# Classes the shell (build.py, later layout.html) owns; screens never write them.
|
||||
SHELL = {"sp-page", "sp-perf", "sp-subnav", "sp-current", "sp-foot"}
|
||||
# The signed-out screens have no shell, so they carry their own footer.
|
||||
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-sheet")
|
||||
|
||||
|
||||
class Node:
|
||||
def __init__(self, tag, attrs, parent):
|
||||
self.tag, self.attrs, self.parent, self.kids, self.text = tag, dict(attrs), parent, [], ""
|
||||
|
||||
@property
|
||||
def classes(self):
|
||||
return (self.attrs.get("class") or "").split()
|
||||
|
||||
def has(self, cls):
|
||||
return cls in self.classes
|
||||
|
||||
def walk(self):
|
||||
yield self
|
||||
for k in self.kids:
|
||||
yield from k.walk()
|
||||
|
||||
def find(self, cls):
|
||||
return [n for n in self.walk() if n.has(cls)]
|
||||
|
||||
|
||||
class Tree(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.root = Node("root", [], None)
|
||||
self.cur = self.root
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
n = Node(tag, attrs, self.cur)
|
||||
self.cur.kids.append(n)
|
||||
if tag not in VOID:
|
||||
self.cur = n
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
n = self.cur
|
||||
while n is not self.root and n.tag != tag:
|
||||
n = n.parent
|
||||
if n is not self.root:
|
||||
self.cur = n.parent
|
||||
|
||||
def handle_data(self, data):
|
||||
self.cur.text += data
|
||||
|
||||
|
||||
def parse(text):
|
||||
t = Tree()
|
||||
t.feed(text)
|
||||
return t.root
|
||||
|
||||
|
||||
def text_of(node):
|
||||
return re.sub(r"\s+", " ", "".join(n.text for n in node.walk())).strip()
|
||||
|
||||
|
||||
def css_rules(css):
|
||||
css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
|
||||
css = re.sub(r"@media[^{]*\{", "", css)
|
||||
for sel, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css):
|
||||
yield sel.strip(), body
|
||||
|
||||
|
||||
def box_kind(box):
|
||||
kinds = []
|
||||
for k in box.kids:
|
||||
if k.has("sp-box-head") or k.has("sp-box-foot"):
|
||||
continue
|
||||
for cls, name in (("sp-record", "record"), ("sp-health", "health"), ("sp-empty", "empty"),
|
||||
("sp-log", "log"), ("sp-help-topic", "help"), ("sp-box-body", "body")):
|
||||
if k.has(cls):
|
||||
kinds.append(name)
|
||||
break
|
||||
else:
|
||||
kinds.append("table" if k.tag == "table" or k.has("table-container") else k.tag)
|
||||
out = []
|
||||
for k in kinds:
|
||||
if not out or out[-1].split("×")[0] != k:
|
||||
out.append(k)
|
||||
else:
|
||||
n = int(out[-1].split("×")[1]) + 1 if "×" in out[-1] else 2
|
||||
out[-1] = f"{k}×{n}"
|
||||
return "+".join(out)
|
||||
|
||||
|
||||
def outline(node):
|
||||
"""The component skeleton of a page: what a reviewer sees with the text blurred."""
|
||||
out = []
|
||||
for k in node.kids:
|
||||
if k.has("sp-head"):
|
||||
parts = ["head"] + [p for p, c in (("postmark", "sp-postmark"), ("actions", "buttons")) if k.find(c)]
|
||||
if not k.find("buttons") and any(n.tag in ("a", "form") and n.parent is k for n in k.walk()):
|
||||
parts.append("actions")
|
||||
out.append("+".join(parts))
|
||||
elif k.has("box"):
|
||||
variant = next((v for v in ("sp-danger-zone", "sp-credential") if k.has(v)), "")
|
||||
head = k.find("sp-box-head")
|
||||
title = text_of(head[0].find("sp-no")[0].parent.kids[1]) if head else "?"
|
||||
out.append(f"box{'.' + variant if variant else ''}[{title}]:{box_kind(k)}")
|
||||
elif k.has("columns"):
|
||||
cols = []
|
||||
for c in k.kids:
|
||||
size = next((x for x in c.classes if re.fullmatch(r"is-\d+", x)), "auto")
|
||||
cols.append({size: outline(c)})
|
||||
out.append({"columns": cols})
|
||||
elif k.tag == "form" or k.has("menu") or k.tag == "aside":
|
||||
out.append({"form": outline(k)} if k.tag == "form" else "side_menu")
|
||||
elif k.has("buttons"):
|
||||
out.append("submit_row")
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
errors = []
|
||||
css = (HERE / "theme.css").read_text(encoding="utf-8")
|
||||
defined = set(re.findall(r"\.(sp-[a-z0-9-]+)", css))
|
||||
|
||||
for sel, body in css_rules(css):
|
||||
if "!important" in body:
|
||||
errors.append(f"theme.css: !important in `{sel}`")
|
||||
if re.search(r"(^|[\s,>+~])#[a-z]", sel):
|
||||
errors.append(f"theme.css: id selector `{sel}` — style components, not one element on one page")
|
||||
# The type floor: 13px. Icons (.ti) and the postmark imprint are not running text.
|
||||
for value, unit in re.findall(r"font-size\s*:\s*([\d.]+)(rem|em|px)", body):
|
||||
floor = {"rem": 0.8125, "em": 0.85, "px": 13}[unit]
|
||||
if float(value) < floor and ".sp-postmark" not in sel and ".ti" not in sel:
|
||||
errors.append(f"theme.css: `{sel}` sets font-size {value}{unit}, below the 13px floor — use --sp-fs-xs or --sp-fs-s")
|
||||
# `min-width: 0` sizes nothing: it only lets a flex or grid child shrink so its text can be clipped.
|
||||
sized = re.sub(r"min-width\s*:\s*0\s*(;|$)", "", body)
|
||||
if re.search(r"(?<![-\w])(max-width|min-width|width)\s*:", sized) and not any(sel.startswith(w) or w in sel for w in WIDTH_OK):
|
||||
errors.append(f"theme.css: `{sel}` sets a width; widths come from the page layout (WIDTH_OK)")
|
||||
|
||||
used, documented, outlines = set(), set(), {}
|
||||
for src in sorted((HERE / "src").glob("*.html")):
|
||||
text = src.read_text(encoding="utf-8")
|
||||
name = src.stem
|
||||
bare = "shell: bare" in text.split("-->")[0]
|
||||
for bad in ("style=", "<style", "<script", " onclick=", " id=\"page-"):
|
||||
if bad in text:
|
||||
errors.append(f"{name}: `{bad}` is forbidden in a screen")
|
||||
root = parse(text)
|
||||
for n in root.walk():
|
||||
for c in n.classes:
|
||||
if c.startswith("sp-"):
|
||||
(documented if name == "components" else used).add(c)
|
||||
if c in SHELL and not (bare and c in SIGNED_OUT):
|
||||
errors.append(f"{name}: `{c}` belongs to the shell")
|
||||
elif c not in defined:
|
||||
errors.append(f"{name}: `{c}` is not defined in theme.css")
|
||||
elif c == "ti" or c.startswith("ti-"):
|
||||
continue
|
||||
elif c == "is-small" and "progress" not in n.classes:
|
||||
errors.append(f"{name}: `is-small` on <{n.tag}> — controls keep the normal size; only a progress bar is small")
|
||||
elif c not in BULMA:
|
||||
errors.append(f"{name}: class `{c}` is neither in the Bulma subset nor an sp- component")
|
||||
if bare:
|
||||
continue
|
||||
top = [k for k in root.kids if k.tag != "root"]
|
||||
if not top or not top[0].has("sp-head"):
|
||||
errors.append(f"{name}: the page must open with page_head (.sp-head)")
|
||||
if len([n for n in root.walk() if n.tag == "h1"]) != 1:
|
||||
errors.append(f"{name}: exactly one <h1>")
|
||||
marks = root.find("sp-postmark")
|
||||
if name != "components" and (len(marks) > 1 or any(not m.parent.has("sp-head") for m in marks)):
|
||||
errors.append(f"{name}: at most one postmark, inside page_head")
|
||||
for tbl in (n for n in root.walk() if n.tag == "table"):
|
||||
if not tbl.parent.has("table-container"):
|
||||
errors.append(f"{name}: a <table> must sit in a table-container so a wide one can scroll")
|
||||
for box in root.find("box"):
|
||||
if not box.kids or not box.kids[0].has("sp-box-head"):
|
||||
errors.append(f"{name}: a box must start with box_head")
|
||||
for k in top[1:]:
|
||||
# A flash is conditional, so it is allowed at the top level but never part of an outline.
|
||||
ok = k.has("box") or k.has("columns") or k.has("notification") or (k.tag == "form" and k.has("sp-form"))
|
||||
if not ok:
|
||||
errors.append(f"{name}: top-level <{k.tag} class=\"{' '.join(k.classes)}\"> — a page is boxes, columns or one form")
|
||||
if name != "components":
|
||||
outlines[name] = outline(root)
|
||||
|
||||
for c in sorted(used - documented - SIGNED_OUT):
|
||||
errors.append(f"components page does not show `{c}` — document it or stop using it")
|
||||
shell_used = set(re.findall(r"sp-[a-z0-9-]+", (HERE / "build.py").read_text(encoding="utf-8")))
|
||||
for c in sorted(defined - used - documented - shell_used):
|
||||
errors.append(f"theme.css defines `{c}` but nothing uses it")
|
||||
|
||||
if "--write" in sys.argv and not errors:
|
||||
(HERE / "outlines.json").write_text(json.dumps(outlines, ensure_ascii=False, indent=1) + "\n",
|
||||
encoding="utf-8", newline="\n")
|
||||
golden = HERE / "outlines.json"
|
||||
if golden.exists() and "--write" not in sys.argv:
|
||||
if json.loads(golden.read_text(encoding="utf-8")) != outlines:
|
||||
errors.append("outlines.json is stale — a screen's structure changed; re-run with --write and say so in the commit")
|
||||
|
||||
for e in errors:
|
||||
print("FAIL", e)
|
||||
print(f"{len(outlines) + 1} screens, {len(used | documented)} sp- classes, {len(errors)} violation(s)")
|
||||
return 1 if errors else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,296 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Components · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.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>
|
||||
<nav class="navbar is-primary" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
|
||||
<div class="navbar-menu is-active">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="overview.html">Overview</a>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="out-domains.html">Outbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
|
||||
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="in-domains.html">Inbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
|
||||
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
|
||||
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="health.html">Server</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
|
||||
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
|
||||
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
|
||||
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
|
||||
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
|
||||
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
|
||||
<div class="navbar-dropdown is-right">
|
||||
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
|
||||
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
|
||||
<hr class="navbar-divider">
|
||||
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sp-perf" aria-hidden="true"></div>
|
||||
|
||||
<main>
|
||||
<div class="container sp-page">
|
||||
<div class="sp-head">
|
||||
<div class="sp-postmark"><span>SELFPOST</span><b>REF</b><span>DESIGN CONTRACT</span></div>
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker">Reference · page_head</p>
|
||||
<h1 class="title is-3">Components</h1>
|
||||
<p class="sp-lead">Every building block of the panel, in every state it has. A screen is assembled from these and from plain Bulma — nothing else. The title of each box is the name of its template partial.</p>
|
||||
</div>
|
||||
<div class="buttons"><a class="button" href="../index.html"><i class="ti ti-arrow-left"></i>Cover sheet</a></div>
|
||||
</div>
|
||||
|
||||
<div class="notification is-success is-light"><i class="ti ti-circle-check"></i><strong>flash</strong> — the result of the last action, between page_head and the first box. Success, or…</div>
|
||||
<div class="notification is-danger is-light"><i class="ti ti-alert-circle"></i>…<strong>flash, error</strong> — a failure that belongs to the page, not to one field. A field's error stays under the field in Bulma's <code>help is-danger</code>.</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">00</span><h2>Page layouts</h2><div class="sp-end">A page is exactly one of these</div></div>
|
||||
<div class="sp-box-body">
|
||||
<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. 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 sp-cards">
|
||||
<div class="column is-6">
|
||||
<div class="box">
|
||||
<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">
|
||||
<div class="sp-postmark"><span>SELFPOST</span><b>OK</b><span>6 OF 6 CHECKS</span></div>
|
||||
<div class="sp-postmark sp-warn"><span>SELFPOST</span><b>WARN</b><span>1 OF 6 CHECKS</span></div>
|
||||
<div class="sp-postmark sp-fail"><span>DNS</span><b>FAIL</b><span>1 OF 3 RECORDS</span></div>
|
||||
</div>
|
||||
<p class="sp-muted">Only inside page_head, at most one per page, and only where the page has a verdict to give.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<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>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="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>
|
||||
</div>
|
||||
<div class="box sp-danger-zone">
|
||||
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-alert-triangle"></i></span><h2>box · sp-danger-zone</h2></div>
|
||||
<div class="sp-box-body"><p>For destructive confirmations and the "delete" entry of a settings page. The number may be an icon when the box is not part of a sequence.</p></div>
|
||||
</div>
|
||||
<div class="box sp-credential">
|
||||
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-eye"></i></span><h2>box · sp-credential</h2><div class="sp-end">Shown once</div></div>
|
||||
<div class="sp-box-body"><p>Only for a secret the panel will never show again.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>health_card</h2><div class="sp-end">Overview only · each card links to its detail</div></div>
|
||||
<div class="sp-health">
|
||||
<a href="#"><span class="sp-h-icon"><i class="ti ti-server-cog"></i></span><span><span class="sp-h-name">Name</span><span class="sp-h-value">Verdict in words</span><span class="sp-h-sub">ok · one supporting fact</span></span></a>
|
||||
<a href="#" class="sp-warn"><span class="sp-h-icon"><i class="ti ti-certificate"></i></span><span><span class="sp-h-name">Name</span><span class="sp-h-value">Expires in 12 days</span><span class="sp-h-sub">sp-warn</span></span></a>
|
||||
<a href="#" class="sp-fail"><span class="sp-h-icon"><i class="ti ti-plug-connected-x"></i></span><span><span class="sp-h-name">Name</span><span class="sp-h-value">1 of 2 answering</span><span class="sp-h-sub">sp-fail</span></span></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-7">
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">05</span><h2>dns_record · copy_field</h2><div class="sp-end">One block per record</div></div>
|
||||
<div class="sp-record">
|
||||
<div class="sp-record-head"><h3>Record</h3><span class="tag is-success is-light">published</span><small>A one-line note</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="copy_field — single line"></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="2" readonly>copy_field — multi-line, for a DKIM key</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>Record</h3><span class="tag is-danger is-light">mismatch</span><small>"In DNS now" appears only when it differs</small></div>
|
||||
<div class="field"><label class="label">In DNS now</label>
|
||||
<div class="control"><input class="input is-danger" readonly value="what the resolver returned"></div>
|
||||
<p class="help is-danger">What is wrong and the exact fix.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">07</span><h2>Table conventions</h2><div class="sp-end">Always inside <code>table-container</code> · an address breaks after its @</div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Time, UTC</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td><a href="#"><strong>the row's link</strong></a></td><td class="sp-clip">A long subject is clipped with an ellipsis instead of wrapping the row</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="#">Details</a><form method="post" action="#"><button type="submit" class="has-text-danger">Delete</button></form></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="sp-box-foot"><span>Page 1 of 9</span><a href="#">Older →</a></div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">09</span><h2>log_pane</h2><div class="sp-end">sp-d time · sp-w warning · sp-e error</div></div>
|
||||
<pre class="sp-log"><span class="sp-d">13:58:07</span> an ordinary line
|
||||
<span class="sp-d">13:58:09</span> <span class="sp-w">a deferred delivery — sp-w</span>
|
||||
<span class="sp-d">13:57:41</span> <span class="sp-e">a reject or a bounce — sp-e</span></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-5">
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">06</span><h2>facts</h2></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Label</dt><dd>Value</dd></div>
|
||||
<div><dt>Mono value</dt><dd class="sp-mono">4XcB7k2Jm9z1</dd></div>
|
||||
<div><dt>sp-big</dt><dd class="sp-big">5 days</dd><dd class="sp-muted">a supporting line</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">08</span><h2>timeline</h2></div>
|
||||
<div class="sp-box-body">
|
||||
<ol class="sp-timeline">
|
||||
<li><time>13:58:07 UTC</time><strong>Done</strong> — default</li>
|
||||
<li class="sp-warn"><time>13:58:09 UTC</time><strong>Deferred</strong> — sp-warn</li>
|
||||
<li class="sp-fail"><time>14:40:00 UTC</time><strong>Bounced</strong> — sp-fail</li>
|
||||
<li class="sp-pending"><time>not yet</time>Expected next — sp-pending</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">10</span><h2>empty_state</h2></div>
|
||||
<div class="sp-empty"><i class="ti ti-stack-2"></i>One sentence: what is absent and why that is fine.</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">11</span><h2>confirm_list</h2></div>
|
||||
<div class="sp-box-body">
|
||||
<ul class="sp-list-check">
|
||||
<li><i class="ti ti-x"></i>each thing that will be lost, one per line;</li>
|
||||
<li><i class="ti ti-x"></i>inside an sp-danger-zone box, above the danger button.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-3">
|
||||
<aside class="menu sp-side">
|
||||
<p class="menu-label">side_menu</p>
|
||||
<ul class="menu-list">
|
||||
<li><a class="is-active" href="#"><i class="ti ti-world-check"></i>Current <span class="tag is-danger is-light">1</span></a></li>
|
||||
<li><a href="#"><i class="ti ti-apps"></i>Section <span class="tag is-light">2</span></a></li>
|
||||
</ul>
|
||||
<p class="menu-label">Rarely changed</p>
|
||||
<ul class="menu-list">
|
||||
<li><a class="has-text-danger" href="#"><i class="ti ti-trash has-text-danger"></i>Delete</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="column is-9">
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">12</span><h2>Form rows · route · help_topic</h2></div>
|
||||
<div class="sp-box-body">
|
||||
<div class="sp-form">
|
||||
<div class="sp-row">
|
||||
<div class="field"><label class="label">sp-row — a wide field</label><div class="control"><input class="input" value="host"></div></div>
|
||||
<div class="field"><label class="label">and a short one</label><div class="control"><input class="input" value="25"></div></div>
|
||||
</div>
|
||||
<div class="sp-pair">
|
||||
<div class="field"><label class="label">sp-pair — two equal fields</label><div class="control"><input class="input" type="password"></div></div>
|
||||
<div class="field"><label class="label">Repeat</label><div class="control"><input class="input" type="password"></div><p class="help">Help text lives in Bulma's <code>help</code>.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="sp-route"><span>from@example.org</span><i class="ti ti-arrow-right sp-muted"></i><span>to@example.net</span><span class="tag is-warning is-light">deferred</span></p>
|
||||
</div>
|
||||
<div class="sp-help-topic"><h3>help_topic</h3><p>A heading and a paragraph; topics stack inside one box on the Help page.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">13</span><h2>shell</h2><div class="sp-end">Owned by the layout — a screen never writes it</div></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Name</dt><dd>The wordmark, not the stamp: at nav size the stamp's perforation is under a pixel and its brick field dissolves into the bar. The stamp lives on sign-in, setup and the favicon.</dd></div>
|
||||
<div><dt>Edge</dt><dd>Built like the mark: the brick field, then the stamp's paper margin, and the perforation along the outer edge of that margin — round holes, flat teeth, the ink hairline. One tile, <code>perf-edge.svg</code>.</dd></div>
|
||||
<div><dt>Sibling strip</dt><dd>The pages of the current group, under the edge. Five or six entries; past that the group is split.</dd></div>
|
||||
<div><dt>Menus</dt><dd>Bulma hover dropdowns, no JavaScript. The user menu holds Account, Help and Sign out.</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">14</span><h2>Type scale</h2><div class="sp-end">Three sizes · nothing to be read is under 13px</div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Size</th><th>Looks like</th><th>Used for</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-small">body · 16px</td><td>The quick brown fox, 203.0.113.25</td><td class="sp-muted">Text, table cells, inputs, selects, buttons, menus and their dropdowns</td></tr>
|
||||
<tr><td class="sp-mono sp-small">--sp-fs-s · 14px</td><td class="sp-small">The quick brown fox, <span class="sp-mono">203.0.113.25</span></td><td class="sp-muted">Supporting text: labels, <code>help</code>, notes in a box head or foot, mono values (<code>sp-small</code>), logs, the footer</td></tr>
|
||||
<tr><td class="sp-mono sp-small">--sp-fs-xs · 13px</td><td><span class="sp-kicker">Upper-case mono label</span> <span class="tag is-light">tag</span></td><td class="sp-muted">Upper-case mono micro-labels (kicker, table head, <code>dt</code>, box title) and tags — nothing else</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="sp-box-foot">Bulma's <code>is-small</code> and <code>is-size-7</code> are not used: a control is never small, and <code>sp-small</code> replaces the helper. The postmark's fine print is an imprint, not text.</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>
|
||||
@@ -0,0 +1,110 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>shop.example.org · DMARC reports · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.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>
|
||||
<nav class="navbar is-primary" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
|
||||
<div class="navbar-menu is-active">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="overview.html">Overview</a>
|
||||
<div class="navbar-item has-dropdown is-hoverable sp-current">
|
||||
<a class="navbar-link" href="out-domains.html">Outbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
|
||||
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item is-active" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="in-domains.html">Inbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
|
||||
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
|
||||
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="health.html">Server</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
|
||||
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
|
||||
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
|
||||
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
|
||||
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
|
||||
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
|
||||
<div class="navbar-dropdown is-right">
|
||||
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
|
||||
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
|
||||
<hr class="navbar-divider">
|
||||
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sp-perf" aria-hidden="true"></div>
|
||||
<div class="sp-subnav"><div class="container"><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html" aria-current="page"><i class="ti ti-report-analytics"></i>DMARC reports</a></div></div>
|
||||
<main>
|
||||
<div class="container sp-page">
|
||||
<div class="sp-head">
|
||||
<div class="sp-postmark sp-warn"><span>DMARC</span><b>99.6%</b><span>PASS · 7 DAYS</span></div>
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker"><a href="dmarc.html">DMARC reports</a> /</p>
|
||||
<h1 class="title is-3">shop.example.org</h1>
|
||||
<p class="sp-lead">11 204 pass · 41 fail in the last 7 days. Failures come from a source that is not this relay — review before tightening <code>p=</code>.</p>
|
||||
</div>
|
||||
<a class="button" href="out-domain.html"><i class="ti ti-world-upload"></i>Domain page</a>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-5">
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Sources · 7 days</h2><a class="sp-help" href="help.html#dmarc" title="How to read pass, fail and sources"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Source</th><th>Pass</th><th>Fail</th><th>Disposition</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-small">203.0.113.25 <span class="tag is-light">this relay</span></td><td>11 204</td><td>0</td><td><span class="tag is-success is-light">none</span></td></tr>
|
||||
<tr><td class="sp-mono sp-small">198.51.100.44</td><td>0</td><td>41</td><td><span class="tag is-warning is-light">none</span></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-7">
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Reports</h2></div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Received, UTC</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 04:40</td><td class="sp-muted">Outlook.com</td><td>20 Sep</td><td>1 903</td><td><span class="tag is-warning is-light">7</span></td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 02:15</td><td class="sp-muted">google.com</td><td>20 Sep</td><td>6 410</td><td>0</td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-20 04:38</td><td class="sp-muted">Outlook.com</td><td>19 Sep</td><td>1 877</td><td><span class="tag is-warning is-light">34</span></td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
|
||||
</tbody>
|
||||
</table></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>
|
||||
@@ -0,0 +1,119 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Outlook.com · 20 Sep · DMARC · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.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>
|
||||
<nav class="navbar is-primary" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
|
||||
<div class="navbar-menu is-active">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="overview.html">Overview</a>
|
||||
<div class="navbar-item has-dropdown is-hoverable sp-current">
|
||||
<a class="navbar-link" href="out-domains.html">Outbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
|
||||
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item is-active" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="in-domains.html">Inbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
|
||||
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
|
||||
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="health.html">Server</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
|
||||
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
|
||||
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
|
||||
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
|
||||
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
|
||||
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
|
||||
<div class="navbar-dropdown is-right">
|
||||
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
|
||||
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
|
||||
<hr class="navbar-divider">
|
||||
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sp-perf" aria-hidden="true"></div>
|
||||
<div class="sp-subnav"><div class="container"><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html" aria-current="page"><i class="ti ti-report-analytics"></i>DMARC reports</a></div></div>
|
||||
<main>
|
||||
<div class="container sp-page">
|
||||
<div class="sp-head">
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker"><a href="dmarc.html">DMARC reports</a> / <a href="dmarc-domain.html">shop.example.org</a> /</p>
|
||||
<h1 class="title is-4">Outlook.com · 20 Sep 2026</h1>
|
||||
<p class="sp-route"><span>shop.example.org</span><span class="tag is-success is-light">1 903 pass</span><span class="tag is-warning is-light">7 fail</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Report</h2><a class="sp-help" href="help.html#dmarc" title="What an aggregate report is"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Reporter</dt><dd>Outlook.com</dd></div>
|
||||
<div><dt>Report id</dt><dd class="sp-mono sp-small">5f1c9a0e7b2d4e61a3</dd></div>
|
||||
<div><dt>Window</dt><dd>20 Sep 00:00 – 21 Sep 00:00 UTC</dd></div>
|
||||
<div><dt>Received</dt><dd>2026-09-21 04:40 UTC</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Published policy</h2></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>p / sp / pct</dt><dd class="sp-mono">none / none / 100</dd></div>
|
||||
<div><dt>adkim / aspf</dt><dd class="sp-mono">r / r</dd></div>
|
||||
<div><dt>rua</dt><dd class="sp-mono sp-small">dmarc@mail.example.org</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Records</h2><div class="sp-end">Parsed from the aggregate XML</div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<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="sp-mono sp-small">203.0.113.25 <span class="tag is-light">this relay</span></td><td>1 903</td><td><span class="tag is-success is-light">none</span></td><td><span class="tag is-success is-light">pass</span></td><td><span class="tag is-success is-light">pass</span></td><td>shop.example.org</td></tr>
|
||||
<tr><td class="sp-mono sp-small">198.51.100.44</td><td>7</td><td><span class="tag is-warning is-light">none</span></td><td><span class="tag is-danger is-light">fail</span></td><td><span class="tag is-danger is-light">fail</span></td><td>shop.example.org</td></tr>
|
||||
</tbody>
|
||||
</table></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>
|
||||
@@ -0,0 +1,105 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>DMARC reports · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.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>
|
||||
<nav class="navbar is-primary" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
|
||||
<div class="navbar-menu is-active">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="overview.html">Overview</a>
|
||||
<div class="navbar-item has-dropdown is-hoverable sp-current">
|
||||
<a class="navbar-link" href="out-domains.html">Outbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
|
||||
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item is-active" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="in-domains.html">Inbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
|
||||
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
|
||||
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="health.html">Server</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
|
||||
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
|
||||
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
|
||||
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
|
||||
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
|
||||
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
|
||||
<div class="navbar-dropdown is-right">
|
||||
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
|
||||
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
|
||||
<hr class="navbar-divider">
|
||||
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sp-perf" aria-hidden="true"></div>
|
||||
<div class="sp-subnav"><div class="container"><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html" aria-current="page"><i class="ti ti-report-analytics"></i>DMARC reports</a></div></div>
|
||||
<main>
|
||||
<div class="container sp-page">
|
||||
<div class="sp-head">
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker">Outbound</p>
|
||||
<h1 class="title is-3">DMARC reports</h1>
|
||||
<p class="sp-lead">How receivers saw mail claiming to be from your domains. Aggregate (<code>rua=</code>) only; open a report for the parsed XML.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Ingest</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#dmarc" title="What the reports are and where they go"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<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 a domain or 90 days</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Recent reports</h2></div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Received, UTC</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 06:12</td><td><a href="dmarc-domain.html">example.org</a></td><td class="sp-muted">google.com</td><td>20 Sep</td><td>412</td><td>0</td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 04:40</td><td><a href="dmarc-domain.html">shop.example.org</a></td><td class="sp-muted">Outlook.com</td><td>20 Sep</td><td>1 903</td><td><span class="tag is-warning is-light">7</span></td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-20 23:58</td><td><a href="dmarc-domain.html">example.org</a></td><td class="sp-muted">Yahoo</td><td>19 Sep</td><td>38</td><td>0</td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-20 06:09</td><td><a href="dmarc-domain.html">notify.acme.io</a></td><td class="sp-muted">google.com</td><td>19 Sep</td><td>61</td><td>0</td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
|
||||
</tbody>
|
||||
</table></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>
|
||||
@@ -0,0 +1,178 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Health · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.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>
|
||||
<nav class="navbar is-primary" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
|
||||
<div class="navbar-menu is-active">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="overview.html">Overview</a>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="out-domains.html">Outbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
|
||||
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="in-domains.html">Inbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
|
||||
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
|
||||
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable sp-current">
|
||||
<a class="navbar-link" href="health.html">Server</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item is-active" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
|
||||
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
|
||||
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
|
||||
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
|
||||
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
|
||||
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
|
||||
<div class="navbar-dropdown is-right">
|
||||
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
|
||||
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
|
||||
<hr class="navbar-divider">
|
||||
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sp-perf" aria-hidden="true"></div>
|
||||
<div class="sp-subnav"><div class="container"><a href="health.html" aria-current="page"><i class="ti ti-heartbeat"></i>Health</a><a href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></div></div>
|
||||
<main>
|
||||
<div class="container sp-page">
|
||||
<div class="sp-head">
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker">Server</p>
|
||||
<h1 class="title is-3">Health</h1>
|
||||
<p class="sp-lead">Everything behind the six cards on Overview. Refreshes every 10 s.</p>
|
||||
</div>
|
||||
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form>
|
||||
</div>
|
||||
|
||||
<div class="columns sp-cards">
|
||||
<div class="column is-6">
|
||||
<div class="box" id="processes">
|
||||
<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>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 class="box" id="sender-check">
|
||||
<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>Messages deferred</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Today</td><td>0 s</td><td>0</td></tr>
|
||||
<tr><td>Last 30 days</td><td>4 s</td><td>0</td></tr>
|
||||
<tr><td>Last 12 months</td><td>41 s</td><td>3</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 defers mail instead of accepting it unchecked. Time is counted only while Postfix itself was running. Counted since <span class="sp-mono">2026-10-13</span>.</p>
|
||||
</div>
|
||||
</div>
|
||||
<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="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">
|
||||
<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>
|
||||
<tr><td>CPU</td><td><progress class="progress is-small is-success mb-1" value="12" max="100"></progress>12 %</td><td class="sp-muted">2 cores, load 0.21</td></tr>
|
||||
<tr><td>Memory</td><td><progress class="progress is-small is-success mb-1" value="41" max="100"></progress>41 %</td><td class="sp-muted">812 MB of 1.9 GB</td></tr>
|
||||
<tr><td>Network</td><td class="sp-nowrap">↓ 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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<div class="box" id="sockets">
|
||||
<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>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 class="box" id="certificate">
|
||||
<div class="sp-box-head"><span class="sp-no">04</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 class="box" id="notification-mail">
|
||||
<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">
|
||||
<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 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>
|
||||
</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>
|
||||
@@ -0,0 +1,161 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Help · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.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>
|
||||
<nav class="navbar is-primary" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
|
||||
<div class="navbar-menu is-active">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="overview.html">Overview</a>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="out-domains.html">Outbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
|
||||
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="in-domains.html">Inbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
|
||||
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
|
||||
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="health.html">Server</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
|
||||
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
|
||||
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
|
||||
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
|
||||
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
|
||||
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item has-dropdown is-hoverable sp-current">
|
||||
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
|
||||
<div class="navbar-dropdown is-right">
|
||||
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
|
||||
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
|
||||
<hr class="navbar-divider">
|
||||
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sp-perf" aria-hidden="true"></div>
|
||||
|
||||
<main>
|
||||
<div class="container sp-page">
|
||||
<div class="sp-head">
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker">Reference</p>
|
||||
<h1 class="title is-3">Help</h1>
|
||||
<p class="sp-lead">What each screen checks or does, and what to do when it is not green. The ? in a box's head opens its topic here.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-3">
|
||||
<aside class="menu sp-side">
|
||||
<p class="menu-label">Overview</p>
|
||||
<ul class="menu-list">
|
||||
<li><a class="is-active" href="#checks"><i class="ti ti-activity-heartbeat"></i>Health checks</a></li>
|
||||
<li><a href="#certificate"><i class="ti ti-certificate"></i>TLS certificate</a></li>
|
||||
<li><a href="#rdns"><i class="ti ti-arrows-exchange"></i>Reverse DNS</a></li>
|
||||
</ul>
|
||||
<p class="menu-label">Outbound</p>
|
||||
<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>
|
||||
<li><a href="#dmarc"><i class="ti ti-report-analytics"></i>DMARC reports</a></li>
|
||||
</ul>
|
||||
<p class="menu-label">Inbound</p>
|
||||
<ul class="menu-list">
|
||||
<li><a href="#inbound"><i class="ti ti-inbox"></i>Backup-MX and forwarding</a></li>
|
||||
<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">
|
||||
<li><a href="#account"><i class="ti ti-user-circle"></i>Your account</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="column is-9">
|
||||
<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="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 the DKIM signature is chosen by, 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>. It is a rejected row of the Outbound log, and its page says which address was refused. This is what keeps one application from getting mail signed with another domain's key.<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, and how many messages were deferred because of it. The card warns when a message was deferred 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, 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. 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="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="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 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 998 on the host (<code>chgrp 998</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. 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, 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>
|
||||
|
||||
<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>
|
||||
@@ -0,0 +1,101 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Delete lists.example.org · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.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>
|
||||
<nav class="navbar is-primary" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
|
||||
<div class="navbar-menu is-active">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="overview.html">Overview</a>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="out-domains.html">Outbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
|
||||
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable sp-current">
|
||||
<a class="navbar-link" href="in-domains.html">Inbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item is-active" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
|
||||
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
|
||||
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="health.html">Server</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
|
||||
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
|
||||
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
|
||||
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
|
||||
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
|
||||
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
|
||||
<div class="navbar-dropdown is-right">
|
||||
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
|
||||
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
|
||||
<hr class="navbar-divider">
|
||||
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sp-perf" aria-hidden="true"></div>
|
||||
<div class="sp-subnav"><div class="container"><a href="in-domains.html" aria-current="page"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></div></div>
|
||||
<main>
|
||||
<div class="container sp-page">
|
||||
<div class="sp-head">
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker"><a href="in-domains.html">Inbound domains</a> / <a href="in-domain.html">lists.example.org</a> /</p>
|
||||
<h1 class="title is-3">Delete lists.example.org</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-7">
|
||||
<div class="box sp-danger-zone">
|
||||
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-alert-triangle"></i></span><h2>This cannot be undone</h2></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="mb-3">Deleting the inbound domain <strong>lists.example.org</strong> will:</p>
|
||||
<ul class="sp-list-check">
|
||||
<li><i class="ti ti-x"></i>stop accepting mail for it on port 25 — senders get a relay-denied reply;</li>
|
||||
<li><i class="ti ti-x"></i>remove its upstream and its 4 listed recipients.</li>
|
||||
</ul>
|
||||
<p class="sp-muted mb-4">Remove or repoint the MX record first, or mail for the domain will bounce. Outbound domains are not touched.</p>
|
||||
<form method="post" action="in-domains.html">
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button is-danger"><i class="ti ti-trash"></i>Delete lists.example.org</button>
|
||||
<a class="button is-text" href="in-domain.html">Keep it</a>
|
||||
</div>
|
||||
</form>
|
||||
</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>
|
||||