Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f888dc7be | ||
|
|
5598b62e4e | ||
|
|
32f5085c48 | ||
|
|
5601f73622 | ||
|
|
e17b1680bd | ||
|
|
5b63da0e49 | ||
|
|
e9aaed1c7b | ||
|
|
1bd71c10d0 | ||
|
|
74acf09212 | ||
|
|
ba07decbb0 | ||
|
|
4fc5cff266 | ||
|
|
ac8c2af500 | ||
|
|
9e3f0d0fad | ||
|
|
403c76d17d | ||
|
|
d9ab7bfa45 | ||
|
|
18bfd148ec | ||
|
|
e70ba9046e | ||
|
|
02afa0fa80 | ||
|
|
97a0c7e508 | ||
|
|
efdc2d63fb | ||
|
|
8dfb483244 | ||
|
|
e6af1a7866 | ||
|
|
aab35f0679 | ||
|
|
0570608738 | ||
|
|
1cf8bfcbe2 | ||
|
|
4761991dd5 | ||
|
|
8355479e03 | ||
|
|
6216e430e9 | ||
|
|
a1e7e05d26 | ||
|
|
fae0068c4a | ||
|
|
b829625066 | ||
|
|
71048922b9 | ||
|
|
5caca024c3 | ||
|
|
8289ed3916 | ||
|
|
958d07fcdd | ||
|
|
f0cd804cb3 | ||
|
|
3950bd333b | ||
|
|
584f8cbc1c | ||
|
|
6671bd3393 | ||
|
|
00e36df553 | ||
|
|
b0ebe061b5 | ||
|
|
601e183e0c | ||
|
|
f9e259a66d | ||
|
|
44e79c4120 | ||
|
|
1d30605533 | ||
|
|
b87baa6dd6 | ||
|
|
9420e30d6f | ||
|
|
93cf1de3b7 | ||
|
|
6b8658602d | ||
|
|
3a520d9ec7 | ||
|
|
4015ecd70d | ||
|
|
ad014ecdc1 | ||
|
|
de6f7a88da | ||
|
|
5985977f21 | ||
|
|
9ff7514621 | ||
|
|
ae7f5258a6 | ||
|
|
5bd632c8e0 | ||
|
|
f1074eb526 | ||
|
|
bc005dea85 | ||
|
|
4b2f3880ef | ||
|
|
fae930a37a | ||
|
|
c443149623 | ||
|
|
5eaf665c01 | ||
|
|
7cc499d57c | ||
|
|
aeba3f8db1 | ||
|
|
d6e67ceb61 | ||
|
|
8ed20b0c98 | ||
|
|
bd7a3b123d | ||
|
|
669f928b4d | ||
|
|
f44f5333b3 | ||
|
|
652f1fe438 | ||
|
|
c9d726dd64 | ||
|
|
f59befdb00 | ||
|
|
15baa1e5d0 | ||
|
|
c9076655b9 | ||
|
|
e34d81498e | ||
|
|
155b721438 | ||
|
|
efaf016c5f | ||
|
|
2bdc0ea9a8 | ||
|
|
6c8bf0d3b3 | ||
|
|
c713f51f24 | ||
|
|
0ed36e9624 | ||
|
|
5178f80fdf | ||
|
|
10c41677bd | ||
|
|
01c754f857 | ||
|
|
691d3c255f | ||
|
|
295d1afb7d | ||
|
|
06c2014384 | ||
|
|
a4cfa11323 | ||
|
|
236cb07769 | ||
|
|
04993e0da3 | ||
|
|
7eb168f418 | ||
|
|
a1b6209470 | ||
|
|
44683a4996 | ||
|
|
870012514a | ||
|
|
012802d83d | ||
|
|
e163fe123d | ||
|
|
178424eaf6 | ||
|
|
18880a8286 | ||
|
|
1dd0aa9fea | ||
|
|
7c79c085e7 | ||
|
|
1f548dfc7a | ||
|
|
7e4ecf1191 | ||
|
|
1faf91055f | ||
|
|
d46920bb02 | ||
|
|
1a8603b887 | ||
|
|
0cf618438b | ||
|
|
977d16494b | ||
|
|
5a5b642eac | ||
|
|
ebb214b1ef | ||
|
|
43841790d4 | ||
|
|
cf7587754f | ||
|
|
d03ea43969 | ||
|
|
142c1defbb | ||
|
|
2c8c305c75 | ||
|
|
d502611123 | ||
|
|
256d370206 | ||
|
|
6e990cda01 | ||
|
|
997af18065 | ||
|
|
ef57d705d4 | ||
|
|
c4c1f12f3c | ||
|
|
9179818262 | ||
|
|
76f04f93d1 | ||
|
|
9745edd132 | ||
|
|
c0c66dfcdb | ||
|
|
76ad20efdf | ||
|
|
4118e1a8d5 | ||
|
|
0e29acb955 | ||
|
|
c0d9aa7518 | ||
|
|
a92d583053 | ||
|
|
0093878eea | ||
|
|
6a7d010868 | ||
|
|
670982fb3e | ||
|
|
ecc4184324 | ||
|
|
e93a277ee7 | ||
|
|
00983cce39 | ||
|
|
aaf0711326 | ||
|
|
60addd56c8 | ||
|
|
67875e51f3 | ||
|
|
baaed5991b | ||
|
|
7f24b2923c | ||
|
|
b40e14d65c | ||
|
|
865cf67966 | ||
|
|
2640ef4fd6 | ||
|
|
192ab5a1d5 | ||
|
|
0c7f2432f5 | ||
|
|
827fea6ea8 | ||
|
|
0cac2f5ca2 | ||
|
|
3cbdb48fd2 | ||
|
|
8c54bfd5bd | ||
|
|
d35b309714 | ||
|
|
669867cd2d | ||
|
|
50d2624985 | ||
|
|
08943caae1 | ||
|
|
38e9252697 | ||
|
|
64e2ea264c | ||
|
|
610f74d6a4 | ||
|
|
75606dfbe9 | ||
|
|
4003a299a6 | ||
|
|
5e8a330cf1 | ||
|
|
bd4b226953 | ||
|
|
9c1dc4050f | ||
|
|
419af4af95 | ||
|
|
888fc5cbf9 | ||
|
|
f113f44d65 | ||
|
|
e87a55b657 | ||
|
|
337c8e8f0e | ||
|
|
f5f4bd4551 | ||
|
|
e6910c2c2d | ||
|
|
6cf8b7b631 | ||
|
|
a6df2ebceb | ||
|
|
f3bc24b638 | ||
|
|
3eb3e94bce | ||
|
|
db1572d7ad | ||
|
|
538a4b6603 | ||
|
|
db6abaefc7 | ||
|
|
cfd546000a | ||
|
|
e8605e68fa | ||
|
|
de4cc26cdb | ||
|
|
7fd7b1f1de | ||
|
|
25cc34426c | ||
|
|
e51917fcb5 | ||
|
|
934972ce48 | ||
|
|
6c61d53239 | ||
|
|
cdb018d9f1 | ||
|
|
6ff353b94a | ||
|
|
14b4933917 | ||
|
|
7a09e62bf1 | ||
|
|
75e9fef037 | ||
|
|
23ecc5d5af | ||
|
|
c938ed20f8 | ||
|
|
51e20ffc22 | ||
|
|
7b4549a35d | ||
|
|
fc53ae1314 | ||
|
|
558c059341 | ||
|
|
55fe38ba5a | ||
|
|
9dd2b47e6f | ||
|
|
6fe333204b | ||
|
|
ead0638ee2 | ||
|
|
dca83e9671 | ||
|
|
ee8d5f65d9 | ||
|
|
4adbde1948 | ||
|
|
d82d9736bd | ||
|
|
278a88d1e1 | ||
|
|
9dc192d495 | ||
|
|
cc01e44ab8 | ||
|
|
fe2844441b | ||
|
|
e8b558eb3b | ||
|
|
65a420d230 | ||
|
|
61f525e2d7 | ||
|
|
f88d8dabcb | ||
|
|
56a4fa892d | ||
|
|
cb25923a7b | ||
|
|
ab25d24706 | ||
|
|
745f1c03d2 | ||
|
|
6ebb6f56d6 | ||
|
|
4f1f7761a2 | ||
|
|
49ab29b1ce | ||
|
|
2c7f0da3d8 | ||
|
|
c6eeb30258 | ||
|
|
a7a5ad3f91 | ||
|
|
048be22ded | ||
|
|
d72a383904 | ||
|
|
85f3374ce1 | ||
|
|
e6aceeb811 | ||
|
|
cc2d9d43c0 | ||
|
|
b39d1d0302 | ||
|
|
9c31649941 | ||
|
|
75d0329569 | ||
|
|
68c710e99a | ||
|
|
8ad56ed739 |
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "0.0.1",
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"name": "mockups",
|
|
||||||
"runtimeExecutable": "python",
|
|
||||||
"runtimeArgs": ["-m", "http.server", "8765", "--bind", "127.0.0.1"],
|
|
||||||
"port": 8765
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
---
|
|
||||||
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).
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
---
|
|
||||||
description: Panel UI mockups use the design system primitives, not a reskin of live templates.
|
|
||||||
globs: docs/assets/panel-ui/**
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# Panel UI system
|
|
||||||
|
|
||||||
Source of truth: [docs/assets/panel-ui/system.html](docs/assets/panel-ui/system.html) and `system.css`.
|
|
||||||
|
|
||||||
Do not copy `internal/web/view/templates` into mockups. Do not mark `<main>` as `ops`/`form` to pick width. Do not fix empty columns, Host/Type height, or split Save/Delete with one-off CSS.
|
|
||||||
|
|
||||||
Compose screens from `stack`, `pair`, `measure`, `fill`, `field-row`, `actions-row`:
|
|
||||||
- Two peer jobs → `pair` (one child shrinks to `measure`).
|
|
||||||
- Tables/logs/DNS → `fill` (nowrap + overflow-x on the card). Add-to-list is a `measure` row inside that card, not a second card above it.
|
|
||||||
- Confirm/login/user form → `measure`.
|
|
||||||
- Host ‖ Type and two equal inputs → `field-row` (shared grid row, not two `.code` paddings).
|
|
||||||
- Save + Delete → `actions-row` inside the card; never submit inside `<form>` and danger after `</form>`.
|
|
||||||
- Danger zone is the second column of the last `pair`, never a full-width `fill`.
|
|
||||||
- Card chrome is title + optional `?` + body + actions. Help is not sprinkled later.
|
|
||||||
- Phone: `pair` stacks; `field-row` does not; tables become lists.
|
|
||||||
- One screen is one HTML file. Shared chrome is `shell.js`. Do not put every screen in one hash-SPA.
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
#!/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
|
name: release
|
||||||
|
|
||||||
# Publishes an immutable, version-tagged image on ghcr.io.
|
# Publishes an immutable, version-tagged image on ghcr.io (spec 10.1).
|
||||||
# Ordinary commits and bare git tag pushes do not publish anything. A published
|
# 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).
|
# 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.
|
# 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
|
# Native per-architecture builds (see docs/development.md), not qemu:
|
||||||
# the full Postfix/OpenDKIM stack under emulation for the e2e gate below is
|
# running the full Postfix/OpenDKIM stack under emulation for the e2e gate
|
||||||
# impractically slow. Each arch builds, e2e-gates and pushes its own tag on its
|
# below is impractically slow. Each arch builds, e2e-gates and pushes its own
|
||||||
# own native runner; a merge job then combines them into the one manifest tag
|
# tag on its own native runner; a merge job then combines them into the one
|
||||||
# documented in deploy/docker-compose.yml. "test, then push" (not push-by-digest
|
# manifest tag documented in deploy/docker-compose.yml. "test, then push" (not
|
||||||
# then test) is deliberate: it means the bytes that get tagged are exactly the
|
# push-by-digest then test) is deliberate: it means the bytes that get tagged
|
||||||
# bytes that passed e2e. The per-arch tags stay in GHCR next to the version
|
# are exactly the bytes that passed e2e. Per-arch tags are pushed only so
|
||||||
# tag: they are the images the multi-arch manifest points at, and GHCR cannot
|
# imagetools can assemble the multi-arch manifest; merge removes them from GHCR
|
||||||
# drop a tag without deleting the image behind it.
|
# so operators see a single version tag (spec 10.1).
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [published]
|
||||||
@@ -177,35 +177,28 @@ jobs:
|
|||||||
backoff=$((backoff * 2))
|
backoff=$((backoff * 2))
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Remove orphaned untagged versions from GHCR
|
- name: Remove per-arch tags from GHCR
|
||||||
# Re-publishing a version moves its tags to new manifests and leaves the
|
# Side-effect tags for imagetools assembly only — not part of the public
|
||||||
# old ones behind as untagged "sha256:..." package versions. Delete only
|
# version surface (deploy/docker-compose.yml pins X.Y.Z, not X.Y.Z-amd64).
|
||||||
# those that no tagged manifest references: the images behind a
|
# imagetools has no "rm" subcommand; delete via the GitHub Packages API.
|
||||||
# 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:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
image="ghcr.io/${{ github.repository }}"
|
version="${{ needs.prepare.outputs.version }}"
|
||||||
owner="${{ github.repository_owner }}"
|
owner="${{ github.repository_owner }}"
|
||||||
pkg="${{ github.event.repository.name }}"
|
pkg="${{ github.event.repository.name }}"
|
||||||
api="/users/${owner}/packages/container/${pkg}/versions"
|
api="/users/${owner}/packages/container/${pkg}/versions"
|
||||||
versions=$(gh api "${api}?per_page=100" --paginate \
|
for suffix in amd64 arm64; do
|
||||||
--jq '.[] | [.id, .name, (.metadata.container.tags | length)] | @tsv')
|
tag="${version}-${suffix}"
|
||||||
keep=$(mktemp)
|
mapfile -t ids < <(gh api "$api" --paginate \
|
||||||
while IFS=$'\t' read -r id digest ntags; do
|
--jq ".[] | select([.metadata.container.tags[]] | index(\"${tag}\")) | .id")
|
||||||
[ "$ntags" -gt 0 ] || continue
|
if [ "${#ids[@]}" -eq 0 ]; then
|
||||||
echo "$digest" >> "$keep"
|
echo "no GHCR package version for tag ${tag}"
|
||||||
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
|
continue
|
||||||
fi
|
fi
|
||||||
echo "deleting orphaned GHCR package version ${id} (${digest})"
|
for id in "${ids[@]}"; do
|
||||||
gh api -X DELETE "${api}/${id}"
|
echo "deleting GHCR package version ${id} (tag ${tag})"
|
||||||
done <<<"$versions"
|
gh api -X DELETE "${api}/${id}"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|||||||
@@ -10,15 +10,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -5,881 +5,6 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [2.0.0] - 2026-10-11
|
|
||||||
|
|
||||||
The panel redesign. A breaking release: every panel URL changed and the
|
|
||||||
database starts from zero - a 1.x `/data` is not upgraded (see Changed and
|
|
||||||
Removed).
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- panel-redesign (stage 0): Bulma 1.0.4 and a 57-icon subset of Tabler Icons
|
|
||||||
3.49.0 are vendored under `/static` (MIT, listed in NOTICE). Nothing links
|
|
||||||
them yet; the old panel is unchanged.
|
|
||||||
- panel-redesign (stage 0): the design contract as guard tests
|
|
||||||
(`guard_*_test.go` under `internal/web`) and a `design-first` CI step. The
|
|
||||||
tests read their rules from the accepted mockups — class vocabulary,
|
|
||||||
stylesheet, page structure, component outlines, the 2.0 route table — and
|
|
||||||
pin the vendored assets. `internal/web/view/legacy_pages.txt` lists what is
|
|
||||||
still pre-2.0 and may only shrink; CI also rejects a commit that changes a
|
|
||||||
guard together with the implementation, or brings back a template from
|
|
||||||
history. The kit and route tests stay skipped until their stages land.
|
|
||||||
- panel-redesign (stage 0): the component kit. `panel.css` is the mockups'
|
|
||||||
stylesheet rule for rule; `components.html` holds every partial with its
|
|
||||||
typed input in `components.go`; the new `layout.html` is the shell (brick
|
|
||||||
navbar with the wordmark, perforated edge, sibling strip, user menu,
|
|
||||||
footer) and the signed-out screen; `GET /server/components` (global role)
|
|
||||||
renders every partial in every state. `@kit` is off the ratchet: the kit
|
|
||||||
is held to the design contract from here on.
|
|
||||||
- panel-redesign (design): Sign out in the user menu is a POST form in the
|
|
||||||
mockups too, and its button takes the same hover, focus and press tint as
|
|
||||||
Account and Help (owner, 2026-10-09).
|
|
||||||
|
|
||||||
- panel-redesign (stage 1): the 2.0 schema. `users.email` is the user's own
|
|
||||||
address; the default DMARC report address is a user's setting
|
|
||||||
(`dmarc_default_mode` / `dmarc_default_address`) and a domain either has an
|
|
||||||
address of its own or follows a named user's default
|
|
||||||
(`domains.dmarc_rua_user_id`) — a new domain follows whoever created it,
|
|
||||||
and deleting that user leaves it with no report address (replaced before
|
|
||||||
release — see the entry of 2026-10-10 below).
|
|
||||||
`user_inbound_domains` and the per-user `all_domains` /
|
|
||||||
`all_inbound_domains` flags are in the schema for the inbound delegation
|
|
||||||
that follows. Details: `docs/schema-migrations.md`.
|
|
||||||
- panel-redesign (stage 1): inbound domains are delegated to domain
|
|
||||||
administrators, separately from outbound. The user form has two lists, each
|
|
||||||
with *All* (which includes domains added later; with it ticked the rows
|
|
||||||
under it are ignored); a domain administrator needs at least one assignment
|
|
||||||
on either. Such a user sees the inbound domains assigned to them — the list
|
|
||||||
is filtered in the query — and can change their upstream and recipients;
|
|
||||||
adding and deleting an inbound domain stays with the global role. Another
|
|
||||||
tenant's domain, a missing one and a user with no inbound reach all get the
|
|
||||||
same 404. A menu group with nothing assigned is absent, and `/` leads an
|
|
||||||
inbound-only administrator to Inbound.
|
|
||||||
- panel-redesign (stage 1): **Account** (`/account`, every role) and
|
|
||||||
**Server › Settings** (`/server/settings`, global role) replace the one
|
|
||||||
Settings page. Account has three forms, each its own POST: profile
|
|
||||||
(username, e-mail), password, and the default DMARC report address
|
|
||||||
(SelfPost hosted, my account e-mail, another address, none) (replaced before
|
|
||||||
release — see the entry of 2026-10-10 below). Saving the profile no longer
|
|
||||||
asks for the password; changing the password still does.
|
|
||||||
- panel-redesign (stage 1): the application form is one POST. Who the
|
|
||||||
application may send as, its client-IP allow-list and its rate limit
|
|
||||||
("use the domain limit", manual or auto) are validated together and saved
|
|
||||||
together or not at all — also at creation, so an application can be added
|
|
||||||
with its limit already in place. The page that shows a generated password
|
|
||||||
is the response to its POST only and is sent with `Cache-Control: no-store`.
|
|
||||||
- panel-redesign (stage 1): **Server › Health** (`/server/health`) holds the
|
|
||||||
tables of the old Status page and its two actions, *Re-check DNS* and
|
|
||||||
*Reload configuration*. **Overview** (`/overview`) is reduced to one
|
|
||||||
verdict per check — machine, processes, TLS certificate, queue, milter
|
|
||||||
sockets, reverse DNS — each leading to its table on Health; a check that
|
|
||||||
could not run shows as a warning, never as fine. Both read the same checks.
|
|
||||||
- panel-redesign (stage 2): **Sign in** and **Create administrator** are the
|
|
||||||
first pages in the new design - the split brand / form screen, built from
|
|
||||||
the component kit. Sign in names the mail host above its title; a refused
|
|
||||||
attempt is shown as a notice above the fields. `login` and `setup` are off
|
|
||||||
the ratchet.
|
|
||||||
- panel-redesign (design): the sign-in page before any administrator exists
|
|
||||||
is a drawn screen (`login-no-admin`): the heading *No administrator yet* and
|
|
||||||
the pointer to the one-time setup link, in the same column as the form. The
|
|
||||||
signed-out column is sized through `.sp-form`, not through `form` (owner,
|
|
||||||
2026-10-09).
|
|
||||||
- panel-redesign (design): the Notifications boxes are out of the Account
|
|
||||||
and Server › Settings mockups. Event notifications are not built
|
|
||||||
(panel-notifications is a candidate), and 2.0 carries no box for a feature
|
|
||||||
that does not exist; they are drawn again as the first step of that
|
|
||||||
feature (owner, 2026-10-10).
|
|
||||||
- panel-redesign (stage 2): **Overview**, **Server › Health**, **Account**
|
|
||||||
and **Server › Settings** are in the new design. Overview shows the verdict
|
|
||||||
as a stamp with one sentence, the six checks as cards and both domain
|
|
||||||
lists; Health holds the tables behind the cards. Both refresh themselves.
|
|
||||||
The Queue card reads "Empty" or "N queued". `status` and `settings` are
|
|
||||||
off the ratchet.
|
|
||||||
- panel-redesign (stage 2): the **Outbound** domain pages are in the new
|
|
||||||
design: the domain list, the domain page (DNS records with what to
|
|
||||||
publish and what is in DNS now, applications, connection), **Domain
|
|
||||||
settings** as a page of its own (report address, rate limit, export,
|
|
||||||
delete), the application form, the password shown once and the delete
|
|
||||||
confirmation. `dashboard`, `domain_detail` and `domain_delete` are off
|
|
||||||
the ratchet.
|
|
||||||
- panel-redesign (stage 2): the **Outbound log**, the message page (facts,
|
|
||||||
history, the lines of `mail.log`) and the three **DMARC report** pages are
|
|
||||||
in the new design. The log refreshes its rows without touching the filter
|
|
||||||
being chosen. `deliveries`, `delivery`, `dmarc`, `dmarc_domain` and
|
|
||||||
`dmarc_report` are off the ratchet.
|
|
||||||
- panel-redesign (stage 2): the **Inbound** pages are in the new design: the
|
|
||||||
domain list, the domain page (MX record, upstream, valid recipients) and
|
|
||||||
the delete confirmation. Every inbound domain page has a **Spam filter**
|
|
||||||
box and the list names the filter's state: the panel reads
|
|
||||||
`INBOUND_ANTISPAM_MILTER` and `INBOUND_ANTISPAM_MILTER_ACTION` and says
|
|
||||||
whether inbound mail is filtered and what happens while the filter is
|
|
||||||
down. The filter's address is shown to the global role only. `inbound`,
|
|
||||||
`inbound_domain` and `inbound_delete` are off the ratchet.
|
|
||||||
- panel-redesign (stage 2): the **Server** pages - System log, Backup,
|
|
||||||
Users, the user form and its delete confirmation - and **Help** are in the
|
|
||||||
new design. Help is a page of topics that the `?` in a box's head opens;
|
|
||||||
the help drawer is gone. The user form sets the user's e-mail. The backup
|
|
||||||
download is encrypted unless the box is unticked. Only the queue page is
|
|
||||||
left on the ratchet.
|
|
||||||
- panel-redesign (design, stage 3): **Help** covers every section -
|
|
||||||
seventeen topics for Overview, Outbound, Inbound, Server and Account - and
|
|
||||||
the boxes of nearly every page carry a `?` that opens their topic (owner,
|
|
||||||
2026-10-10). A reader sees the topics of the sections their menu shows.
|
|
||||||
- panel-redesign (design, stage 3): **a domain's DMARC report address is one
|
|
||||||
of three choices** - no reports (a new domain), the SelfPost hosted
|
|
||||||
address, or an address typed for the domain - set in Domain settings, with
|
|
||||||
a button that fills in one's own profile e-mail. The per-user default
|
|
||||||
report address, a domain "following" a user and the DMARC box on Account
|
|
||||||
are gone, and with them `users.dmarc_default_mode`,
|
|
||||||
`users.dmarc_default_address` and `domains.dmarc_rua_user_id` (owner,
|
|
||||||
2026-10-10; replaces the design of 2026-09-21 described above).
|
|
||||||
- panel-redesign (stage 3): with the inbound spam filter on, its socket is one
|
|
||||||
of the milter sockets on **Server › Health** and counts in Overview's card.
|
|
||||||
The panel connects to the configured address (`inet:host:port` or
|
|
||||||
`unix:/path`, one second at most, nothing sent). A filter that does not
|
|
||||||
answer is an error with `INBOUND_ANTISPAM_MILTER_ACTION=tempfail` - inbound
|
|
||||||
mail is deferred - and a warning otherwise - mail goes through unfiltered.
|
|
||||||
- **Port 25 no longer accepts `dmarc-reports@<hostname>`**, the report
|
|
||||||
mailbox of the server as a whole. Only an address a domain has chosen is
|
|
||||||
accepted: its hosted address `dmarc-reports+<domain>@<hostname>`, or a
|
|
||||||
typed address that is on this host. A domain whose published `rua=` still
|
|
||||||
names the general mailbox must be set to *SelfPost hosted* and its DMARC
|
|
||||||
record republished (owner, 2026-10-10).
|
|
||||||
- panel-redesign (design, stage 3): **New password** and **Delete** are in
|
|
||||||
each row of a domain's Applications table again, as POST forms that look
|
|
||||||
like the links beside them.
|
|
||||||
- panel-redesign (stage 2): the **Outbound queue** page is in the new shell
|
|
||||||
with its content unchanged - the listing of `postqueue -p` and how
|
|
||||||
retries work. With it every page is built from the component kit: the old
|
|
||||||
stylesheet, the old layout and the engine's second rendering path are
|
|
||||||
deleted, and the ratchet is empty.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- **Breaking — every panel URL changed.** A path now reads like the menu
|
|
||||||
(`/overview`, `/outbound/…`, `/inbound/…`, `/server/…`, `/account`); the
|
|
||||||
old paths are removed, not redirected — see *Removed*. Polled fragments all
|
|
||||||
answer `<page>/fragment`. Everything under `/server/` is guarded for the
|
|
||||||
global role where the routes are registered, in addition to each handler's
|
|
||||||
own check. An application is addressed under its domain
|
|
||||||
(`/outbound/domains/{id}/applications/{aid}`), and an application id that
|
|
||||||
does not belong to the domain in the path is a 404.
|
|
||||||
- **Breaking — the database starts from zero.** The 1.x migration chain
|
|
||||||
(`0001`–`0010`) is replaced by one baseline, `0001_init.sql`. A 1.x
|
|
||||||
`/data/selfpost.db` is refused at start-up with an explicit error and left
|
|
||||||
untouched; nothing is migrated. A domain can be carried over with its
|
|
||||||
export file.
|
|
||||||
- The panel role `domain_admin` is stored as `domain`.
|
|
||||||
- On the old domain page, "Same as Settings" makes the domain follow the
|
|
||||||
default report address of the user who chose it (replaced before release —
|
|
||||||
see the entry of 2026-10-10 below).
|
|
||||||
- The old panel is untouched on screen but steps aside in the tree: its
|
|
||||||
stylesheet is `legacy.css` and its layout `layout_legacy.html` until the
|
|
||||||
last old page is restyled. The 2.0 menu paths in the new shell do not
|
|
||||||
exist before stage 1; only the kit page uses that shell so far (a stage-0
|
|
||||||
state: both files are deleted and every page is on the shell — see
|
|
||||||
*Removed*).
|
|
||||||
- panel-redesign is `agreed` (owner, 2026-10-09); work starts with stage 0,
|
|
||||||
the component kit.
|
|
||||||
- panel-redesign (stage 3): the operator guide, `architecture.md` (Panel HTTP
|
|
||||||
surface, Persistence) and `security.md` (roles, sessions, errors shown to
|
|
||||||
users) describe the 2.0 panel. `security.md` records two accepted risks from
|
|
||||||
the 2.0 review: a delegated inbound upstream may be any host and port, and
|
|
||||||
Account's profile is saved without the password.
|
|
||||||
|
|
||||||
### Security
|
|
||||||
|
|
||||||
- A panel session belongs to a user by id and ends with them. It used to be
|
|
||||||
keyed by user name: the cookie of a deleted or renamed user stayed valid
|
|
||||||
and became a session of whoever was next created under that name. Signing
|
|
||||||
in again is required after upgrading (there is no upgrade from 1.x; this
|
|
||||||
concerns development builds of 2.0).
|
|
||||||
- Changing one's password ends one's own other sessions only; it used to
|
|
||||||
sign every other user out. A password set by an administrator on the user
|
|
||||||
form ends that user's sessions.
|
|
||||||
- Internal errors (database, `saslpasswd2`, Postfix reload) are no longer
|
|
||||||
shown on the application, inbound upstream / recipients and limit forms;
|
|
||||||
they are logged and the page says so.
|
|
||||||
- `/static/` no longer answers with a listing of the embedded files.
|
|
||||||
- The DMARC reports hub is open to a domain administrator for the domains
|
|
||||||
assigned to them, without the server's ingest statistics; the list of
|
|
||||||
reports no longer treats "no domains" as "all domains".
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Saving a domain's report-address form untouched no longer moves a domain
|
|
||||||
that follows another user's default to whoever saved it: that user's
|
|
||||||
default is its own choice, and "My default instead" is the change
|
|
||||||
(replaced before release — see the entry of 2026-10-10 below).
|
|
||||||
- The auto rate-limit fields follow the limit mode on the domain and
|
|
||||||
application forms (the script applied only the first matching rule).
|
|
||||||
- The DMARC page of a domain no longer answers 500 once the domain has a
|
|
||||||
reported source.
|
|
||||||
- **The panel can read the mail queue.** The container's entrypoint left the
|
|
||||||
queue's `public/` and `maildrop/` directories in group `postfix` on every
|
|
||||||
start; Postfix keeps them in `postdrop`, the group `postqueue` works
|
|
||||||
through, so the panel's `postqueue -p` was refused ("Connect to the Postfix
|
|
||||||
showq service: Permission denied"), the Queue card said "Could not be read"
|
|
||||||
and the queue page had no listing. Present since the persistent queue of
|
|
||||||
1.3.0; mail flow was not affected. The e2e suite opens the queue page now.
|
|
||||||
- **Port 25 answers again with DMARC report ingest on.** The generated
|
|
||||||
`master.cf` gave the port-25 service `-o smtpd_recipient_restrictions=
|
|
||||||
check_recipient_access texthash:…,…` with a space in the value; Postfix
|
|
||||||
cuts a service line at whitespace, so `smtpd` was started with a stray
|
|
||||||
argument and died with "fatal: unexpected command-line argument" on every
|
|
||||||
connection - no inbound relay and no DMARC reports were accepted while
|
|
||||||
`DMARC_REPORTS_ENABLE=true`, although `postfix check` and the suite were
|
|
||||||
silent. The list is written with commas only, the generator refuses a
|
|
||||||
value with whitespace, and the e2e check asks Postfix what it reads.
|
|
||||||
Present since DMARC ingest was fitted into the 1.9.1 fix of the same
|
|
||||||
stanza.
|
|
||||||
- **DMARC reports are accepted with the inbound relay on.** With both
|
|
||||||
features enabled every report address was refused as "User unknown in
|
|
||||||
relay recipient table": the relay's unlisted-recipient check runs after the
|
|
||||||
restriction list, and the report addresses - on the server's own name, a
|
|
||||||
relay domain - were not in `relay_recipient_maps`. Their table is a
|
|
||||||
recipient table now. Hidden until now by the port-25 failure above.
|
|
||||||
- **DMARC reports reach the panel.** A report accepted on port 25 stayed in
|
|
||||||
the queue: the generated delivery service was a bare `pipe` ("fatal:
|
|
||||||
missing user= command-line attribute"), because the generator addressed it
|
|
||||||
under a type Postfix does not have and set `flags=`, `user=` and `argv=`
|
|
||||||
as `-o` parameters, which they are not. Behind that, the command would
|
|
||||||
have refused to run (Postfix starts it without the container's
|
|
||||||
environment) and was never told the recipient, so the check that a report
|
|
||||||
came to the address of its own domain was skipped. The service line
|
|
||||||
carries its arguments, the recipient is one of them, and
|
|
||||||
`export_environment` names what the command reads. No report had been
|
|
||||||
delivered since the feature shipped in 1.7.0; the e2e check asks Postfix
|
|
||||||
how it reads the service.
|
|
||||||
- Postfix no longer logs "warning: not owned by root" for the queue
|
|
||||||
directory and its `pid/` at every start: the entrypoint hands the two back
|
|
||||||
to root after its recursive change of owner.
|
|
||||||
- Overview's Processes card counts the programs that are meant to stay up
|
|
||||||
("6 of 6 running"); the idle one-shot `postfix-reload` was counted as
|
|
||||||
running. The Milter sockets card no longer clips its second line with three
|
|
||||||
sockets, and a DKIM value is shown whole instead of scrolling inside its
|
|
||||||
field.
|
|
||||||
- Server › Health shows the idle reload helper (`postfix-reload`) as
|
|
||||||
**on demand**, with when it last ran, instead of `stopped` beside the
|
|
||||||
running programs (owner, 2026-10-10).
|
|
||||||
- The confirmation before deleting a user names the domains that follow
|
|
||||||
that user's default report address and will be left without one; it used
|
|
||||||
to say the domains were not affected (replaced before release — see the
|
|
||||||
entry of 2026-10-10 below).
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- **2.0 starts from an empty data directory.** A 1.x `/data` is not upgraded:
|
|
||||||
its database is refused at start and left untouched, and a 1.x backup is not
|
|
||||||
restored; a domain is carried over with its export file. Gone with it, as
|
|
||||||
built: the 1.x migration chain, every pre-2.0 panel path (listed next), the
|
|
||||||
help drawer (Help is a page of topics), the default DMARC report address —
|
|
||||||
the panel-wide one of 1.x (`dmarc_report_email`, "Same as Settings") and the
|
|
||||||
per-user one with a domain following a user that earlier entries of this
|
|
||||||
release describe (`users.dmarc_default_*`, `domains.dmarc_rua_user_id`) —
|
|
||||||
together with the DMARC box on Account, the old stylesheet `legacy.css`, the
|
|
||||||
old layout `layout_legacy.html`, and the unused `rate_limits.allowed_ips`
|
|
||||||
column.
|
|
||||||
- Old panel paths, GET and POST alike, without redirects: `/status`,
|
|
||||||
`/status/fragment`, `/status/recheck`, `/reload`, `/domains` and everything
|
|
||||||
under it (`/domains/import`, `/domains/{id}`, `…/dns-recheck`, `…/delete`,
|
|
||||||
`…/ratelimit`, `…/ratelimit/recalc`, `…/dmarc`, `…/export`), `/inbound` and
|
|
||||||
`/inbound/{id}/…`, `/dmarc`, `/dmarc/reports/{id}`, `/dmarc/domains/{id}`,
|
|
||||||
`/deliveries`, `/deliveries/rows`, `/deliveries/{id}`, `/mail-queue`,
|
|
||||||
`/mail-queue/body`, `/system-log`, `/system-log/body`, `/backup`, `/users`
|
|
||||||
and everything under it, `/settings`, and the `/account` → `/settings`
|
|
||||||
redirect; `POST /domains/{id}/applications` and `/applications/{aid}/mode`,
|
|
||||||
`/authips`, `/ratelimit`, `/ratelimit/recalc`, `/password`, `/delete` — the
|
|
||||||
three separate saves of an application are one form now.
|
|
||||||
- The instance setting `dmarc_report_email` (a mirror of the global user's
|
|
||||||
profile field) and the unused `rate_limits.allowed_ips` column.
|
|
||||||
|
|
||||||
## [1.9.5] - 2026-10-09
|
|
||||||
|
|
||||||
CI-only cut: the release workflow no longer breaks the multi-arch image it
|
|
||||||
just published. No product code changes.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- ci (GHCR): the release no longer deletes the `X.Y.Z-amd64` / `X.Y.Z-arm64`
|
|
||||||
package versions after the manifest merge. Those versions are the images the
|
|
||||||
`X.Y.Z` manifest points at, so `docker pull ghcr.io/mixeme/selfpost:1.3.0`
|
|
||||||
failed with a missing manifest. The per-arch tags now stay; the merge job
|
|
||||||
instead removes untagged versions that no tagged manifest references.
|
|
||||||
|
|
||||||
## [1.9.4] - 2026-10-09
|
|
||||||
|
|
||||||
Plan-only cut, owner-requested: the redesign goes first as `2.0.0` with a
|
|
||||||
component-kit stage and guards against resurrected pages; antispam moves to
|
|
||||||
`2.1.0`; a new candidate explains the empty delivery log. No product code
|
|
||||||
changes.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Roadmap order (owner, 2026-10-09): the panel redesign ships first as
|
|
||||||
`2.0.0`; inbound-antispam-panel follows as `2.1.0` on the 2.0 baseline
|
|
||||||
(its migration becomes `0002_inbound_spam_log.sql`), built from the
|
|
||||||
redesign's component kit so its two screens are styled once. The redesign
|
|
||||||
plan gains a **Component kit** section and stage (partials with typed
|
|
||||||
inputs, one `panel.css`, a `/server/components` kit page, acceptance before
|
|
||||||
any page is restyled), a staged checklist (5/27), Health decided inside 2.0
|
|
||||||
and the queue pages left as old content in the new shell, and an **Against
|
|
||||||
resurrected pages** section: a shrink-only `legacyPages` ratchet in the
|
|
||||||
guard tests, the outline golden, the old-path test and a history-blob
|
|
||||||
check in CI, so an old template from git history cannot pass as a
|
|
||||||
redesigned page.
|
|
||||||
- New roadmap candidate `delivery-log-storage`: the *Delivery log* box on a
|
|
||||||
message's page is empty for anything older than the last daily rotation,
|
|
||||||
because the lines are grepped from the current `mail.log` on each view and
|
|
||||||
never stored; the item proposes keeping them with the send-log row.
|
|
||||||
|
|
||||||
## [1.9.3] - 2026-09-22
|
|
||||||
|
|
||||||
Panel-redesign prep, docs only: stage 1-2 mockups on all 33 screens, the
|
|
||||||
accepted header/type-scale/contrast corrections, the account e-mail and
|
|
||||||
inbound-delegation decisions, and the routes/quarantine/preflight roadmap
|
|
||||||
screens. Nothing in `internal/web` changes; the design stays `candidate`
|
|
||||||
(3/21) until the redesign itself ships as `2.0.0`.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Panel redesign, account e-mail and notifications: a user's e-mail is its own
|
|
||||||
field (2.0 baseline `users.email`, today `dmarc_report_email`) — edited under
|
|
||||||
**Account**, seen and set by the global administrator on the user form and in
|
|
||||||
the users list; DMARC may *use* it, as one choice beside the hosted address,
|
|
||||||
another address or none (Settings and domain settings). **Account** gains a
|
|
||||||
Notifications box and **Settings** a switch for the new roadmap candidate
|
|
||||||
`panel-notifications`; `password-reset` is added as a candidate too. The
|
|
||||||
inbound spam filter becomes visible: a *Spam filter* box on every inbound
|
|
||||||
domain, a tag on the inbound domains list, its socket among the milter
|
|
||||||
sockets. Preflight loses a box that only filled space — the test form now
|
|
||||||
shares its box with its result — and the plan's layout rule and contract say
|
|
||||||
so: what stands beside a form must be real. The role is labelled by its reach,
|
|
||||||
`global` or `domain` — everyone in the panel is an administrator — and each
|
|
||||||
assignment list on the user form gains **All**, a per-user flag that covers
|
|
||||||
domains added later. The DMARC default report address is a **user's**
|
|
||||||
setting, not the server's: it moves from Server › Settings to **Account**
|
|
||||||
with its report-authorization record, and a domain follows the default of a
|
|
||||||
*named* user, so two people sharing a domain never pull it two ways.
|
|
||||||
- Panel redesign, contrast: a sweep of every text node against its real ground
|
|
||||||
found the solid red button at 3:1 (Bulma derives a solid button's text from
|
|
||||||
the fill's lightness; brick had been patched, red had not), the muted grey
|
|
||||||
at 4.1:1, the amber postmark at 4.0 and Bulma's menu labels at 4.2. Solid
|
|
||||||
buttons are white on colour, muted is `#5c6774`, the postmark uses the warn
|
|
||||||
token; all 33 screens now measure 4.5:1 or better. The sweep is kept as
|
|
||||||
`panel/audit.js` — text size, contrast, overflow, clipped values, missing
|
|
||||||
icons — and its clean output joins the evidence a restyle step must show.
|
|
||||||
- Panel redesign, header (owner's choice: variant A, with the perforation on a
|
|
||||||
light edge as on the mark): the navbar carries the **wordmark** instead of
|
|
||||||
the stamp — `docs/assets/selfpost-wordmark.svg` and `-ink`, the stamp's own
|
|
||||||
outlines, recorded in `docs/product.md` — and under the brick bar runs the
|
|
||||||
stamp's paper margin with the perforation along its outer edge: round holes,
|
|
||||||
flat teeth, the ink hairline (`panel/perf-edge.svg`). All 33 screens rebuilt;
|
|
||||||
the components page documents the shell; the comparison sheet is removed.
|
|
||||||
The six health cards now split into six, three or two columns, so one card
|
|
||||||
is never left alone on a row. Three corrections after the owner's review:
|
|
||||||
the paper edge is 8px under the 60px bar — it had been scaled from the hole
|
|
||||||
size to 16px, where the mark's margin is a tenth of the brick field; the
|
|
||||||
health row is compact again (71px: smaller icons, each line a block with its
|
|
||||||
own line height, a verdict never wraps and stretches the row); and a
|
|
||||||
read-only value is as tall as the Copy button joined to it. `check.py` no
|
|
||||||
longer counts `min-width: 0` as setting a width. Text sizes: the filter
|
|
||||||
controls in a box head and the navbar's dropdown entries are 15px like the
|
|
||||||
bar itself (they had Bulma's `is-small` and its .875rem dropdown); `check.py`
|
|
||||||
now rejects `is-small` on anything but a progress bar.
|
|
||||||
- Panel redesign, type scale: about 40 % of the panel's text measured under
|
|
||||||
13px (labels 11.7, table heads 10.5, fact labels 10.2, tags and help 11.3).
|
|
||||||
Body is now 16px with two tokens under it — `--sp-fs-s` 14px for supporting
|
|
||||||
text and `--sp-fs-xs` 13px for upper-case mono labels and tags; Bulma's
|
|
||||||
small sizes are overridden or dropped (`sp-small` replaces `is-size-7`), and
|
|
||||||
`check.py` fails any font size under the floor. Every table now sits in a
|
|
||||||
`table-container` (enforced), addresses in tables break after the `@`, the
|
|
||||||
inbound log folds the client IP under the sender; all screens fit without
|
|
||||||
scrolling at 1280px and up. Documented on the components page and in the
|
|
||||||
plan (§ Type and tables).
|
|
||||||
- Panel redesign, header comparison sheet
|
|
||||||
([docs/assets/panel-redesign/header/](docs/assets/panel-redesign/header/index.html)):
|
|
||||||
at nav size the stamp dissolves into the brick bar and the perforated edge
|
|
||||||
was drawn inverted (round bumps instead of round holes). Three replacements
|
|
||||||
beside the current header — **A** wordmark with the stamp's real hole-to-tooth
|
|
||||||
perforation bitten into the bar, **B** wordmark on a plain bar, **C** a
|
|
||||||
stamp-paper header with an ink hairline and brick only behind the name. The
|
|
||||||
wordmark is cut from the stamp's own outlines. Pending the owner's choice;
|
|
||||||
the mockups are unchanged. `.claude/launch.json` serves the mockups locally
|
|
||||||
(`python -m http.server 8765` from the repository root).
|
|
||||||
- Panel redesign, the roadmap's screens: **Inbound › Filter lists** and the
|
|
||||||
adjusted **Inbound › Log** (client, decision, engine and reason; *Allow /
|
|
||||||
Deny sender*) for inbound-antispam-panel, **Inbound › Quarantine** (list and
|
|
||||||
held message with Release / Discard) for inbound-quarantine, and **Server ›
|
|
||||||
Preflight** for preflight — all from existing components; 33 screens. Their
|
|
||||||
paths join § Routes; the quarantine and preflight plans link their mockups.
|
|
||||||
A mockup is the target look, not a decision to build: each item keeps its
|
|
||||||
status, and quarantine's open questions stay open.
|
|
||||||
- Panel redesign, two owner decisions recorded in
|
|
||||||
`docs/plans/panel-redesign.md`: **no compatibility** — the redesign is
|
|
||||||
`2.0.0`, old paths are removed without redirects, the SQLite migration chain
|
|
||||||
is replaced by one baseline and 2.0 starts from an empty data directory
|
|
||||||
(schema-squash ships as a step of this plan, without its upgrade gate; the
|
|
||||||
only live installation is the owner's); and
|
|
||||||
**inbound delegation** — a domain administrator manages assigned inbound
|
|
||||||
domains, granted separately from outbound (`user_inbound_domains`, a second
|
|
||||||
list on the user form, per-domain authorization under `/inbound/`). Mockups `users`, `user`,
|
|
||||||
`user-delete` redrawn for the two lists. Checklist `3/21`.
|
|
||||||
- Panel redesign, routes: the owner decided that paths follow the new names
|
|
||||||
and structure. `docs/plans/panel-redesign.md` § Routes maps every screen to
|
|
||||||
its new path (`/overview`, `/outbound/…`, `/inbound/…`, `/server/…`,
|
|
||||||
`/account`) with the rules that come along — one `<page>/fragment` name, the
|
|
||||||
application form as a single POST, 301s from old GET paths for one MINOR,
|
|
||||||
subtree `requireGlobal()` under security review, and
|
|
||||||
`TestRoutesFollowNavigation`. Mockups gained what the first pass missed: the
|
|
||||||
*Reload configuration* box (now on Server › Health), the `flash` component
|
|
||||||
and the `sp-help` link that replaces the help drawer. Checklist `1/17`.
|
|
||||||
- Panel redesign, design contract:
|
|
||||||
[docs/plans/panel-redesign.md](docs/plans/panel-redesign.md) rewritten for
|
|
||||||
the accepted direction — three layers (Bulma as shipped, adapters, own `sp-`
|
|
||||||
components), three page layouts, what an implementer must not do, the guard
|
|
||||||
tests that land before any template, a `design-first` CI step and
|
|
||||||
evidence-based acceptance. A
|
|
||||||
[components page](docs/assets/panel-redesign/panel/components.html) shows
|
|
||||||
every building block in every state; `panel/check.py` lints the mockups
|
|
||||||
against the contract and writes `panel/outlines.json`, the per-screen
|
|
||||||
component skeleton an implementation is compared with. Own modifier classes
|
|
||||||
renamed to carry the `sp-` prefix so the vocabulary is machine-checkable.
|
|
||||||
Agent rule `.cursor/rules/panel-design.mdc`; roadmap row updated (`0/15`).
|
|
||||||
- Panel redesign, stage 2: the accepted direction drawn on every screen —
|
|
||||||
29 pages under [docs/assets/panel-redesign/panel/](docs/assets/panel-redesign/index.html).
|
|
||||||
Direction C (Bulma) is the base, with A's postal signature (postmark verdict,
|
|
||||||
numbered mono section heads, the stamp's perforated edge) and B's icons.
|
|
||||||
Overview keeps verdicts only and the tables move to a new **Server › Health**
|
|
||||||
page; the own password becomes **Account**; queues are drawn as tables
|
|
||||||
(needs `postqueue -j`) and **Inbound** gets its own Log and Queue. Pages are
|
|
||||||
built from fragments by `panel/build.py`; the built HTML is committed.
|
|
||||||
Mockups only; nothing in `internal/web` changes.
|
|
||||||
- Panel redesign, stage 1: three directions drawn on the three key screens
|
|
||||||
(Overview, an outbound domain, sign-in) under
|
|
||||||
[docs/assets/panel-redesign/](docs/assets/panel-redesign/index.html) —
|
|
||||||
**A** Pico CSS ("Blank"), **B** Tabler ("Console"), **C** Bulma ("Counter"),
|
|
||||||
with a cover sheet comparing size, JavaScript, CSP fit and own CSS. All three
|
|
||||||
share a navigation grouped by mail direction (Overview · Outbound · Inbound ·
|
|
||||||
Server, with a symmetric Log and Queue on both sides), one block per DNS
|
|
||||||
record instead of separate "status" and "records to publish" cards, and
|
|
||||||
applications as a table. Mockups only; nothing in `internal/web` changes.
|
|
||||||
The brief behind them supersedes the "no component library" decision in
|
|
||||||
`docs/plans/panel-redesign.md`; that file is rewritten once a direction is
|
|
||||||
accepted.
|
|
||||||
- Roadmap item and givens for a panel visual redesign
|
|
||||||
([docs/plans/panel-redesign.md](docs/plans/panel-redesign.md)) — layout
|
|
||||||
grammar (`measure` / `fill` plus Web Awesome layout CSS), no JS component
|
|
||||||
library, new mockups rather than `docs/assets/panel-ui/`. Status: candidate.
|
|
||||||
- Roadmap item and plan for `/preflight` installation check page
|
|
||||||
([docs/plans/preflight.md](docs/plans/preflight.md)) — instance-level
|
|
||||||
infrastructure verification (rDNS, TLS, ports, HELO banner, proxy headers,
|
|
||||||
DKIM socket, test email). Status: candidate.
|
|
||||||
|
|
||||||
## [1.9.2] - 2026-09-22
|
|
||||||
|
|
||||||
Review pass over `1.4.0`…`1.9.1`: port 25 no longer shares the submission
|
|
||||||
milter chain, DMARC reports are validated before they are stored, and the auto
|
|
||||||
rate limit is correct for a non-default level-1 window. Schema migration
|
|
||||||
`0010` (index only). Upgrading is a tag bump.
|
|
||||||
|
|
||||||
A full-tree code review on 2026-08-19 (architecture, code quality, security,
|
|
||||||
GUI, tests, documentation, AGPL compliance; 38 findings) produced most of what
|
|
||||||
follows. Its four open items live in [roadmap.md](docs/roadmap.md); its working
|
|
||||||
document is not part of the release.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- SQLite migration `0010_send_log_app_login_index.sql` —
|
|
||||||
`idx_send_log_app_login_created_at`. The journal-milter counts an
|
|
||||||
application's messages on every `MAIL FROM`, and the domain page computes
|
|
||||||
per-application statistics on every render; both were scanning the
|
|
||||||
`created_at` range because `app_login` was unindexed.
|
|
||||||
- DMARC aggregate reports are accepted as **zip** as well as gzip or plain XML
|
|
||||||
(several reporters ship a single-entry zip; those reports previously counted
|
|
||||||
as parse failures).
|
|
||||||
- e2e: `checkPort25ConfigCombinations` regenerates the Postfix configuration
|
|
||||||
inside the container for all four combinations of `INBOUND_RELAY_ENABLE` /
|
|
||||||
`DMARC_REPORTS_ENABLE` / `INBOUND_ANTISPAM_MILTER` and requires
|
|
||||||
`postfix check` to pass. The `1.9.1` crash-loop shipped in `1.4.0` and
|
|
||||||
survived three releases because the suite only ever exercised the flags-off
|
|
||||||
path.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- README hero line and a **Positioning** block in `docs/product.md` record the
|
|
||||||
tagline (`YOUR SMTP. YOUR RULES.`) and hero copy; the stamp subtitle stays
|
|
||||||
`SELF-HOSTED SMTP RELAY`. `docs/assets/selfpost-proof.html` updated to match.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- The setup account's password is hashed at bcrypt cost `12` like every other
|
|
||||||
panel password; it was still being written at `bcrypt.DefaultCost` (10). The
|
|
||||||
work factor now has one definition, `auth.BcryptCost`. bcrypt records the
|
|
||||||
cost in the hash, so existing passwords keep verifying unchanged.
|
|
||||||
- Login/setup rate limiter: protecting blocked buckets from eviction is now
|
|
||||||
bounded. Blocked buckets are still never evicted to make room, but past
|
|
||||||
`maxBuckets × 4` they are evicted too (nearest expiry first) — otherwise an
|
|
||||||
attacker with many source addresses could grow the bucket map without limit
|
|
||||||
by making each address fail its way to blocked. The overflow is logged at
|
|
||||||
most once per window.
|
|
||||||
- `postqueue -p` output is bounded to 4 MiB (the timeout alone left the size of
|
|
||||||
the listing, and therefore the panel's allocation, up to the queue).
|
|
||||||
- [schema-migrations.md](docs/schema-migrations.md): the schema section was
|
|
||||||
still headed `v9` while the chain head is `v10`, and three links pointed at
|
|
||||||
plan files deleted when those features shipped.
|
|
||||||
- postqueue reader is now time-bounded: `postqueue -p` runs under a 5-second
|
|
||||||
context timeout so a stuck queue command cannot hang the queue panel path
|
|
||||||
indefinitely.
|
|
||||||
- DMARC panel layout classes now exist in
|
|
||||||
`internal/web/view/static/panel.css` (`.pair`, `.attn`, `.desk-only`,
|
|
||||||
`.phone-only`, `.phone-list`, `.check-row`), so desktop/mobile DMARC views and
|
|
||||||
attention highlighting render as intended.
|
|
||||||
- `dmarc-ingest` now bounds stdin with `io.LimitReader` (10 MiB) before parsing.
|
|
||||||
- User-role update no longer contains a duplicate "last global admin" demotion
|
|
||||||
guard; one check now returns a context-specific message for self-demotion.
|
|
||||||
- Settings flash text now reflects what actually changed and only mentions
|
|
||||||
session sign-out when the password changed.
|
|
||||||
- Help page/drawer links now use `SourceURL` (`{{.SourceURL}}/blob/main/docs/...`)
|
|
||||||
instead of a hardcoded repository URL.
|
|
||||||
- Help drawer close controls include accessibility attributes (`role="button"`,
|
|
||||||
`aria-label`) for the scrim and close action.
|
|
||||||
- Test coverage expanded for DMARC map token injection rejection and email
|
|
||||||
validation edge cases (ASCII/local-part/domain/freemail cases).
|
|
||||||
- **Port 25 no longer runs the submission milter chain.** `smtpd_milters` is
|
|
||||||
now always overridden on `smtp/inet` — to the optional antispam milter, or
|
|
||||||
to nothing. Previously, with `INBOUND_ANTISPAM_MILTER` unset, inbound mail
|
|
||||||
inherited main.cf's chain: OpenDKIM (strict, `tempfail`) could defer relayed
|
|
||||||
mail during a signing outage, and the journal-milter filed inbound mail into
|
|
||||||
the outbound send log under the **sender's** domain — so Deliveries showed
|
|
||||||
other people's mail, and a forged `From:` on an inbound message consumed that
|
|
||||||
sending domain's level-2 rate-limit budget. [architecture.md](docs/architecture.md)
|
|
||||||
already described the intended behaviour; the code now matches it.
|
|
||||||
- **DMARC reports are validated before they are stored.** A report is accepted
|
|
||||||
only when the domain in `policy_published` is a sending domain configured
|
|
||||||
here and, for a per-domain hosted address, matches that address's `+tag`.
|
|
||||||
The rua address form is predictable, so anyone able to reach it could
|
|
||||||
previously file reports under any domain, skew the panel's alignment figures
|
|
||||||
and the tighten-`p=` hint, and evict genuine reports through the 500-report
|
|
||||||
retention cap.
|
|
||||||
- Auto level-2 rate limit: the ceiling is now scaled to the level-1 window
|
|
||||||
before the multiplier is applied. `ceil(avg msg/h × multiplier)` was stored
|
|
||||||
as the ceiling for a window of `RATE_LIMIT_WINDOW_SECONDS`, so any window
|
|
||||||
other than the default `3600` shifted the effective allowance by
|
|
||||||
`window/3600` (a 1800s window allowed twice the intended rate). A domain
|
|
||||||
that has sent anything now also gets a floor of 1.
|
|
||||||
- Single-domain export no longer drops rate limits silently when the store
|
|
||||||
errors — the error is returned instead of being swallowed by `err == nil && ok`.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- [roadmap.md](docs/roadmap.md) now carries everything the 2026-08-19 code
|
|
||||||
review left unimplemented, so no finding lives only in a plan file:
|
|
||||||
`csrf-tokens` (the recorded §4.4 decision that never became a task),
|
|
||||||
`template-data-typing`, `structured-logging`, and
|
|
||||||
`review-2026-08-followups` for the small remainder (backup re-auth, the
|
|
||||||
missing service/handler/error-page tests, and nine minor findings that were
|
|
||||||
never carried into the review's own phase tables). Each roadmap row stands on
|
|
||||||
its own — file, symptom, and what "done" means — and the declined finding
|
|
||||||
(`//go:build linux` for the `/proc` reader) is recorded as declined so it is
|
|
||||||
not raised again. The review's working document is gone from `docs/plans/`
|
|
||||||
now that nothing depends on it; git history keeps it.
|
|
||||||
- Extracted `internal/configsafe` — the empty-value and forbidden-character
|
|
||||||
check every generated config file shares. Each writer keeps its own forbidden
|
|
||||||
set next to the file it protects (an OpenDKIM table and a Postfix map break on
|
|
||||||
different characters); only the check itself is shared.
|
|
||||||
- Build, deploy and migration comments no longer cite the archived
|
|
||||||
specification. The `spec N` references CHANGELOG `[0.5.0]` said were gone
|
|
||||||
survived in `build/`, `deploy/`, the first two migrations and the release
|
|
||||||
workflow; they are removed and the affected comment paragraphs re-wrapped.
|
|
||||||
Comments only — no build or configuration behaviour changes.
|
|
||||||
- Dropped the `parseDomainRateLimitForm` / `parseAppRateLimitForm` pass-through
|
|
||||||
wrappers left behind when the two parsers were merged: nothing but their own
|
|
||||||
tests still called them.
|
|
||||||
- Extracted shared helpers: `internal/atomicfile` (atomic temp-write+rename) and
|
|
||||||
`internal/supervisor` (supervisorctl start/signal wrappers), and reused them in
|
|
||||||
domain/postfix code paths.
|
|
||||||
- `docs/roadmap.md`: shipped `panel-docs` now references CHANGELOG history
|
|
||||||
instead of a removed plan file.
|
|
||||||
- [schema-migrations.md](docs/schema-migrations.md) notes that migration `0009`
|
|
||||||
changed what the IP list *means* — permissive (those IPs got the application
|
|
||||||
ceiling) before 1.9.0, restrictive (every other IP refused) after. Its
|
|
||||||
conversion branch only fires for a database that carried application-scope
|
|
||||||
`allowed_ips` from before 1.9.0.
|
|
||||||
- Panel help, [guide.md](docs/guide.md) and [architecture.md](docs/architecture.md):
|
|
||||||
the client IP allow-list is a **sending** control, not an authentication
|
|
||||||
boundary — the SASL login still succeeds, the check runs in the
|
|
||||||
journal-milter at `MAIL FROM`, and it is fail-open like the rate limits.
|
|
||||||
architecture.md's rate-limit description still documented the pre-1.9.0
|
|
||||||
trusted-IP semantics.
|
|
||||||
- Removed unused duplicates of the inbound validators (`Host`, `Port`,
|
|
||||||
`TLSMode`, `RecipientMode`, `MailboxInDomain`, `NormalizeHost`) from
|
|
||||||
`internal/web/validate` — the live path uses `internal/inbound/validate.go`;
|
|
||||||
the copies were reachable only from their own tests.
|
|
||||||
- `gofmt` across `cmd/` and `internal/` (10 files had drifted); dead branch
|
|
||||||
removed from `postfix.inboundNexthop`.
|
|
||||||
- The planned inbound-antispam migration is renumbered `0011_inbound_spam_log.sql`.
|
|
||||||
- Operator and as-built docs aligned with the code after a full pass —
|
|
||||||
[architecture.md](docs/architecture.md) (port 25 opens for inbound relay
|
|
||||||
and/or DMARC ingest; antispam requires inbound relay; DMARC pipe transport
|
|
||||||
and map paths; `/help` and `/dmarc*` routes; four panel roles including
|
|
||||||
rate-limit recalc; restore Resync vs manual reload); [guide.md](docs/guide.md)
|
|
||||||
and [README.md](README.md) (image pin `1.9.1`, port 25 conditions, combined
|
|
||||||
listener limits, reload vs Resync); [roadmap.md](docs/roadmap.md) (`panel-docs`
|
|
||||||
closed at `[1.8.0]`, next item inbound-antispam-panel); [development.md](docs/development.md)
|
|
||||||
version-cut table through `1.9.1`; [schema-migrations.md](docs/schema-migrations.md)
|
|
||||||
last-updated note. No behaviour change.
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- Unused legacy asset `docs/assets/icon.png` (superseded by the four brand SVGs
|
|
||||||
and `internal/web/static/favicon.png`; nothing referenced it).
|
|
||||||
- Plan documents for already-shipped features: `domain-stats-auto-ratelimit`,
|
|
||||||
`dmarc-reports`, `inbound-relay`, `panel-docs`, `queue-retries`,
|
|
||||||
`send-log-retention`. Only unimplemented plans remain in `docs/plans/`.
|
|
||||||
|
|
||||||
## [1.9.1] - 2026-08-19
|
|
||||||
|
|
||||||
Port-25 Postfix startup when inbound relay, DMARC ingest, or the inbound antispam
|
|
||||||
milter are enabled. Schema-migration reference and roadmap plans for inbound
|
|
||||||
antispam and quarantine. Upgrading from 1.9.0 is a tag bump; no migration.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- [docs/plans/inbound-antispam-panel.md](docs/plans/inbound-antispam-panel.md) —
|
|
||||||
agreed plan for inbound antispam journal (inbound-journal milter + mail.log
|
|
||||||
rejects) and instance-wide allow/deny lists synced to rspamd maps; target
|
|
||||||
`1.10.0`; Composer → Opus → Fable workflow.
|
|
||||||
- [docs/plans/inbound-quarantine.md](docs/plans/inbound-quarantine.md) — candidate
|
|
||||||
plan for held-mail review/release (design TBD).
|
|
||||||
- [docs/schema-migrations.md](docs/schema-migrations.md) — living reference for the
|
|
||||||
SQLite migration chain (`user_version` head, per-file history, legacy artefacts,
|
|
||||||
1.x rules, and planned 2.x squash gate).
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- [docs/roadmap.md](docs/roadmap.md) — `inbound-antispam-panel` (agreed, 1/12)
|
|
||||||
and `inbound-quarantine` (candidate) in the index.
|
|
||||||
- [docs/development.md](docs/development.md) — plan checklists may override
|
|
||||||
model routing with Composer → Opus → Fable.
|
|
||||||
- [docs/development.md](docs/development.md), [docs/roadmap.md](docs/roadmap.md),
|
|
||||||
and [docs/architecture.md](docs/architecture.md) link to the new file;
|
|
||||||
`schema-squash` defers gate thresholds to it instead of a stale v5 snapshot.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- `build/postfix-config.sh`: enabling inbound relay, DMARC ingest, and/or the
|
|
||||||
inbound antispam milter no longer crash-loops on start — `postconf -P` cannot
|
|
||||||
set `master.cf` `-o` values that contain whitespace, so port-25
|
|
||||||
`smtpd_recipient_restrictions` and `smtpd_milters` are patched directly; DMARC
|
|
||||||
pipe `argv` uses a `dmarc-ingest` symlink instead of `panel -dmarc-ingest`.
|
|
||||||
|
|
||||||
## [1.9.0] - 2026-08-18
|
|
||||||
|
|
||||||
Application sending controls: client IP allow-list for authorization, and
|
|
||||||
level-2 rate limits that override the domain ceiling per application.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Per-application **client IP allow-list** on the domain page: when enabled,
|
|
||||||
only listed addresses may authenticate and submit as that application; when
|
|
||||||
off, any client IP is allowed. Form `POST /applications/{id}/authips`.
|
|
||||||
- SQLite migration `0009_application_auth_ips.sql` (moves legacy trusted IPs
|
|
||||||
from `rate_limits` into `applications.auth_allowed_ips`).
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Application level-2 rate limit **overrides** the domain limit for that login
|
|
||||||
— the ceiling may be higher or lower than the domain setting (still ≤ level
|
|
||||||
1). Rate limits are no longer tied to client IPs; enforcement is in the
|
|
||||||
journal-milter at `MAIL FROM`.
|
|
||||||
- Domain export/import carries `auth_ip_restrict` / `auth_allowed_ips`; legacy
|
|
||||||
`allowed_ips` on an application `rate_limit` in export JSON migrate on import.
|
|
||||||
- Panel help, Settings, and [guide.md](docs/guide.md) describe override semantics
|
|
||||||
and the IP allow-list boundary.
|
|
||||||
- [guide.md](docs/guide.md): full-backup section documents all four capture
|
|
||||||
methods (panel, `selfpost-backup`, stopped `tar` of the project directory,
|
|
||||||
stopped `tar` of `./data` only), warns against `tar` on a live container, and
|
|
||||||
compares downtime, archive contents, and restore behaviour in a table.
|
|
||||||
|
|
||||||
## [1.8.0] - 2026-08-18
|
|
||||||
|
|
||||||
Built-in operator documentation in the panel: a CSS-only help drawer and Help
|
|
||||||
page so Status checks and domain controls are explained without leaving the
|
|
||||||
panel.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Help** navigation entry and `/help` index page.
|
|
||||||
- CSS-checkbox help drawer (no extra script): seeded topics for Status (Machine,
|
|
||||||
TLS, PTR, mail queue, inbound when enabled) and domain cards (DNS, records,
|
|
||||||
DMARC, connection, applications, settings, export).
|
|
||||||
- «?» entry points on Status and domain card heads open the matching drawer
|
|
||||||
topic.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- [guide.md](docs/guide.md) documents the boundary: in-panel help for card
|
|
||||||
meaning; the repository guide for installation, env vars, and operations.
|
|
||||||
|
|
||||||
## [1.7.0] - 2026-08-18
|
|
||||||
|
|
||||||
Optional DMARC aggregate report ingest: accept `rua=` mail on port 25, parse
|
|
||||||
gzip/XML into SQLite, and show summaries in the panel.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Optional DMARC aggregate report ingest (`DMARC_REPORTS_ENABLE`): port 25
|
|
||||||
accepts only configured `rua=` addresses, gzip/XML is parsed into SQLite, and
|
|
||||||
the panel shows per-domain roll-ups and individual reports under *DMARC*.
|
|
||||||
- Per-domain **SelfPost hosted** `rua=` mode when ingest is enabled
|
|
||||||
(`dmarc-reports+<domain>@<hostname>`).
|
|
||||||
|
|
||||||
## [1.6.0] - 2026-08-18
|
|
||||||
|
|
||||||
30-day sending statistics per domain and application, plus optional auto
|
|
||||||
level-2 rate limits derived from average send rate.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Domain page **Sending statistics** card: total messages, peak and average
|
|
||||||
msg/h over the last 30 days (or shorter when send-log retention is below
|
|
||||||
30 days). Per-application stats in the application list.
|
|
||||||
- Level-2 rate limit **Manual** / **Auto** mode on domain and application
|
|
||||||
forms. Auto sets `max_messages` from `ceil(avg × multiplier)` over the
|
|
||||||
level-1 window, capped at level 1; background recalc every six hours and
|
|
||||||
**Recalculate now** on the domain page.
|
|
||||||
- Domain export JSON now includes rate limits (`mode`, `auto_multiplier`,
|
|
||||||
ceilings, trusted IPs).
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Auto mode uses the level-1 window (`RATE_LIMIT_WINDOW_SECONDS`); manual
|
|
||||||
mode behaviour is unchanged.
|
|
||||||
|
|
||||||
## [1.5.0] - 2026-08-18
|
|
||||||
|
|
||||||
Send-log retention is configurable from panel **Settings** (global
|
|
||||||
administrator); `SEND_LOG_RETENTION_DAYS` seeds the initial default only.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Panel **Settings** (global administrator): **Send log retention (days)** —
|
|
||||||
how long delivery journal rows on `/deliveries` are kept (7–365). Stored in
|
|
||||||
SQLite `settings`; `SEND_LOG_RETENTION_DAYS` seeds the initial default only.
|
|
||||||
The log-tailer re-reads the value every prune cycle (no restart).
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Deliveries list and delivery detail pages show the configured retention
|
|
||||||
instead of a hardcoded ninety-day message.
|
|
||||||
|
|
||||||
## [1.4.0] - 2026-08-17
|
|
||||||
|
|
||||||
Optional inbound relay (backup-MX / forwarder) on port 25, off by default.
|
|
||||||
Mail is accepted only for domains configured in the panel and forwarded to
|
|
||||||
an upstream; there are still no mailboxes. A dedicated security review of
|
|
||||||
the inbound path is still open on the plan (Fable).
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- `INBOUND_RELAY_ENABLE` (default `false`): when true, Postfix listens on
|
|
||||||
port 25 without SASL, only for `relay_domains` + known recipients, and
|
|
||||||
forwards via `transport_maps`. Empty upstream host is omitted from the
|
|
||||||
maps so mail is never accepted with nowhere to send it.
|
|
||||||
- Panel **Inbound** pages (global administrator): domains, upstream host/port
|
|
||||||
and TLS, recipient list or any-at-domain, MX check against
|
|
||||||
`SELFPOST_HOSTNAME`.
|
|
||||||
- Optional `INBOUND_ANTISPAM_MILTER` on the inbound listener only, plus
|
|
||||||
[deploy/antispam/docker-compose.antispam.yml](deploy/antispam/docker-compose.antispam.yml).
|
|
||||||
Default milter action is fail-open (`accept`).
|
|
||||||
- Inbound rate limit (`INBOUND_RATE_LIMIT_MESSAGES_PER_IP`, default 20) and
|
|
||||||
`INBOUND_MESSAGE_SIZE_LIMIT` (default 25 MiB) on smtpd port 25.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Compose publishes host port 25 even when the flag is off (nothing listens
|
|
||||||
until it is on), the same pattern as 587 / `SUBMISSION_ENABLE`.
|
|
||||||
- Full-backup restore Resync also rebuilds inbound maps when the flag is on.
|
|
||||||
Single-domain export/import is still sending domains only.
|
|
||||||
|
|
||||||
## [1.3.1] - 2026-08-17
|
|
||||||
|
|
||||||
Retry policy in the panel, persistent Postfix queue, and self-contained
|
|
||||||
full backups. Mail queue and delivery history show this Postfix's first
|
|
||||||
retry, backoff cap, and queue lifetime. Deferred mail survives container
|
|
||||||
recreate. Full backups now archive `data/`, compose, `.env`, and `certs/`;
|
|
||||||
the archive layout breaks 1.3.0 flat backups (unpack those with
|
|
||||||
`tar xzf backup.tar.gz -C ./data` as before). Upgrading from 1.3.0 is a
|
|
||||||
tag bump; no schema migration.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Mail queue and a delivery's history show this Postfix's retry policy
|
|
||||||
(first delay, backoff cap, queue lifetime), read from `postconf -h` once
|
|
||||||
at panel start. A manual `postconf -e` override is visible after the next
|
|
||||||
panel restart. There is no attempt counter — Postfix retries on time.
|
|
||||||
|
|
||||||
- docs: **Plan checklists** in [development.md](docs/development.md) — format,
|
|
||||||
`Progress` column in [roadmap.md](docs/roadmap.md), per-step commit +
|
|
||||||
CHANGELOG, version cuts `1.3.1`…`1.8.0` per roadmap stage.
|
|
||||||
- docs: **Implementation checklists** with model routing in
|
|
||||||
[docs/plans/](docs/plans/) (queue-retries, inbound-relay, send-log-retention,
|
|
||||||
domain-stats-auto-ratelimit, dmarc-reports, panel-docs).
|
|
||||||
- docs: roadmap candidates **send-log-retention** (panel Settings for delivery
|
|
||||||
journal retention) and **domain-stats-auto-ratelimit** (30-day send stats per
|
|
||||||
domain/application, auto level-2 rate limit from avg × multiplier) — plans in
|
|
||||||
[docs/plans/](docs/plans/).
|
|
||||||
|
|
||||||
- Postfix queue under `/data/postfix/queue` — deferred and active mail survive
|
|
||||||
container recreate and are included in full backups.
|
|
||||||
- Full backup archives the whole operator project: `data/`, `docker-compose.yml`,
|
|
||||||
`.env`, and `certs/` (requires `.:/selfpost-deploy:ro` in compose). Restore
|
|
||||||
by unpacking into an empty project directory.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- **Breaking:** full-backup archive layout — paths are prefixed with `data/`;
|
|
||||||
deploy files sit at the archive root. Old flat archives restore with
|
|
||||||
`tar xzf backup.tar.gz -C ./data` as before.
|
|
||||||
- docs: operator guide, architecture, security, and backup UI updated for
|
|
||||||
self-contained backups and persistent queue.
|
|
||||||
|
|
||||||
- docs: HTML mockups for a full panel UI refresh
|
|
||||||
([docs/assets/panel-ui/](docs/assets/panel-ui/index.html)) — current screens,
|
|
||||||
agreed and candidate roadmap surfaces (queue-retries, inbound-relay,
|
|
||||||
dmarc-reports, panel-docs), a hybrid width (ops pages use the window, forms
|
|
||||||
keep a reading measure), and a 390px emergency layout. The stamp and brick
|
|
||||||
palette stay; extra nav icons are proposals only. Design artefact; the
|
|
||||||
running panel is unchanged.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- docs: panel UI mockups after review — Status restored to live readings
|
|
||||||
(queue line, PTR, machine/process tables, Configuration); inbound MX DNS
|
|
||||||
check, recipient mode (list or any), and Danger zone beside recipients;
|
|
||||||
Backup and Settings as two-column ops pages; Save and Delete user on one
|
|
||||||
row; help «?» on domain cards; Host/name ‖ Type height matched to the live
|
|
||||||
panel (`b0ebe06`).
|
|
||||||
|
|
||||||
- docs: panel UI design system and mockups rebuilt as separate pages
|
|
||||||
(`system.html`, `status.html`, `domain.html`, …), not one hash sheet.
|
|
||||||
Regions are `stack` / `pair` / `measure` / `fill`; Host ‖ Type is
|
|
||||||
`field-row`; Save + Delete is `actions-row`. Shared chrome is `shell.js`.
|
|
||||||
|
|
||||||
- docs: panel UI mockups — add-domain sits in the list card (Domains and
|
|
||||||
Inbound); DMARC candidate screens drill into a domain roll-up and a
|
|
||||||
parsed aggregate report (aligned vs third-party fail), not a hub-only
|
|
||||||
summary.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- ci (GHCR): per-arch package tags (`X.Y.Z-amd64`, `X.Y.Z-arm64`) are dropped
|
|
||||||
after the manifest merge via the GitHub Packages API. The `[1.3.0]` merge job
|
|
||||||
called `docker buildx imagetools rm`, which is not a valid subcommand — cleanup
|
|
||||||
failed with a warning and the side-effect tags stayed in the registry until
|
|
||||||
this fix.
|
|
||||||
|
|
||||||
## [1.3.0] - 2026-08-14
|
## [1.3.0] - 2026-08-14
|
||||||
|
|
||||||
Security and quality after 1.2.5: domain-admin send-log authorization,
|
Security and quality after 1.2.5: domain-admin send-log authorization,
|
||||||
@@ -897,7 +22,7 @@ Plex. Upgrading from 1.2.x is a tag bump; no migration.
|
|||||||
- docs: agreed roadmap item **queue-retries** — show this Postfix's retry
|
- docs: agreed roadmap item **queue-retries** — show this Postfix's retry
|
||||||
policy (first delay, backoff cap, queue lifetime) on Mail queue and on a
|
policy (first delay, backoff cap, queue lifetime) on Mail queue and on a
|
||||||
delivery's history, reading `postconf -h` once at panel start so a manual
|
delivery's history, reading `postconf -h` once at panel start so a manual
|
||||||
override is visible. Plan: `docs/plans/queue-retries.md` (removed once shipped).
|
override is visible. Plan: [docs/plans/queue-retries.md](docs/plans/queue-retries.md).
|
||||||
Explanation only; no attempt counter and no panel knobs. Not yet
|
Explanation only; no attempt counter and no panel knobs. Not yet
|
||||||
implemented.
|
implemented.
|
||||||
|
|
||||||
@@ -964,6 +89,11 @@ Plex. Upgrading from 1.2.x is a tag bump; no migration.
|
|||||||
the same Resync on demand. The `internal/backup` package comment now matches
|
the same Resync on demand. The `internal/backup` package comment now matches
|
||||||
this behaviour.
|
this behaviour.
|
||||||
|
|
||||||
|
- ci (GHCR): per-arch package tags (`X.Y.Z-amd64`, `X.Y.Z-arm64`) are dropped
|
||||||
|
after the manifest merge via the GitHub Packages API. The merge job had called
|
||||||
|
`docker buildx imagetools rm`, which is not a valid subcommand — cleanup failed
|
||||||
|
with a warning and the side-effect tags stayed in the registry.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- docs: operator and as-built docs aligned with the code after a full
|
- docs: operator and as-built docs aligned with the code after a full
|
||||||
@@ -1013,13 +143,15 @@ Plex. Upgrading from 1.2.x is a tag bump; no migration.
|
|||||||
(`vX.Y.Z`) or a manual `workflow_dispatch` with an explicit SemVer version — a
|
(`vX.Y.Z`) or a manual `workflow_dispatch` with an explicit SemVer version — a
|
||||||
bare git tag push no longer starts the build. `release.yml` listens for
|
bare git tag push no longer starts the build. `release.yml` listens for
|
||||||
`release: published`, checks out that tag (not `main` HEAD), e2e-gates each
|
`release: published`, checks out that tag (not `main` HEAD), e2e-gates each
|
||||||
native arch build, and merges `X.Y.Z-amd64` and `X.Y.Z-arm64` into one
|
native arch build, merges `X.Y.Z-amd64` and `X.Y.Z-arm64` into one manifest,
|
||||||
manifest. Reliable removal of the per-arch side-effect tags from GHCR landed
|
then removes the per-arch tags from GHCR via the GitHub Packages API so
|
||||||
in `[1.3.1]`. A dispatch whose version input is missing or not `X.Y.Z` fails
|
operators see only `ghcr.io/mixeme/selfpost:X.Y.Z` (what
|
||||||
in `prepare`. [development.md](docs/development.md) documents draft vs
|
`deploy/docker-compose.yml` pins). A dispatch whose version input is missing
|
||||||
published releases, why deleting a release tag converts it back to draft, and
|
or not `X.Y.Z` fails in `prepare`. [development.md](docs/development.md)
|
||||||
Gitea → GitHub tag-mirror pitfalls (do not prune release tags on GitHub; a
|
documents draft vs published releases, why deleting a release tag converts
|
||||||
mirrored `v1.0.0` still runs that tag's `on: push: tags` workflow).
|
it back to draft, and Gitea → GitHub tag-mirror pitfalls (do not prune release
|
||||||
|
tags on GitHub; a mirrored `v1.0.0` still runs that tag's `on: push: tags`
|
||||||
|
workflow).
|
||||||
|
|
||||||
- test: the authorization and sign-in surfaces that had no tests now have them.
|
- test: the authorization and sign-in surfaces that had no tests now have them.
|
||||||
The login limiter is covered for its ceiling, its per-address scope, the reset
|
The login limiter is covered for its ceiling, its per-address scope, the reset
|
||||||
@@ -1089,7 +221,7 @@ Plex. Upgrading from 1.2.x is a tag bump; no migration.
|
|||||||
RBAC in the doc-alignment pass above). Corrected stale
|
RBAC in the doc-alignment pass above). Corrected stale
|
||||||
`admin.dmarc_report_email` references in
|
`admin.dmarc_report_email` references in
|
||||||
[roadmap.md](docs/roadmap.md) and
|
[roadmap.md](docs/roadmap.md) and
|
||||||
`docs/plans/dmarc-reports.md` (removed once shipped) to the setting's
|
[docs/plans/dmarc-reports.md](docs/plans/dmarc-reports.md) to the setting's
|
||||||
actual home after migration `0005`. No behaviour change.
|
actual home after migration `0005`. No behaviour change.
|
||||||
|
|
||||||
- panel: code-review P6 cleanup — the unused `auth.RequireGlobal` middleware is
|
- panel: code-review P6 cleanup — the unused `auth.RequireGlobal` middleware is
|
||||||
@@ -1116,9 +248,7 @@ Rate-limit form polish after 1.2.4. Upgrading is a tag bump; no migration.
|
|||||||
carry muted leads and matched control height; trusted-IP help sits under the
|
carry muted leads and matched control height; trusted-IP help sits under the
|
||||||
IP field. Domain settings pairs DMARC reports with the level-2 rate limit
|
IP field. Domain settings pairs DMARC reports with the level-2 rate limit
|
||||||
using CSS subgrid so titles, fields, and Save / Remove buttons line up across
|
using CSS subgrid so titles, fields, and Save / Remove buttons line up across
|
||||||
columns. Domain page Export and Danger cards stack naturally again — a
|
columns.
|
||||||
shared-baseline flex pin on `.split` cards made mismatched body lengths look
|
|
||||||
worse than a plain stack.
|
|
||||||
|
|
||||||
## [1.2.4] - 2026-08-12
|
## [1.2.4] - 2026-08-12
|
||||||
|
|
||||||
@@ -1925,10 +1055,9 @@ database, or the on-disk layout. Upgrading is a tag bump.
|
|||||||
Bookmarks to the old paths stop working.
|
Bookmarks to the old paths stop working.
|
||||||
|
|
||||||
- panel: each entry in the navigation bar now carries an icon beside its label,
|
- panel: each entry in the navigation bar now carries an icon beside its label,
|
||||||
so the bar is scannable at a glance instead of a row of similar-length words —
|
so the bar is scannable at a glance instead of a row of similar-length words.
|
||||||
Backup uses a filing-cabinet mark; Sign out has its own icon too. The icons
|
The icons are inline SVG drawn in the entry's own colour — no extra request,
|
||||||
are inline SVG drawn in the entry's own colour — no extra request, no
|
no exemption from the panel's Content-Security-Policy — and are hidden from
|
||||||
exemption from the panel's Content-Security-Policy — and are hidden from
|
|
||||||
screen readers, which still announce the label alone.
|
screen readers, which still announce the label alone.
|
||||||
|
|
||||||
- panel: the navigation bar is laid out as two rows on purpose — the signed-in
|
- panel: the navigation bar is laid out as two rows on purpose — the signed-in
|
||||||
@@ -2068,11 +1197,6 @@ database, or the on-disk layout. Upgrading is a tag bump.
|
|||||||
login/password and on the sending server name.
|
login/password and on the sending server name.
|
||||||
- panel: the *Addresses* field is hidden while an application's address mode is
|
- panel: the *Addresses* field is hidden while an application's address mode is
|
||||||
*Any address of the domain*, where the server ignores it.
|
*Any address of the domain*, where the server ignores it.
|
||||||
- deploy: `cap_add` gains `CAP_FOWNER` and `CAP_FSETID` so the entrypoint can
|
|
||||||
chmod and set the setgid bit on `/data` directories it has just chowned under
|
|
||||||
`cap_drop: ALL`, and `CAP_KILL` so supervisord can signal OpenDKIM across uids
|
|
||||||
when domains are added or removed. Production boot under the hardened compose
|
|
||||||
file exposed both gaps after Phase 10.
|
|
||||||
- ci: disable provenance attestation on release image push, so the ghcr.io
|
- ci: disable provenance attestation on release image push, so the ghcr.io
|
||||||
manifest list shows only `linux/amd64`/`linux/arm64` (no `unknown/unknown`).
|
manifest list shows only `linux/amd64`/`linux/arm64` (no `unknown/unknown`).
|
||||||
- ci: run `go vet` and `go test ./...` on every push to `main` and every pull
|
- ci: run `go vet` and `go test ./...` on every push to `main` and every pull
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
# SelfPost build.
|
# SelfPost build.
|
||||||
#
|
#
|
||||||
# The version is stamped into both binaries and MUST match the Docker image tag
|
# The version is stamped into both binaries and MUST match the Docker image tag
|
||||||
# (architecture.md § Persistence: backup/restore compatibility check). Override
|
# (spec 7.5.A: backup/restore compatibility check). Override on the command line:
|
||||||
# on the command line:
|
|
||||||
#
|
#
|
||||||
# make build VERSION=1.3.0
|
# make build VERSION=1.3.0
|
||||||
#
|
#
|
||||||
# CGO is disabled to keep the binaries fully static (modernc.org/sqlite is pure
|
# CGO is disabled to keep the binaries fully static (modernc.org/sqlite is pure
|
||||||
# Go, so no cgo is required).
|
# Go, so no cgo is required) — see spec 7.1.
|
||||||
|
|
||||||
VERSION ?= dev
|
VERSION ?= dev
|
||||||
MODULE := github.com/mixeme/selfpost
|
MODULE := github.com/mixeme/selfpost
|
||||||
|
|||||||
@@ -21,22 +21,6 @@ htmx 2.0.4 (internal/web/view/static/htmx.min.js)
|
|||||||
Licence: Zero-Clause BSD (0BSD)
|
Licence: Zero-Clause BSD (0BSD)
|
||||||
https://github.com/bigskysoftware/htmx
|
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
|
IBM Plex Sans / IBM Plex Mono
|
||||||
Copyright © 2017 IBM Corp., with Reserved Font Name "Plex"
|
Copyright © 2017 IBM Corp., with Reserved Font Name "Plex"
|
||||||
Licence: SIL Open Font License 1.1
|
Licence: SIL Open Font License 1.1
|
||||||
|
|||||||
@@ -4,16 +4,13 @@
|
|||||||
|
|
||||||
# SelfPost
|
# SelfPost
|
||||||
|
|
||||||
Self-hosted SMTP relay with a web panel — one Docker image, no provider.
|
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.
|
||||||
|
|
||||||
Postfix, OpenDKIM, and a small Go panel run together under `supervisord`; you
|
SelfPost sends mail straight to the internet from **your own IP**, with per-domain
|
||||||
configure domains, DKIM keys, and SASL applications once, then point your apps
|
DKIM signing. It is **outbound only** — no inbound mail, mailboxes, or webmail.
|
||||||
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
|
**For:** operators who run their own VPS or home server and want a simple relay
|
||||||
they control, without a third-party SMTP provider.
|
they control, without a third-party SMTP provider.
|
||||||
@@ -24,11 +21,7 @@ send log and DNS checks in the panel, encrypted backups.
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Outbound SMTP (465/smtps; optional 587 submission) with per-domain DKIM signing
|
- Outbound SMTP (465/smtps; optional 587 submission) with per-domain DKIM signing
|
||||||
- Optional inbound relay on port 25 (backup-MX / forwarder; off by default)
|
- Web panel — domains, applications, deliveries, mail queue, system log, backup
|
||||||
- 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
|
- Multi-domain relay — each SASL application is bound to one sending domain
|
||||||
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
|
- 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
|
- Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides
|
||||||
@@ -85,15 +78,6 @@ 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.
|
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
|
## Quick start
|
||||||
|
|
||||||
> **First boot — create the admin account.** On a fresh container SelfPost prints
|
> **First boot — create the admin account.** On a fresh container SelfPost prints
|
||||||
@@ -112,7 +96,7 @@ docker run --rm -d --name selfpost-try \
|
|||||||
-e SELFPOST_HOSTNAME=mail.local.test \
|
-e SELFPOST_HOSTNAME=mail.local.test \
|
||||||
-e PANEL_COOKIE_SECURE=false \
|
-e PANEL_COOKIE_SECURE=false \
|
||||||
-v selfpost-try-data:/data \
|
-v selfpost-try-data:/data \
|
||||||
ghcr.io/mixeme/selfpost:2.0.0
|
ghcr.io/mixeme/selfpost:1.3.0
|
||||||
```
|
```
|
||||||
|
|
||||||
**Get the setup URL** (pick one):
|
**Get the setup URL** (pick one):
|
||||||
@@ -152,10 +136,8 @@ DNS — lives in the operator guide's [Full
|
|||||||
deployment](docs/guide.md#full-deployment) section, with proxy-specific
|
deployment](docs/guide.md#full-deployment) section, with proxy-specific
|
||||||
commands under [Reverse proxy](docs/guide.md#reverse-proxy-mandatory).
|
commands under [Reverse proxy](docs/guide.md#reverse-proxy-mandatory).
|
||||||
|
|
||||||
The compose file always publishes **465**, **587**, and **25**; Postfix listens
|
The compose file always publishes **465** and **587**; Postfix listens on 587
|
||||||
on 587 only when `SUBMISSION_ENABLE=true`, and on 25 when
|
only when `SUBMISSION_ENABLE=true` (see [Ports](docs/guide.md#ports)). Bump the
|
||||||
`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
|
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 (`TRUSTED_PROXY_CIDR`, rate limits, retention): see [Environment
|
||||||
variables](docs/guide.md#environment-variables).
|
variables](docs/guide.md#environment-variables).
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
#
|
#
|
||||||
# SelfPost — single Debian-slim image running postfix + opendkim + panel under
|
# SelfPost — single Debian-slim image running postfix + opendkim + panel under
|
||||||
# supervisord. Build from the repository root:
|
# supervisord (spec 4). Build from the repository root:
|
||||||
#
|
#
|
||||||
# docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .
|
# 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
|
WORKDIR /src
|
||||||
|
|
||||||
# Version stamped into both binaries; MUST match the image tag.
|
# Version stamped into both binaries; MUST match the image tag (spec 7.5.A).
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
# Module metadata first for layer caching.
|
# Module metadata first for layer caching.
|
||||||
@@ -52,13 +52,13 @@ RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \
|
|||||||
curl \
|
curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Unprivileged user for the panel process.
|
# Unprivileged user for the panel process (spec 7.6.8).
|
||||||
RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel
|
RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel
|
||||||
|
|
||||||
# Shared group bridging the unprivileged services: the panel generates
|
# Shared group bridging the unprivileged services (spec 5.1, 6): the panel
|
||||||
# per-domain DKIM keys, application SASL accounts (sasldb2) and the Postfix
|
# generates per-domain DKIM keys, application SASL accounts (sasldb2) and the
|
||||||
# sender map, while OpenDKIM and Postfix (different users) must read them.
|
# Postfix sender map, while OpenDKIM and Postfix (different users) must read
|
||||||
# Membership in this group — plus setgid dirs under /data (set up in
|
# 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
|
# 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
|
# the sasldb2/sender map, and lets the panel reach the supervisor control socket
|
||||||
# to signal OpenDKIM/Postfix reloads without any process running as root.
|
# to signal OpenDKIM/Postfix reloads without any process running as root.
|
||||||
@@ -67,15 +67,15 @@ RUN groupadd --system selfpost \
|
|||||||
&& usermod -aG selfpost opendkim \
|
&& usermod -aG selfpost opendkim \
|
||||||
&& usermod -aG selfpost postfix
|
&& usermod -aG selfpost postfix
|
||||||
|
|
||||||
# The panel reads the mail queue with `postqueue -p` (and the status page's
|
# The panel reads the mail queue with `postqueue -p` (spec 7.2.11, and the
|
||||||
# queue card). postqueue is setgid postdrop, which normally gives it the group
|
# status page's queue card). postqueue is setgid postdrop, which normally gives
|
||||||
# needed to reach Postfix's showq socket — but the documented deployment runs
|
# it the group needed to reach Postfix's showq socket — but the documented
|
||||||
# with `no-new-privileges`, which disables setgid transitions, so the panel
|
# deployment runs with `no-new-privileges`, which disables setgid transitions,
|
||||||
# would always see "Permission denied". Making `panel` a real member of postdrop
|
# so the panel would always see "Permission denied". Making `panel` a real
|
||||||
# grants the same access without relying on a setgid escalation the hardening
|
# member of postdrop grants the same access without relying on a setgid
|
||||||
# deliberately forbids. postdrop membership is read-side only: it does not let
|
# escalation the hardening deliberately forbids. postdrop membership is
|
||||||
# the panel bypass any Postfix restriction that a local user does not already
|
# read-side only: it does not let the panel bypass any Postfix restriction that
|
||||||
# have through the world-executable sendmail.
|
# a local user does not already have through the world-executable sendmail.
|
||||||
RUN usermod -aG postdrop panel
|
RUN usermod -aG postdrop panel
|
||||||
|
|
||||||
# Runtime directories: milter sockets and the consolidated persistent root.
|
# Runtime directories: milter sockets and the consolidated persistent root.
|
||||||
@@ -107,10 +107,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=0755 build/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
COPY --chmod=0644 build/supervisord.conf /etc/supervisor/supervisord.conf
|
COPY --chmod=0644 build/supervisord.conf /etc/supervisor/supervisord.conf
|
||||||
|
|
||||||
# Published submission ports: 465 (smtps, primary) and 587 (submission, optional),
|
# Published submission ports: 465 (smtps, primary) and 587 (submission, optional)
|
||||||
# inbound SMTP on 25 when INBOUND_RELAY_ENABLE=true, plus the panel on 8080.
|
# plus the panel on 8080. Outbound delivery dials remote MXs on 25 as a client,
|
||||||
# Outbound delivery dials remote MXs on 25 as a client even when inbound is off.
|
# which needs no inbound listener or EXPOSE.
|
||||||
EXPOSE 8080 465 587 25
|
EXPOSE 8080 465 587
|
||||||
|
|
||||||
# Liveness probe: panel HTTP plus mail-path processes (opendkim, panel, postfix).
|
# 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.
|
# Does not verify TLS, DNS, or end-to-end delivery — see docs/guide.md Operations.
|
||||||
@@ -118,5 +118,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
|
|||||||
CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1
|
CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1
|
||||||
|
|
||||||
# The entrypoint fixes /data ownership (bind mount) as root, then execs
|
# The entrypoint fixes /data ownership (bind mount) as root, then execs
|
||||||
# supervisord, which becomes PID 1 and owns process supervision.
|
# supervisord, which becomes PID 1 and owns process supervision (spec 4).
|
||||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
If any managed program exhausts its restart retries and enters FATAL, this
|
If any managed program exhausts its restart retries and enters FATAL, this
|
||||||
listener signals supervisord (its parent) to terminate, so the whole container
|
listener signals supervisord (its parent) to terminate, so the whole container
|
||||||
exits and Docker's `restart` policy can recreate it cleanly — rather than
|
exits and Docker's `restart` policy can recreate it cleanly — rather than
|
||||||
lingering "alive" with a dead Postfix/OpenDKIM/panel.
|
lingering "alive" with a dead Postfix/OpenDKIM/panel (spec 4).
|
||||||
|
|
||||||
Communication with supervisord uses the event listener protocol over stdin and
|
Communication with supervisord uses the event listener protocol over stdin and
|
||||||
stdout, so stdout must carry only protocol tokens.
|
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
|
# 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
|
# simultaneously match the PTR/rDNS record, the certificate CN/SAN, and the
|
||||||
# Cyrus SASL realm. The panel (main.go saslRealm()) and postfix-config.sh each
|
# Cyrus SASL realm (spec 5.2 p.3, 8). The panel (main.go saslRealm()) and
|
||||||
# fall back independently when it's unset — to `localhost` and to the container
|
# postfix-config.sh each fall back independently when it's unset — to
|
||||||
# hostname respectively — so accounts get written under one realm and looked up
|
# `localhost` and to the container hostname respectively — so accounts get
|
||||||
# under another and authentication silently fails for every application, while
|
# written under one realm and looked up under another and authentication
|
||||||
# HELO also stops matching the PTR record and mail that does go out lands in
|
# silently fails for every application, while HELO also stops matching the
|
||||||
# spam. No fallback can be correct, so fail loudly here, before either side of
|
# PTR record and mail that does go out lands in spam. No fallback can be
|
||||||
# that split has a chance to run, rather than leave a green panel with broken
|
# correct, so fail loudly here, before either side of that split has a chance
|
||||||
# mail.
|
# to run, rather than leave a green panel with broken mail.
|
||||||
if [ -z "$SELFPOST_HOSTNAME" ]; then
|
if [ -z "$SELFPOST_HOSTNAME" ]; then
|
||||||
cat >&2 <<'EOF'
|
cat >&2 <<'EOF'
|
||||||
FATAL: SELFPOST_HOSTNAME is not set.
|
FATAL: SELFPOST_HOSTNAME is not set.
|
||||||
@@ -52,11 +52,11 @@ case "$SELFPOST_HOSTNAME" in
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# The persistent root /data is a host bind mount, so it arrives owned by the
|
# The persistent root /data is a host bind mount (spec 9), so it arrives owned
|
||||||
# host user (typically root), not by the unprivileged panel user that actually
|
# by the host user (typically root), not by the unprivileged panel user that
|
||||||
# writes the SQLite database, setup token and DKIM keys. Fix its ownership here
|
# actually writes the SQLite database, setup token and DKIM keys (spec 7.6.8).
|
||||||
# — the one place still running as root — before handing off to supervisord,
|
# Fix its ownership here — the one place still running as root — before handing
|
||||||
# which starts the panel as the panel user.
|
# off to supervisord, which starts the panel as the panel user.
|
||||||
#
|
#
|
||||||
# Mode must stay world-traversable (0755): OpenDKIM and Postfix reach their
|
# 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
|
# 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
|
# 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
|
# deliberately owned by postfix (postlogd writes the delivery log there) and is
|
||||||
# normalised on its own below.
|
# normalised on its own below.
|
||||||
find /data -mindepth 1 -maxdepth 1 ! -user panel ! -name log ! -name postfix -exec chown -R panel:panel {} +
|
find /data -mindepth 1 -maxdepth 1 ! -user panel ! -name log -exec chown -R panel:panel {} +
|
||||||
|
|
||||||
# DKIM key tree. The panel (user `panel`) generates keys and writes
|
# DKIM key tree (spec 6, 9). The panel (user `panel`) generates keys and writes
|
||||||
# the OpenDKIM tables; OpenDKIM (user `opendkim`) must read them. Normalise the
|
# 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
|
# tree on every start so it is correct whether /data is fresh, restarted, or
|
||||||
# just restored from a backup:
|
# 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
|
chmod 0640 /data/opendkim/KeyTable /data/opendkim/SigningTable
|
||||||
find /data/opendkim/keys -type f -name '*.private' -exec chmod 0640 {} +
|
find /data/opendkim/keys -type f -name '*.private' -exec chmod 0640 {} +
|
||||||
|
|
||||||
# Application SASL accounts. The panel (user `panel`) writes the
|
# Application SASL accounts (spec 5.1, 9). The panel (user `panel`) writes the
|
||||||
# sasldb2 via saslpasswd2; Postfix (user `postfix`) reads it to authenticate SMTP
|
# 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:
|
# 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
|
# setgid directory so new files inherit the group, and the database itself
|
||||||
@@ -99,20 +99,13 @@ chown -R panel:selfpost /data/sasl
|
|||||||
chmod 2750 /data/sasl
|
chmod 2750 /data/sasl
|
||||||
[ -e /data/sasl/sasldb2 ] && chmod 0640 /data/sasl/sasldb2
|
[ -e /data/sasl/sasldb2 ] && chmod 0640 /data/sasl/sasldb2
|
||||||
|
|
||||||
# Postfix state under /data (architecture.md § Persistence). The panel writes
|
# Postfix sender_login_maps (spec 5.1). The panel writes it; Postfix reads it.
|
||||||
# sender_login_maps; Postfix owns the on-disk queue tree under queue/.
|
# Ensure the file exists (empty is fine) before Postfix starts so a reload that
|
||||||
mkdir -p /data/postfix/queue
|
# references it never fails on a missing file, and keep it group-readable.
|
||||||
|
mkdir -p /data/postfix
|
||||||
[ -e /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps
|
[ -e /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps
|
||||||
# Inbound relay maps (written by the panel when INBOUND_RELAY_ENABLE=true).
|
chown -R panel:selfpost /data/postfix
|
||||||
# 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
|
chmod 2750 /data/postfix
|
||||||
chown panel:selfpost /data/postfix/sender_login_maps
|
|
||||||
chmod 0640 /data/postfix/sender_login_maps
|
chmod 0640 /data/postfix/sender_login_maps
|
||||||
|
|
||||||
# Delivery log (architecture.md § Log tailer). postlogd writes it as user
|
# Delivery log (architecture.md § Log tailer). postlogd writes it as user
|
||||||
@@ -132,14 +125,14 @@ chown -R postfix:selfpost /data/log
|
|||||||
chmod 2750 /data/log
|
chmod 2750 /data/log
|
||||||
find /data/log -type f -exec chmod 0640 {} +
|
find /data/log -type f -exec chmod 0640 {} +
|
||||||
|
|
||||||
# Milter socket directories. Postfix (user `postfix`) must actually CONNECT to
|
# Milter socket directories (spec 5 p.3, 7.3). Postfix (user `postfix`) must
|
||||||
# both milter sockets — OpenDKIM's and the panel's journal-milter — not just
|
# actually CONNECT to both milter sockets — OpenDKIM's and the panel's
|
||||||
# probe them at start-up. The sockets are created by the opendkim and panel
|
# journal-milter — not just probe them at start-up. The sockets are
|
||||||
# users respectively, so bridge them to `postfix` through the shared `selfpost`
|
# created by the opendkim and panel users respectively, so bridge them to
|
||||||
# group: group-owned + setgid dirs mean each socket created inside inherits
|
# `postfix` through the shared `selfpost` group: group-owned + setgid dirs mean
|
||||||
# group `selfpost`, and group-traversable (2750) lets postfix reach it. Without
|
# each socket created inside inherits group `selfpost`, and group-traversable
|
||||||
# this, smtpd cannot talk to OpenDKIM and, because signing is strict
|
# (2750) lets postfix reach it. Without this, smtpd cannot talk to OpenDKIM and,
|
||||||
# (default_action=tempfail), rejects all mail.
|
# because signing is strict (default_action=tempfail), rejects all mail.
|
||||||
mkdir -p /run/opendkim /run/selfpost
|
mkdir -p /run/opendkim /run/selfpost
|
||||||
chown opendkim:selfpost /run/opendkim
|
chown opendkim:selfpost /run/opendkim
|
||||||
chown panel:selfpost /run/selfpost
|
chown panel:selfpost /run/selfpost
|
||||||
@@ -151,22 +144,4 @@ chmod 2750 /run/opendkim /run/selfpost
|
|||||||
# start the same way the /data normalisation above is.
|
# start the same way the /data normalisation above is.
|
||||||
/usr/local/bin/postfix-config.sh
|
/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
|
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Periodic logrotate for /data/log/mail.log. Rotation renames the file,
|
# Periodic logrotate for /data/log/mail.log (spec 9, 10). Rotation renames the
|
||||||
# recreates it (`create 0640 postfix selfpost`, matching a cold container
|
# file, recreates it (`create 0640 postfix selfpost`, matching a cold container
|
||||||
# start), then runs `postfix reload` (the same mechanism `postfix logrotate`
|
# start), then runs `postfix reload` (the same mechanism `postfix logrotate`
|
||||||
# uses): postlogd keeps writing to the renamed inode until reload, and the
|
# 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
|
# panel's log-tailer holds its own descriptor on that inode, so nothing
|
||||||
# before the reload is lost. `create` (rather than `nocreate`) matters here
|
# written before the reload is lost. `create` (rather than `nocreate`) matters
|
||||||
# beyond timing: a postlogd-triggered recreate lands the file at 0600 owned by
|
# here beyond timing: a postlogd-triggered recreate lands the file at 0600
|
||||||
# postfix, which the unprivileged panel process cannot read — confirmed on a
|
# owned by postfix, which the unprivileged panel process cannot read —
|
||||||
# live container — so logrotate must be the one to create it.
|
# 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
|
# 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
|
# (tracked in /var/lib/logrotate/status), so it is safe to invoke this more
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# OpenDKIM — per-domain signing.
|
# OpenDKIM — per-domain signing (spec 6).
|
||||||
#
|
#
|
||||||
# Signing is strictly per-domain: each sending domain has its own key pair and
|
# 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
|
# selector. The panel generates keys under /data/opendkim/keys/<domain>/ and
|
||||||
# keeps KeyTable/SigningTable in sync as domains are added or removed, then asks
|
# keeps KeyTable/SigningTable in sync as domains are added or removed, then asks
|
||||||
# supervisord to send this process SIGUSR1 to reload the tables.
|
# supervisord to send this process SIGUSR1 to reload the tables (spec 6.2, 6.5).
|
||||||
#
|
#
|
||||||
# Both table files are created (empty) by entrypoint.sh before OpenDKIM starts,
|
# 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
|
# so the daemon comes up cleanly on a fresh /data with no domains yet and simply
|
||||||
|
|||||||
@@ -1,72 +1,28 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Generate the outbound-relay Postfix configuration.
|
# Generate the outbound-relay Postfix configuration (spec 5, 5.1, 5.2).
|
||||||
#
|
#
|
||||||
# Run as root from entrypoint.sh on every container start, BEFORE supervisord
|
# 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
|
# 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
|
# 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
|
# 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
|
# (the only panel-edited Postfix file, sender_login_maps, lives under /data and
|
||||||
# is generated separately by the panel).
|
# is generated separately by the panel — spec 5.1).
|
||||||
#
|
#
|
||||||
# main.cf is written with `postconf -e`, the master.cf submission services with
|
# 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
|
# `postconf -M`/`-P`. No user input is interpolated: every value here comes from
|
||||||
# a fixed literal or a container environment variable.
|
# a fixed literal or a container environment variable (spec 7.6.3).
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
# Sets one -o option of the smtp/inet (port 25) service by patching its stanza.
|
# --- environment knobs (spec 8) ----------------------------------------------
|
||||||
#
|
|
||||||
# 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
|
# 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
|
# sasldb2 accounts are looked up under. The panel creates accounts under realm
|
||||||
# $SELFPOST_HOSTNAME (SASL_REALM), so myhostname MUST match or authentication
|
# $SELFPOST_HOSTNAME (SASL_REALM), so myhostname MUST match or authentication
|
||||||
# fails. Fall back to the container hostname only outside a real deployment.
|
# fails. Fall back to the container hostname only outside a real deployment.
|
||||||
HOSTNAME_VALUE="${SELFPOST_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}"
|
HOSTNAME_VALUE="${SELFPOST_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}"
|
||||||
|
|
||||||
# TLS material supplied by the reverse-proxy through a read-only bind mount. The
|
# TLS material supplied by the reverse-proxy through a read-only bind mount
|
||||||
# relay requires TLS on 465; if these files are absent the master still starts
|
# (spec 5.2). The relay requires TLS on 465; if these files are absent the
|
||||||
# but TLS handshakes on 465 fail until they appear.
|
# 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
|
# 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
|
# is often :ro and owned by the host user (e2e TempDir / CI runner UID), so
|
||||||
@@ -88,8 +44,8 @@ if [ -f "$TLS_CERT_SRC" ] && [ -f "$TLS_KEY_SRC" ]; then
|
|||||||
TLS_KEY="$TLS_INTERNAL_DIR/privkey.pem"
|
TLS_KEY="$TLS_INTERNAL_DIR/privkey.pem"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Level-1 rate limit (native Postfix anvil). Conservative defaults, sensible
|
# Level-1 rate limit (native Postfix anvil, spec 5 p.5 / 7.4). Conservative
|
||||||
# during IP warm-up.
|
# defaults, sensible during IP warm-up (spec 10).
|
||||||
RATE_MSGS="${RATE_LIMIT_MESSAGES_PER_IP:-100}"
|
RATE_MSGS="${RATE_LIMIT_MESSAGES_PER_IP:-100}"
|
||||||
RATE_WINDOW="${RATE_LIMIT_WINDOW_SECONDS:-3600}"
|
RATE_WINDOW="${RATE_LIMIT_WINDOW_SECONDS:-3600}"
|
||||||
|
|
||||||
@@ -98,41 +54,15 @@ RATE_WINDOW="${RATE_LIMIT_WINDOW_SECONDS:-3600}"
|
|||||||
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}"
|
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}"
|
||||||
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
|
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
|
||||||
|
|
||||||
# Persistent panel-managed sender map; texthash needs no postmap, so the
|
# Persistent panel-managed sender map (spec 5.1); texthash needs no postmap, so
|
||||||
# unprivileged panel can rewrite it and just ask for a reload.
|
# the unprivileged panel can rewrite it and just ask for a reload.
|
||||||
SENDER_LOGIN_MAPS="${POSTFIX_SENDER_LOGIN_MAPS:-/data/postfix/sender_login_maps}"
|
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}"
|
SASLDB_PATH="${SASL_DB_PATH:-/data/sasl/sasldb2}"
|
||||||
|
|
||||||
# Optional submission service on 587 (off by default, enabled only when a client
|
# Optional submission service on 587 (spec 5 p.1: off by default, enabled only
|
||||||
# library needs STARTTLS on 587 instead of implicit TLS on 465).
|
# when a client library needs STARTTLS on 587 instead of implicit TLS on 465).
|
||||||
SUBMISSION_ENABLE="${SUBMISSION_ENABLE:-false}"
|
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}"
|
|
||||||
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
|
# 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
|
# under the persistent /data (not the ephemeral /var/log) so the delivery lines
|
||||||
# for messages still marked "queued" survive a container recreate — without
|
# for messages still marked "queued" survive a container recreate — without
|
||||||
@@ -166,13 +96,13 @@ postconf -e \
|
|||||||
"alias_maps=" \
|
"alias_maps=" \
|
||||||
"alias_database="
|
"alias_database="
|
||||||
|
|
||||||
# Outbound delivery: straight to the recipient MX, opportunistic TLS.
|
# Outbound delivery: straight to the recipient MX, opportunistic TLS (spec 5 p.2).
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"smtp_tls_security_level=may" \
|
"smtp_tls_security_level=may" \
|
||||||
"smtp_tls_note_starttls_offer=yes"
|
"smtp_tls_note_starttls_offer=yes"
|
||||||
|
|
||||||
# TLS server material shared by every inbound service. auth_only guarantees
|
# TLS server material shared by every inbound service (spec 5.2). auth_only
|
||||||
# credentials are never accepted before TLS is up on any port.
|
# guarantees credentials are never accepted before TLS is up on any port.
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"smtpd_tls_cert_file=${TLS_CERT}" \
|
"smtpd_tls_cert_file=${TLS_CERT}" \
|
||||||
"smtpd_tls_key_file=${TLS_KEY}" \
|
"smtpd_tls_key_file=${TLS_KEY}" \
|
||||||
@@ -180,11 +110,11 @@ postconf -e \
|
|||||||
"smtpd_tls_auth_only=yes" \
|
"smtpd_tls_auth_only=yes" \
|
||||||
"smtpd_tls_loglevel=1"
|
"smtpd_tls_loglevel=1"
|
||||||
|
|
||||||
# SASL: Cyrus with the local sasldb2 the panel maintains. The realm is left
|
# SASL: Cyrus with the local sasldb2 the panel maintains (spec 5.1). The realm
|
||||||
# implicit (smtpd_sasl_local_domain empty) so the authenticated name Postfix
|
# is left implicit (smtpd_sasl_local_domain empty) so the authenticated name
|
||||||
# uses for sender_login_maps is the BARE login the panel writes into the map;
|
# Postfix uses for sender_login_maps is the BARE login the panel writes into the
|
||||||
# the sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname as
|
# map; the sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname
|
||||||
# the server realm, which equals the realm the accounts were created under.
|
# as the server realm, which equals the realm the accounts were created under.
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"smtpd_sasl_auth_enable=yes" \
|
"smtpd_sasl_auth_enable=yes" \
|
||||||
"smtpd_sasl_type=cyrus" \
|
"smtpd_sasl_type=cyrus" \
|
||||||
@@ -194,42 +124,42 @@ postconf -e \
|
|||||||
"smtpd_sasl_tls_security_options=noanonymous" \
|
"smtpd_sasl_tls_security_options=noanonymous" \
|
||||||
"broken_sasl_auth_clients=yes"
|
"broken_sasl_auth_clients=yes"
|
||||||
|
|
||||||
# Sender binding (the critical anti-spoofing control). texthash resolves the
|
# Sender binding (spec 5.1 p.3, the critical anti-spoofing control). texthash
|
||||||
# full address first, then the "@domain" wildcard, so both address modes work
|
# resolves the full address first, then the "@domain" wildcard, so both address
|
||||||
# from the same map.
|
# modes work from the same map.
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}" \
|
"smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}"
|
||||||
"queue_directory=${QUEUE_DIR}"
|
|
||||||
|
|
||||||
# Restrictions: authenticated clients only, no relay to foreign destinations,
|
# Restrictions: authenticated clients only, no relay to foreign destinations,
|
||||||
# and every authenticated sender address must be owned by its login. NO
|
# and every authenticated sender address must be owned by its login. NO
|
||||||
# permit_mynetworks anywhere — authorisation is by credentials, never by
|
# permit_mynetworks anywhere — authorisation is by credentials, never by network
|
||||||
# network. This is what makes an open relay impossible.
|
# (spec 5 p.1/p.4, 5.1). This is what makes an open relay impossible.
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"smtpd_helo_required=yes" \
|
"smtpd_helo_required=yes" \
|
||||||
"smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
|
"smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
|
||||||
"smtpd_recipient_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
|
"smtpd_recipient_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
|
||||||
"smtpd_sender_restrictions=reject_sender_login_mismatch, permit"
|
"smtpd_sender_restrictions=reject_sender_login_mismatch, permit"
|
||||||
|
|
||||||
# Level-1 rate limit by client IP. Backstop that keeps working even if the
|
# Level-1 rate limit by client IP (spec 5 p.5). Backstop that keeps working even
|
||||||
# journal-milter (level 2) is down.
|
# if the journal-milter (level 2) is down.
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"smtpd_client_message_rate_limit=${RATE_MSGS}" \
|
"smtpd_client_message_rate_limit=${RATE_MSGS}" \
|
||||||
"anvil_rate_time_unit=${RATE_WINDOW}s"
|
"anvil_rate_time_unit=${RATE_WINDOW}s"
|
||||||
|
|
||||||
# Milter chain. OpenDKIM signs and is treated strictly (default_action=tempfail:
|
# Milter chain (spec 5 p.3, 7.3). OpenDKIM signs and is treated strictly
|
||||||
# if it is unreachable, defer rather than send unsigned). The journal-milter is
|
# (default_action=tempfail: if it is unreachable, defer rather than send
|
||||||
# monitoring only and is fail-open (default_action=accept): its failure must
|
# unsigned). The journal-milter is monitoring only and is fail-open
|
||||||
# never block the relay. Per-milter settings use Postfix 3.0+ brace syntax.
|
# (default_action=accept): its failure must never block the relay. Per-milter
|
||||||
|
# settings use Postfix 3.0+ brace syntax.
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"milter_protocol=6" \
|
"milter_protocol=6" \
|
||||||
"milter_default_action=tempfail" \
|
"milter_default_action=tempfail" \
|
||||||
"smtpd_milters={ unix:${OPENDKIM_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \
|
"smtpd_milters={ unix:${OPENDKIM_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \
|
||||||
"non_smtpd_milters="
|
"non_smtpd_milters="
|
||||||
|
|
||||||
# Bounded milter timeouts: a *hung* milter (socket accepts but never replies)
|
# Bounded milter timeouts (spec 7.3): a *hung* milter (socket accepts but never
|
||||||
# must fail open just like a crash, not stall mail acceptance until the Postfix
|
# replies) must fail open just like a crash, not stall mail acceptance until the
|
||||||
# defaults (300s content) elapse. With default_action per milter, a
|
# Postfix defaults (300s content) elapse. With default_action per milter, a
|
||||||
# journal-milter hang then resolves to accept and an OpenDKIM hang to tempfail,
|
# journal-milter hang then resolves to accept and an OpenDKIM hang to tempfail,
|
||||||
# but within seconds rather than minutes. Values are well above any healthy
|
# 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
|
# response time (signing/DB insert are sub-second), so they never fire in normal
|
||||||
@@ -240,8 +170,8 @@ postconf -e \
|
|||||||
"milter_content_timeout=${MILTER_CONTENT_TIMEOUT:-30s}"
|
"milter_content_timeout=${MILTER_CONTENT_TIMEOUT:-30s}"
|
||||||
|
|
||||||
# --- master.cf: inbound submission services ----------------------------------
|
# --- master.cf: inbound submission services ----------------------------------
|
||||||
# smtps (465, implicit/wrapper TLS) — the primary, always-on submission
|
# smtps (465, implicit/wrapper TLS) — the primary, always-on submission service
|
||||||
# service. chroot=n so smtpd can read the sasldb2 and sender map under /data
|
# (spec 5 p.1). chroot=n so smtpd can read the sasldb2 and sender map under /data
|
||||||
# and the Cyrus config outside any chroot.
|
# and the Cyrus config outside any chroot.
|
||||||
postconf -M "smtps/inet=smtps inet n - n - - smtpd"
|
postconf -M "smtps/inet=smtps inet n - n - - smtpd"
|
||||||
postconf -P \
|
postconf -P \
|
||||||
@@ -249,10 +179,10 @@ postconf -P \
|
|||||||
"smtps/inet/smtpd_sasl_auth_enable=yes" \
|
"smtps/inet/smtpd_sasl_auth_enable=yes" \
|
||||||
"smtps/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"
|
"smtps/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"
|
||||||
|
|
||||||
# submission (587, STARTTLS) — optional. Same SASL/milter/limits; the only
|
# submission (587, STARTTLS) — optional (spec 5 p.1). Same SASL/milter/limits;
|
||||||
# difference is TLS is negotiated via STARTTLS, so require encryption before
|
# the only difference is TLS is negotiated via STARTTLS, so require encryption
|
||||||
# auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a restart
|
# before auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a
|
||||||
# after disabling it does not leave the port listening.
|
# restart after disabling it does not leave the port listening.
|
||||||
if [ "${SUBMISSION_ENABLE}" = "true" ]; then
|
if [ "${SUBMISSION_ENABLE}" = "true" ]; then
|
||||||
postconf -M "submission/inet=submission inet n - n - - smtpd"
|
postconf -M "submission/inet=submission inet n - n - - smtpd"
|
||||||
postconf -P \
|
postconf -P \
|
||||||
@@ -263,158 +193,18 @@ else
|
|||||||
postconf -MX "submission/inet" 2>/dev/null || true
|
postconf -MX "submission/inet" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- inbound smtpd on port 25 (optional inbound relay and/or DMARC ingest) -----
|
# Disable chroot for every service (spec 5 p.2). Debian ships the smtp delivery
|
||||||
# Debian's stock master.cf enables smtp/inet. When both flags are off, remove
|
# agent and others chrooted to /var/spool/postfix, where they cannot read
|
||||||
# that listener so port 25 is not an inbound smtpd (outbound delivery uses
|
# /etc/resolv.conf — so outbound MX lookups fail with "Host not found" and mail
|
||||||
# smtp/unix). When either is on: no SASL, no OpenDKIM; relay accepts only
|
# never leaves. Inside a container the chroot buys little (the container is the
|
||||||
# configured domains/recipients; DMARC accepts only allow-listed report addresses.
|
# isolation boundary) and breaks DNS/TLS trust-store access, so turn it off
|
||||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
# uniformly. Our own smtps/submission services are already n; this covers the
|
||||||
RELAY_DOMAINS_SETTING=""
|
# delivery agents and the rest.
|
||||||
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.
|
|
||||||
postconf -M 'dmarc-ingest/unix=dmarc-ingest unix - n n - - 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.
|
|
||||||
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: either the optional antispam milter, or nothing at all.
|
|
||||||
INBOUND_MILTERS=""
|
|
||||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ -n "${INBOUND_ANTISPAM_MILTER}" ]; then
|
|
||||||
case "${INBOUND_ANTISPAM_MILTER}" in
|
|
||||||
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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
postconf -M "smtp/inet=smtp inet n - n - - smtpd"
|
|
||||||
postconf -P \
|
|
||||||
"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"
|
postconf -F "*/*/chroot=n"
|
||||||
|
|
||||||
# --- Cyrus SASL app config for smtpd -----------------------------------------
|
# --- Cyrus SASL app config for smtpd -----------------------------------------
|
||||||
# Tells the Cyrus library (invoked by smtpd via smtpd_sasl_path=smtpd) to verify
|
# Tells the Cyrus library (invoked by smtpd via smtpd_sasl_path=smtpd) to verify
|
||||||
# passwords straight from the panel-maintained sasldb2. PLAIN/LOGIN
|
# passwords straight from the panel-maintained sasldb2 (spec 5.1). PLAIN/LOGIN
|
||||||
# only — both are safe because TLS is mandatory before auth on every port.
|
# only — both are safe because TLS is mandatory before auth on every port.
|
||||||
mkdir -p /etc/postfix/sasl
|
mkdir -p /etc/postfix/sasl
|
||||||
cat > /etc/postfix/sasl/smtpd.conf <<EOF
|
cat > /etc/postfix/sasl/smtpd.conf <<EOF
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Postfix start wrapper: solves the cold-start race where Postfix would try to
|
# Postfix start wrapper (spec 4): solves the cold-start race where Postfix would
|
||||||
# reach the milter sockets before they are listening.
|
# try to reach the milter sockets before they are listening.
|
||||||
#
|
#
|
||||||
# It blocks until BOTH milter sockets — OpenDKIM and the panel's journal-milter
|
# 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
|
# — are present, then execs `postfix start-fg`. If they are not ready within the
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
; SelfPost process supervision.
|
; SelfPost process supervision (spec 4).
|
||||||
;
|
;
|
||||||
; Start ORDER is enforced by priority=: OpenDKIM, then the panel (which opens
|
; Start ORDER is enforced by priority=: OpenDKIM, then the panel (which opens
|
||||||
; the journal-milter socket), then the Postfix wrapper — which additionally
|
; the journal-milter socket), then the Postfix wrapper — which additionally
|
||||||
@@ -18,8 +18,8 @@ loglevel=info
|
|||||||
|
|
||||||
; The control socket is reachable by the shared `selfpost` group so the panel
|
; The control socket is reachable by the shared `selfpost` group so the panel
|
||||||
; (running as the unprivileged `panel` user) can ask supervisord to send
|
; (running as the unprivileged `panel` user) can ask supervisord to send
|
||||||
; OpenDKIM a reload signal after it rewrites the DKIM tables. It is still
|
; OpenDKIM a reload signal after it rewrites the DKIM tables (spec 6.2). It is
|
||||||
; unreachable by world; only root and the two SelfPost services are in the
|
; 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
|
; group. No HTTP auth is configured because access is gated by socket
|
||||||
; permissions, not the network.
|
; permissions, not the network.
|
||||||
[unix_http_server]
|
[unix_http_server]
|
||||||
@@ -67,15 +67,15 @@ stdout_logfile_maxbytes=0
|
|||||||
stderr_logfile=/dev/stderr
|
stderr_logfile=/dev/stderr
|
||||||
stderr_logfile_maxbytes=0
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
; One-shot reload helper. The panel runs unprivileged and cannot run `postfix
|
; One-shot reload helper (spec 5.2, 7.2.12). The panel runs unprivileged and
|
||||||
; reload` itself, and it cannot signal the Postfix master directly: `postfix
|
; cannot run `postfix reload` itself, and it cannot signal the Postfix master
|
||||||
; start-fg` forks a separate master process, so a signal to the supervised
|
; directly: `postfix start-fg` forks a separate master process, so a signal to
|
||||||
; foreground process never reaches it (unlike OpenDKIM, which runs in the
|
; the supervised foreground process never reaches it (unlike OpenDKIM, which
|
||||||
; foreground as the supervised process itself). Instead the panel asks
|
; runs in the foreground as the supervised process itself). Instead the panel
|
||||||
; supervisord — over the group-accessible control socket — to run this program,
|
; asks supervisord — over the group-accessible control socket — to run this
|
||||||
; which executes the canonical `postfix reload` as root. autostart is off (it
|
; program, which executes the canonical `postfix reload` as root. autostart is
|
||||||
; only runs on demand) and a fast, clean exit is expected, so it never trips the
|
; off (it only runs on demand) and a fast, clean exit is expected, so it never
|
||||||
; crashexit listener.
|
; trips the crashexit listener.
|
||||||
[program:postfix-reload]
|
[program:postfix-reload]
|
||||||
command=/usr/sbin/postfix reload
|
command=/usr/sbin/postfix reload
|
||||||
autostart=false
|
autostart=false
|
||||||
@@ -88,7 +88,7 @@ stdout_logfile_maxbytes=0
|
|||||||
stderr_logfile=/dev/stderr
|
stderr_logfile=/dev/stderr
|
||||||
stderr_logfile_maxbytes=0
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
; Periodic TLS-certificate refresh. Runs a daily `postfix reload`
|
; Periodic TLS-certificate refresh (spec 5.2 p.4). Runs a daily `postfix reload`
|
||||||
; so certificates the reverse-proxy renews in the read-only mount are picked up.
|
; 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
|
; 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.
|
; non-zero, so it neither trips the crashexit listener nor needs restarting.
|
||||||
@@ -103,10 +103,10 @@ stdout_logfile_maxbytes=0
|
|||||||
stderr_logfile=/dev/stderr
|
stderr_logfile=/dev/stderr
|
||||||
stderr_logfile_maxbytes=0
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
; Periodic logrotate for /data/log/mail.log (daily, 7-14 files kept in the
|
; Periodic logrotate for /data/log/mail.log (spec 9, 10: daily, 7-14 files kept
|
||||||
; image). Runs as root so logrotate can read/rotate the log. Exits non-zero when
|
; in the image). Runs as root so logrotate can read/rotate the log. Exits
|
||||||
; the config is group/other-writable (logrotate would ignore it silently);
|
; non-zero when the config is group/other-writable (logrotate would ignore it
|
||||||
; autorestart surfaces BACKOFF on the Status page.
|
; silently); autorestart surfaces BACKOFF on the Status page.
|
||||||
[program:logrotate]
|
[program:logrotate]
|
||||||
command=/usr/local/bin/logrotate-loop.sh
|
command=/usr/local/bin/logrotate-loop.sh
|
||||||
priority=400
|
priority=400
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"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 incrErr := st.IncrDMARCParseFailures(); incrErr != nil {
|
|
||||||
log.Printf("dmarc ingest: record failure: %v", incrErr)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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"
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,14 +15,6 @@ var documentedPublic = []string{
|
|||||||
"PANEL_SESSION_IDLE_DAYS",
|
"PANEL_SESSION_IDLE_DAYS",
|
||||||
"SELFPOST_DNS_RESOLVERS",
|
"SELFPOST_DNS_RESOLVERS",
|
||||||
"TRUSTED_PROXY_CIDR",
|
"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".
|
// documentedInternal matches architecture.md § Configuration "Internal env vars".
|
||||||
@@ -41,15 +33,6 @@ var documentedInternal = []string{
|
|||||||
"SASL_REALM",
|
"SASL_REALM",
|
||||||
"POSTFIX_DIR",
|
"POSTFIX_DIR",
|
||||||
"POSTFIX_SENDER_LOGIN_MAPS",
|
"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_CONNECT_TIMEOUT",
|
||||||
"MILTER_COMMAND_TIMEOUT",
|
"MILTER_COMMAND_TIMEOUT",
|
||||||
"MILTER_CONTENT_TIMEOUT",
|
"MILTER_CONTENT_TIMEOUT",
|
||||||
@@ -87,11 +70,6 @@ var loadConfigKeys = []string{
|
|||||||
"SASL_DB_PATH",
|
"SASL_DB_PATH",
|
||||||
"SASL_REALM",
|
"SASL_REALM",
|
||||||
"POSTFIX_DIR",
|
"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
|
// buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh
|
||||||
@@ -106,24 +84,8 @@ var buildScriptKeys = []string{
|
|||||||
"JOURNAL_MILTER_SOCKET",
|
"JOURNAL_MILTER_SOCKET",
|
||||||
"MAIL_LOG",
|
"MAIL_LOG",
|
||||||
"POSTFIX_SENDER_LOGIN_MAPS",
|
"POSTFIX_SENDER_LOGIN_MAPS",
|
||||||
"POSTFIX_QUEUE_DIR",
|
|
||||||
"SASL_DB_PATH",
|
"SASL_DB_PATH",
|
||||||
"SUBMISSION_ENABLE",
|
"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_CONNECT_TIMEOUT",
|
||||||
"MILTER_COMMAND_TIMEOUT",
|
"MILTER_COMMAND_TIMEOUT",
|
||||||
"MILTER_CONTENT_TIMEOUT",
|
"MILTER_CONTENT_TIMEOUT",
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/app"
|
"github.com/mixeme/selfpost/internal/app"
|
||||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||||
"github.com/mixeme/selfpost/internal/dmarc"
|
|
||||||
"github.com/mixeme/selfpost/internal/domain"
|
"github.com/mixeme/selfpost/internal/domain"
|
||||||
"github.com/mixeme/selfpost/internal/inbound"
|
|
||||||
"github.com/mixeme/selfpost/internal/postfix"
|
"github.com/mixeme/selfpost/internal/postfix"
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
"github.com/mixeme/selfpost/internal/web"
|
"github.com/mixeme/selfpost/internal/web"
|
||||||
@@ -21,14 +19,10 @@ import (
|
|||||||
// mailStack is the panel's domain and application services plus the on-disk
|
// mailStack is the panel's domain and application services plus the on-disk
|
||||||
// mail-path adapters they write through.
|
// mail-path adapters they write through.
|
||||||
type mailStack struct {
|
type mailStack struct {
|
||||||
Domains *domain.Service
|
Domains *domain.Service
|
||||||
Apps *app.Service
|
Apps *app.Service
|
||||||
Inbound *inbound.Service
|
pf *postfix.Postfix
|
||||||
DMARC *dmarc.Service
|
odk *domain.OpenDKIM
|
||||||
pf *postfix.Postfix
|
|
||||||
odk *domain.OpenDKIM
|
|
||||||
inboundEnabled bool
|
|
||||||
dmarcEnabled bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMailStack(cfg config, st *store.Store) *mailStack {
|
func newMailStack(cfg config, st *store.Store) *mailStack {
|
||||||
@@ -36,12 +30,7 @@ func newMailStack(cfg config, st *store.Store) *mailStack {
|
|||||||
odk := domain.NewOpenDKIM(cfg.opendkimDir)
|
odk := domain.NewOpenDKIM(cfg.opendkimDir)
|
||||||
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
|
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
|
||||||
domains := domain.NewService(st, odk, apps, cfg.dkimSelectorDef)
|
domains := domain.NewService(st, odk, apps, cfg.dkimSelectorDef)
|
||||||
inb := inbound.NewService(st, pf)
|
return &mailStack{Domains: domains, Apps: apps, pf: pf, odk: odk}
|
||||||
dmarcSvc := dmarc.NewService(st, pf, cfg.hostname, cfg.dmarcEnabled)
|
|
||||||
return &mailStack{
|
|
||||||
Domains: domains, Apps: apps, Inbound: inb, DMARC: dmarcSvc,
|
|
||||||
pf: pf, odk: odk, inboundEnabled: cfg.inboundEnabled, dmarcEnabled: cfg.dmarcEnabled,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resync rebuilds OpenDKIM's tables and Postfix's sender map from SQLite and
|
// Resync rebuilds OpenDKIM's tables and Postfix's sender map from SQLite and
|
||||||
@@ -53,16 +42,6 @@ func (m *mailStack) Resync() error {
|
|||||||
if err := m.Apps.Resync(); err != nil {
|
if err := m.Apps.Resync(); err != nil {
|
||||||
return fmt.Errorf("postfix resync: %w", err)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if m.dmarcEnabled {
|
|
||||||
if err := m.DMARC.Resync(); err != nil {
|
|
||||||
return fmt.Errorf("dmarc maps resync: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,33 +66,22 @@ func resyncAfterRestore(cfg config, st *store.Store, testNoReload bool) error {
|
|||||||
// the environment describes it, with nothing bound to a port yet.
|
// the environment describes it, with nothing bound to a port yet.
|
||||||
func newPanel(cfg config, st *store.Store) (*web.Server, error) {
|
func newPanel(cfg config, st *store.Store) (*web.Server, error) {
|
||||||
ms := newMailStack(cfg, st)
|
ms := newMailStack(cfg, st)
|
||||||
// postfix-config.sh has already run (entrypoint). postconf -h is the
|
return web.New(st, ms.Domains, ms.Apps, web.Config{
|
||||||
// effective config, including a manual override; the panel keeps this
|
Hostname: cfg.hostname,
|
||||||
// snapshot for the process lifetime (architecture.md).
|
CookieSecure: cfg.cookieSecure,
|
||||||
retryPolicy := postfix.LoadRetryPolicy()
|
SubmissionEnabled: cfg.submissionEnabled,
|
||||||
return web.New(st, ms.Domains, ms.Apps, ms.Inbound, ms.DMARC, web.Config{
|
MailLogPath: cfg.mailLog,
|
||||||
Hostname: cfg.hostname,
|
DataDir: cfg.dataDir,
|
||||||
CookieSecure: cfg.cookieSecure,
|
DBPath: cfg.dbPath,
|
||||||
SubmissionEnabled: cfg.submissionEnabled,
|
Version: buildinfo.Version,
|
||||||
MailLogPath: cfg.mailLog,
|
TrustedProxyCIDRs: cfg.trustedProxies,
|
||||||
DataDir: cfg.dataDir,
|
TLSCertFile: cfg.tlsCertFile,
|
||||||
DBPath: cfg.dbPath,
|
OpenDKIMSocket: cfg.opendkimSocket,
|
||||||
DeployRoot: cfg.deployRoot,
|
JournalSocket: cfg.journalSocket,
|
||||||
Version: buildinfo.Version,
|
SessionIdleDays: cfg.sessionIdleDays,
|
||||||
TrustedProxyCIDRs: cfg.trustedProxies,
|
DNSResolvers: cfg.dnsResolvers,
|
||||||
TLSCertFile: cfg.tlsCertFile,
|
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
|
||||||
OpenDKIMSocket: cfg.opendkimSocket,
|
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
|
||||||
JournalSocket: cfg.journalSocket,
|
|
||||||
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,
|
|
||||||
}, cfg.setupTokenPath)
|
}, cfg.setupTokenPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,16 +28,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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")
|
showVersion := flag.Bool("version", false, "print version and exit")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -88,15 +78,6 @@ type config struct {
|
|||||||
saslDBPath string
|
saslDBPath string
|
||||||
saslRealm string
|
saslRealm string
|
||||||
postfixDir string
|
postfixDir string
|
||||||
deployRoot 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
|
|
||||||
dmarcEnabled bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() config {
|
func loadConfig() config {
|
||||||
@@ -157,30 +138,9 @@ func loadConfig() config {
|
|||||||
saslDBPath: envDefault("SASL_DB_PATH", filepath.Join(dataDir, "sasl", "sasldb2")),
|
saslDBPath: envDefault("SASL_DB_PATH", filepath.Join(dataDir, "sasl", "sasldb2")),
|
||||||
saslRealm: saslRealm(),
|
saslRealm: saslRealm(),
|
||||||
postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")),
|
postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")),
|
||||||
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")),
|
|
||||||
// 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
|
// 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
|
// hostname Postfix's SASL layer uses so a client authenticating with a bare
|
||||||
// login resolves to the right account.
|
// login resolves to the right account.
|
||||||
@@ -281,20 +241,10 @@ func run() error {
|
|||||||
if err := resyncAfterRestore(cfg, st, false); err != nil {
|
if err := resyncAfterRestore(cfg, st, false); err != nil {
|
||||||
return err
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := st.EnsureSendLogRetentionDays(cfg.retentionDays); err != nil {
|
|
||||||
return fmt.Errorf("bootstrap send-log retention: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
errc := make(chan error, 4)
|
errc := make(chan error, 3)
|
||||||
|
|
||||||
roles := []struct {
|
roles := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -302,20 +252,7 @@ func run() error {
|
|||||||
}{
|
}{
|
||||||
{"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg, st) }},
|
{"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg, st) }},
|
||||||
{"journal-milter", func(ctx context.Context) error { return serveJournal(ctx, cfg, st) }},
|
{"journal-milter", func(ctx context.Context) error { return serveJournal(ctx, cfg, st) }},
|
||||||
{"log-tailer", func(ctx context.Context) error {
|
{"log-tailer", func(ctx context.Context) error { return logtail.Run(ctx, cfg.mailLog, st, cfg.retentionDays) }},
|
||||||
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
|
|
||||||
})
|
|
||||||
}},
|
|
||||||
{"rate-limit-recalc", func(ctx context.Context) error {
|
|
||||||
runAutoRateLimitRecalc(ctx, cfg, st)
|
|
||||||
return nil
|
|
||||||
}},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, r := range roles {
|
for _, r := range roles {
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
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
|
// Restoring a SelfPost backup is not a code path in the panel: the operator
|
||||||
// extracts the archive into a project directory and starts the image, and the
|
// extracts the archive into the /data bind mount and starts the image, and the
|
||||||
// panel is expected to come up on it (architecture.md § Persistence). Nothing
|
// 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
|
// 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
|
// through /backup, unpacked the way `tar -xzf` unpacks it, and a second panel
|
||||||
@@ -37,10 +37,9 @@ const (
|
|||||||
|
|
||||||
// restored is the outcome of a full backup-and-restore round trip.
|
// restored is the outcome of a full backup-and-restore round trip.
|
||||||
type restored struct {
|
type restored struct {
|
||||||
panel http.Handler // panel booted on the restored data directory
|
panel http.Handler // panel booted on the restored data directory
|
||||||
deployRoot string // the restored project directory
|
dataDir string // the restored /data
|
||||||
dataDir string // the restored /data
|
session *http.Cookie // a session opened before the backup was taken
|
||||||
session *http.Cookie // a session opened before the backup was taken
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// restoreFromOwnBackup runs the operator's path end to end: seed a panel that
|
// restoreFromOwnBackup runs the operator's path end to end: seed a panel that
|
||||||
@@ -51,7 +50,7 @@ type restored struct {
|
|||||||
func restoreFromOwnBackup(t *testing.T, password string) restored {
|
func restoreFromOwnBackup(t *testing.T, password string) restored {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
live := seedPanelProject(t)
|
live := seedPanelData(t)
|
||||||
panel := bootPanel(t, live)
|
panel := bootPanel(t, live)
|
||||||
session := signIn(t, panel)
|
session := signIn(t, panel)
|
||||||
archive := downloadBackup(t, panel, session, password)
|
archive := downloadBackup(t, panel, session, password)
|
||||||
@@ -59,12 +58,7 @@ func restoreFromOwnBackup(t *testing.T, password string) restored {
|
|||||||
target := t.TempDir()
|
target := t.TempDir()
|
||||||
extract(t, archive, target)
|
extract(t, archive, target)
|
||||||
|
|
||||||
return restored{
|
return restored{panel: bootPanel(t, target), dataDir: target, session: session}
|
||||||
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
|
// The panel has to come up on the restored directory and show the state that
|
||||||
@@ -74,7 +68,7 @@ func restoreFromOwnBackup(t *testing.T, password string) restored {
|
|||||||
func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
|
func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
|
||||||
r := restoreFromOwnBackup(t, "")
|
r := restoreFromOwnBackup(t, "")
|
||||||
|
|
||||||
body := getPage(t, r.panel, "/outbound/log", signIn(t, r.panel))
|
body := getPage(t, r.panel, "/deliveries", signIn(t, r.panel))
|
||||||
for _, want := range []string{restoreDomain, restoreSubject} {
|
for _, want := range []string{restoreDomain, restoreSubject} {
|
||||||
if !strings.Contains(body, want) {
|
if !strings.Contains(body, want) {
|
||||||
t.Errorf("the restored panel's send log does not show %q:\n%s", want, body)
|
t.Errorf("the restored panel's send log does not show %q:\n%s", want, body)
|
||||||
@@ -97,12 +91,6 @@ func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
|
|||||||
t.Errorf("%s = %q, want %q", path, got, want)
|
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
|
// The one-time setup link is closed by the presence of a panel user, and the
|
||||||
@@ -128,7 +116,7 @@ func TestARestoreDoesNotReopenTheSetupLink(t *testing.T) {
|
|||||||
func TestARestoredPanelHonoursSessionsFromTheArchive(t *testing.T) {
|
func TestARestoredPanelHonoursSessionsFromTheArchive(t *testing.T) {
|
||||||
r := restoreFromOwnBackup(t, "")
|
r := restoreFromOwnBackup(t, "")
|
||||||
|
|
||||||
rec := request(t, r.panel, http.MethodGet, "/outbound/log", nil, r.session)
|
rec := request(t, r.panel, http.MethodGet, "/deliveries", nil, r.session)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Errorf("a session from before the backup = %d on the restored panel, want 200", rec.Code)
|
t.Errorf("a session from before the backup = %d on the restored panel, want 200", rec.Code)
|
||||||
}
|
}
|
||||||
@@ -141,7 +129,7 @@ func TestARestoredPanelHonoursSessionsFromTheArchive(t *testing.T) {
|
|||||||
func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) {
|
func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) {
|
||||||
r := restoreFromOwnBackup(t, "a-long-enough-password")
|
r := restoreFromOwnBackup(t, "a-long-enough-password")
|
||||||
|
|
||||||
body := getPage(t, r.panel, "/outbound/log", signIn(t, r.panel))
|
body := getPage(t, r.panel, "/deliveries", signIn(t, r.panel))
|
||||||
if !strings.Contains(body, restoreSubject) {
|
if !strings.Contains(body, restoreSubject) {
|
||||||
t.Errorf("the panel restored from an encrypted backup lost the send log:\n%s", body)
|
t.Errorf("the panel restored from an encrypted backup lost the send log:\n%s", body)
|
||||||
}
|
}
|
||||||
@@ -150,9 +138,8 @@ func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) {
|
|||||||
// A restore boot runs one Resync from SQLite. If the archive's Postfix map
|
// 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.
|
// drifted from the database, that step puts it back before mail flows.
|
||||||
func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) {
|
func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) {
|
||||||
deployRoot := seedPanelProject(t)
|
dataDir := seedPanelData(t)
|
||||||
dataDir := filepath.Join(deployRoot, "data")
|
cfg := panelConfig(t, dataDir)
|
||||||
cfg := panelConfig(t, deployRoot)
|
|
||||||
|
|
||||||
mapPath := filepath.Join(dataDir, "postfix", "sender_login_maps")
|
mapPath := filepath.Join(dataDir, "postfix", "sender_login_maps")
|
||||||
if err := os.WriteFile(mapPath, []byte("stale map\n"), 0o640); err != nil {
|
if err := os.WriteFile(mapPath, []byte("stale map\n"), 0o640); err != nil {
|
||||||
@@ -204,14 +191,12 @@ func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) {
|
|||||||
// stays put on a mismatch: the operator's next move is to start the image the
|
// 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.
|
// backup names, and it has to be there when they do.
|
||||||
func TestPanelRefusesADataDirectoryRestoredFromAnotherVersion(t *testing.T) {
|
func TestPanelRefusesADataDirectoryRestoredFromAnotherVersion(t *testing.T) {
|
||||||
deployRoot := seedPanelProject(t)
|
live := seedPanelData(t)
|
||||||
dataDir := filepath.Join(deployRoot, "data")
|
|
||||||
var archive bytes.Buffer
|
var archive bytes.Buffer
|
||||||
if err := backup.Create(&archive, backup.Params{
|
if err := backup.Create(&archive, backup.Params{
|
||||||
DataDir: dataDir,
|
DataDir: live,
|
||||||
DBPath: filepath.Join(dataDir, "selfpost.db"),
|
DBPath: filepath.Join(live, "selfpost.db"),
|
||||||
Version: "9.9.9",
|
Version: "9.9.9",
|
||||||
DeployRoot: deployRoot,
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("create backup: %v", err)
|
t.Fatalf("create backup: %v", err)
|
||||||
}
|
}
|
||||||
@@ -233,15 +218,13 @@ func TestPanelRefusesADataDirectoryRestoredFromAnotherVersion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedPanelProject builds an operator project tree: data/ with a panel that has
|
// seedPanelData builds the /data tree of a panel that has been in use: an
|
||||||
// been in use, plus docker-compose.yml, .env, and certs/ for full backups.
|
// administrator, a sending domain with an application and one logged message,
|
||||||
func seedPanelProject(t *testing.T) string {
|
// 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 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
deployRoot := t.TempDir()
|
dataDir := 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"))
|
st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -285,33 +268,16 @@ func seedPanelProject(t *testing.T) string {
|
|||||||
t.Fatalf("write %s: %v", full, err)
|
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeDeployFile(t *testing.T, path, content string) {
|
// panelConfig resolves the panel's own configuration for a data directory, so
|
||||||
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
|
// 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
|
// than where it put them. Cookies are marked insecure for the same reason the
|
||||||
// e2e stand does it: the test client speaks plain HTTP.
|
// e2e stand does it: the test client speaks plain HTTP.
|
||||||
func panelConfig(t *testing.T, deployRoot string) config {
|
func panelConfig(t *testing.T, dataDir string) config {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dataDir := filepath.Join(deployRoot, "data")
|
|
||||||
t.Setenv("SELFPOST_DATA_DIR", dataDir)
|
t.Setenv("SELFPOST_DATA_DIR", dataDir)
|
||||||
t.Setenv("SELFPOST_DEPLOY_ROOT", deployRoot)
|
|
||||||
t.Setenv("PANEL_COOKIE_SECURE", "false")
|
t.Setenv("PANEL_COOKIE_SECURE", "false")
|
||||||
t.Setenv("SELFPOST_HOSTNAME", "mail.example.ru")
|
t.Setenv("SELFPOST_HOSTNAME", "mail.example.ru")
|
||||||
// MAIL_LOG's default is an absolute path, not one derived from the data
|
// MAIL_LOG's default is an absolute path, not one derived from the data
|
||||||
@@ -322,13 +288,13 @@ func panelConfig(t *testing.T, deployRoot string) config {
|
|||||||
|
|
||||||
// bootPanel performs the startup sequence run() performs, in the same order,
|
// bootPanel performs the startup sequence run() performs, in the same order,
|
||||||
// and returns the panel's HTTP handler.
|
// and returns the panel's HTTP handler.
|
||||||
func bootPanel(t *testing.T, deployRoot string) http.Handler {
|
func bootPanel(t *testing.T, dataDir string) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
cfg := panelConfig(t, deployRoot)
|
cfg := panelConfig(t, dataDir)
|
||||||
|
|
||||||
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
|
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("the panel refused to start on %s: %v", deployRoot, err)
|
t.Fatalf("the panel refused to start on %s: %v", dataDir, err)
|
||||||
}
|
}
|
||||||
if restored {
|
if restored {
|
||||||
if _, err := os.Stat(cfg.manifestPath); err == nil {
|
if _, err := os.Stat(cfg.manifestPath); err == nil {
|
||||||
@@ -384,7 +350,7 @@ func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password
|
|||||||
form.Set("password", password)
|
form.Set("password", password)
|
||||||
form.Set("password_confirm", password)
|
form.Set("password_confirm", password)
|
||||||
}
|
}
|
||||||
rec := request(t, h, http.MethodPost, "/server/backup", strings.NewReader(form.Encode()), session)
|
rec := request(t, h, http.MethodPost, "/backup", strings.NewReader(form.Encode()), session)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("download a backup = %d, want 200:\n%s", rec.Code, rec.Body.String())
|
t.Fatalf("download a backup = %d, want 200:\n%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
@@ -414,8 +380,8 @@ func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password
|
|||||||
return plain
|
return plain
|
||||||
}
|
}
|
||||||
|
|
||||||
// extract unpacks a backup archive into dir, as `tar -xzf` does onto the project
|
// extract unpacks a backup archive into dir, as `tar -xzf` does onto the /data
|
||||||
// directory before the image is started.
|
// bind mount before the image is started.
|
||||||
func extract(t *testing.T, archive []byte, dir string) {
|
func extract(t *testing.T, archive []byte, dir string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
gz, err := gzip.NewReader(bytes.NewReader(archive))
|
gz, err := gzip.NewReader(bytes.NewReader(archive))
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
//
|
//
|
||||||
// docker exec <container> selfpost-backup > selfpost-backup.tar.gz
|
// docker exec <container> selfpost-backup > selfpost-backup.tar.gz
|
||||||
//
|
//
|
||||||
// Use -o to write to a file instead. The resulting archive is self-contained:
|
// Use -o to write to a file instead. The resulting archive contains DKIM private
|
||||||
// data/, docker-compose.yml, .env, and certs/ when present — treat it as a
|
// keys, the admin password hash and SASL credentials — treat it as a secret
|
||||||
// secret (architecture.md § Persistence).
|
// (architecture.md § Persistence).
|
||||||
//
|
//
|
||||||
// Given a password (SELFPOST_BACKUP_PASSWORD or -password-file, never an
|
// 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
|
// argument, which would show up in the process list) the archive is written as
|
||||||
@@ -87,13 +87,9 @@ func run(outPath, password string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := backup.Create(sink, backup.Params{
|
if err := backup.Create(sink, backup.Params{
|
||||||
DataDir: dataDir,
|
DataDir: dataDir,
|
||||||
DBPath: dbPath,
|
DBPath: dbPath,
|
||||||
Version: buildinfo.Version,
|
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 {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,18 +9,13 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/backup"
|
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// seedProject builds the minimum operator project tree a backup can be taken from.
|
// seedDataDir builds the minimum /data tree a backup can be taken from.
|
||||||
func seedProject(t *testing.T) (deployRoot, dataDir string) {
|
func seedDataDir(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
deployRoot = t.TempDir()
|
dataDir := 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"))
|
st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open store: %v", err)
|
t.Fatalf("open store: %v", err)
|
||||||
@@ -31,31 +26,16 @@ func seedProject(t *testing.T) (deployRoot, dataDir string) {
|
|||||||
if err := st.Close(); err != nil {
|
if err := st.Close(); err != nil {
|
||||||
t.Fatalf("close store: %v", err)
|
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_DATA_DIR", dataDir)
|
||||||
t.Setenv("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db"))
|
t.Setenv("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db"))
|
||||||
t.Setenv("SELFPOST_DEPLOY_ROOT", deployRoot)
|
return dataDir
|
||||||
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
|
// 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
|
// 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.
|
// the CLI are tested as the one round trip an operator actually performs.
|
||||||
func TestEncryptedBackupRoundTrip(t *testing.T) {
|
func TestEncryptedBackupRoundTrip(t *testing.T) {
|
||||||
seedProject(t)
|
seedDataDir(t)
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
encrypted := filepath.Join(dir, "backup.spbk")
|
encrypted := filepath.Join(dir, "backup.spbk")
|
||||||
plain := filepath.Join(dir, "backup.tar.gz")
|
plain := filepath.Join(dir, "backup.tar.gz")
|
||||||
@@ -101,12 +81,7 @@ func TestEncryptedBackupRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
names[hdr.Name] = true
|
names[hdr.Name] = true
|
||||||
}
|
}
|
||||||
for _, want := range []string{
|
for _, want := range []string{"manifest.json", "selfpost.db"} {
|
||||||
backup.DataArchivePrefix + backup.ManifestName,
|
|
||||||
backup.DataArchivePrefix + "selfpost.db",
|
|
||||||
backup.ComposeFileName,
|
|
||||||
backup.EnvFileName,
|
|
||||||
} {
|
|
||||||
if !names[want] {
|
if !names[want] {
|
||||||
t.Errorf("decrypted archive has no %s (entries: %v)", want, names)
|
t.Errorf("decrypted archive has no %s (entries: %v)", want, names)
|
||||||
}
|
}
|
||||||
@@ -116,7 +91,7 @@ func TestEncryptedBackupRoundTrip(t *testing.T) {
|
|||||||
// Without a password the CLI keeps producing the plain archive that existing
|
// Without a password the CLI keeps producing the plain archive that existing
|
||||||
// backup scripts consume.
|
// backup scripts consume.
|
||||||
func TestUnencryptedBackupStaysPlain(t *testing.T) {
|
func TestUnencryptedBackupStaysPlain(t *testing.T) {
|
||||||
seedProject(t)
|
seedDataDir(t)
|
||||||
out := filepath.Join(t.TempDir(), "backup.tar.gz")
|
out := filepath.Join(t.TempDir(), "backup.tar.gz")
|
||||||
if err := run(out, ""); err != nil {
|
if err := run(out, ""); err != nil {
|
||||||
t.Fatalf("create backup: %v", err)
|
t.Fatalf("create backup: %v", err)
|
||||||
|
|||||||
@@ -11,30 +11,6 @@ SELFPOST_HOSTNAME=mail.example.com
|
|||||||
# primary 465/smtps listener.
|
# primary 465/smtps listener.
|
||||||
SUBMISSION_ENABLE=false
|
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
|
|
||||||
|
|
||||||
# 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".
|
# Level-1 backstop rate limit (anvil) — see docs/guide.md "Rate limiting".
|
||||||
RATE_LIMIT_MESSAGES_PER_IP=100
|
RATE_LIMIT_MESSAGES_PER_IP=100
|
||||||
RATE_LIMIT_WINDOW_SECONDS=3600
|
RATE_LIMIT_WINDOW_SECONDS=3600
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
# Example host Apache vhost for the SelfPost panel (default scenario). Install
|
# Example host Apache vhost for the SelfPost panel (spec 10.3, default
|
||||||
# on the host running Apache — NOT inside the SelfPost container. Requires
|
# scenario). Install on the host running Apache — NOT inside the SelfPost
|
||||||
# mod_ssl and mod_proxy/mod_proxy_http enabled (`a2enmod ssl proxy proxy_http`).
|
# 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
|
# Certificates: obtain with the Apache certbot plugin against THIS vhost
|
||||||
# (`certbot --apache -d mail.example.com`). Certbot edits this file in place to
|
# (`certbot --apache -d mail.example.com`). Certbot edits this file in place
|
||||||
# add the SSLCertificateFile/SSLCertificateKeyFile directives and a :80 -> :443
|
# to add the SSLCertificateFile/SSLCertificateKeyFile directives and a
|
||||||
# redirect vhost, and renews in the background via its own timer. The resulting
|
# :80 -> :443 redirect vhost, and renews in the background via its own timer.
|
||||||
# PEM files land at
|
# The resulting PEM files land at
|
||||||
# /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host —
|
# /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host
|
||||||
# bind-mount that directory read-only into the SelfPost container (see
|
# — bind-mount that directory read-only into the SelfPost container (see
|
||||||
# ../docker-compose.yml, the `./certs:/etc/postfix/tls:ro` volume) so Postfix
|
# ../docker-compose.yml, the `./certs:/etc/postfix/tls:ro` volume) so Postfix
|
||||||
# can present the very same certificate on 465/587. One certificate, two
|
# can present the very same certificate on 465/587. One certificate, two
|
||||||
# consumers — no separate cert just for the panel.
|
# consumers (spec 10 p.4) — no separate cert just for the panel.
|
||||||
|
|
||||||
<VirtualHost *:443>
|
<VirtualHost *:443>
|
||||||
ServerName mail.example.com
|
ServerName mail.example.com
|
||||||
@@ -25,9 +26,9 @@
|
|||||||
ProxyPass "/" "http://127.0.0.1:8080/"
|
ProxyPass "/" "http://127.0.0.1:8080/"
|
||||||
ProxyPassReverse "/" "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.
|
# The panel is plain HTTP behind the proxy; it never terminates TLS
|
||||||
# Cookies are still marked Secure by the panel because the browser only ever
|
# itself (spec 10 p.2). Cookies are still marked Secure by the panel
|
||||||
# sees this HTTPS vhost.
|
# (spec 7.6.6) because the browser only ever sees this HTTPS vhost.
|
||||||
</VirtualHost>
|
</VirtualHost>
|
||||||
|
|
||||||
<VirtualHost *:80>
|
<VirtualHost *:80>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
|
||||||
# Alternative reverse-proxy: Caddy — the simplest option, fully automatic ACME
|
# Alternative reverse-proxy: Caddy (spec 10.3) — the simplest option, fully
|
||||||
# with no separate certbot container. Caddy writes certificates as plain PEM
|
# automatic ACME with no separate certbot container. Caddy writes certificates
|
||||||
# under its own data directory, which this fragment bind-mounts to the host so
|
# as plain PEM under its own data directory, which this fragment bind-mounts
|
||||||
# SelfPost can read the same files.
|
# to the host so SelfPost can read the same files.
|
||||||
#
|
#
|
||||||
# NOTE: Caddy's on-disk cert path includes
|
# NOTE (spec 10.3 explicitly flags this): Caddy's on-disk cert path includes
|
||||||
# the ACME CA's name as a path segment, e.g.
|
# the ACME CA's name as a path segment, e.g.
|
||||||
# <data>/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>/<hostname>.crt
|
# <data>/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>/<hostname>.crt
|
||||||
# This has been stable across recent Caddy releases but is an internal
|
# This has been stable across recent Caddy releases but is an internal
|
||||||
@@ -23,7 +23,6 @@ services:
|
|||||||
ports: !override
|
ports: !override
|
||||||
- "465:465"
|
- "465:465"
|
||||||
- "587:587"
|
- "587:587"
|
||||||
- "25:25"
|
|
||||||
environment:
|
environment:
|
||||||
# Caddy names certificate files after the hostname, not
|
# Caddy names certificate files after the hostname, not
|
||||||
# fullchain.pem/privkey.pem like certbot — override the base file's
|
# fullchain.pem/privkey.pem like certbot — override the base file's
|
||||||
@@ -32,7 +31,6 @@ services:
|
|||||||
TLS_KEY_FILE: /etc/postfix/tls/<hostname>.key
|
TLS_KEY_FILE: /etc/postfix/tls/<hostname>.key
|
||||||
volumes: !override
|
volumes: !override
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
- .:/selfpost-deploy:ro
|
|
||||||
# Replace <hostname> with your actual mail/panel hostname, matching
|
# Replace <hostname> with your actual mail/panel hostname, matching
|
||||||
# the Caddyfile below and SELFPOST_HOSTNAME.
|
# the Caddyfile below and SELFPOST_HOSTNAME.
|
||||||
- ./caddy/caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>:/etc/postfix/tls:ro
|
- ./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.
|
# SelfPost — default deployment, reverse-proxy = Apache (spec 10, 10.5).
|
||||||
#
|
#
|
||||||
# This file only runs SelfPost itself. Apache is assumed to already be installed
|
# This file only runs SelfPost itself. Apache is assumed to already be
|
||||||
# on the HOST (the target audience for this project typically runs Apache there
|
# installed on the HOST (the target audience for this project typically runs
|
||||||
# already) and reverse-proxies HTTPS for the panel; it is not containerised
|
# Apache there already — spec 10.5) and reverse-proxies HTTPS for the panel;
|
||||||
# here. See ../apache/selfpost-vhost.conf for a ready vhost fragment, and
|
# it is not containerised here. See ../apache/selfpost-vhost.conf for a ready
|
||||||
# docs/guide.md "Reverse proxy" for the certbot steps that produce the PEM files
|
# vhost fragment, and docs/guide.md "Reverse proxy" for the
|
||||||
# this compose file mounts.
|
# certbot steps that produce the PEM files this compose file mounts.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# 1. Copy this file (and .env.example as .env) next to your own ./data and
|
# 1. Copy this file (and .env.example as .env) next to your own ./data and
|
||||||
@@ -15,39 +15,32 @@
|
|||||||
# TLS paths are fixed in this file to match that bind mount, not .env.
|
# TLS paths are fixed in this file to match that bind mount, not .env.
|
||||||
# 3. docker compose up -d
|
# 3. docker compose up -d
|
||||||
#
|
#
|
||||||
# The image tag below is FIXED on purpose: backup restore compares the manifest
|
# The image tag below is FIXED on purpose (spec 10 p.10, 7.5.A): backup
|
||||||
# version against the running binary's version, so ":latest" would make that
|
# restore compares the manifest version against the running binary's version,
|
||||||
# check meaningless. Bump the tag deliberately when you want to upgrade.
|
# so ":latest" would make that check meaningless. Bump the tag deliberately
|
||||||
|
# when you want to upgrade.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
selfpost:
|
selfpost:
|
||||||
image: ghcr.io/mixeme/selfpost:2.0.0
|
image: ghcr.io/mixeme/selfpost:1.3.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
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
|
# Path Postfix reads inside the container — matches the certs bind mount
|
||||||
# below. Point these at your reverse-proxy's PEM output.
|
# below. Point these at your reverse-proxy's PEM output (spec 10 p.2).
|
||||||
TLS_CERT_FILE: /etc/postfix/tls/fullchain.pem
|
TLS_CERT_FILE: /etc/postfix/tls/fullchain.pem
|
||||||
TLS_KEY_FILE: /etc/postfix/tls/privkey.pem
|
TLS_KEY_FILE: /etc/postfix/tls/privkey.pem
|
||||||
# Set to true to also publish RFC 6409 submission (587/STARTTLS) alongside
|
# Set to true to also publish RFC 6409 submission (587/STARTTLS)
|
||||||
# the primary 465/smtps listener.
|
# alongside the primary 465/smtps listener (spec 5).
|
||||||
SUBMISSION_ENABLE: "${SUBMISSION_ENABLE:-false}"
|
SUBMISSION_ENABLE: "${SUBMISSION_ENABLE:-false}"
|
||||||
INBOUND_RELAY_ENABLE: "${INBOUND_RELAY_ENABLE:-false}"
|
# Level-1 backstop rate limit (anvil, spec 5.5, 7.4); per-domain/app
|
||||||
DMARC_REPORTS_ENABLE: "${DMARC_REPORTS_ENABLE:-false}"
|
# limits (level 2) are configured later from the panel itself.
|
||||||
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_MESSAGES_PER_IP: "${RATE_LIMIT_MESSAGES_PER_IP:-100}"
|
||||||
RATE_LIMIT_WINDOW_SECONDS: "${RATE_LIMIT_WINDOW_SECONDS:-3600}"
|
RATE_LIMIT_WINDOW_SECONDS: "${RATE_LIMIT_WINDOW_SECONDS:-3600}"
|
||||||
# How long the send log keeps rows before the background sweep deletes
|
# How long the send log keeps rows before the background sweep deletes
|
||||||
# them — the main driver of /data growth over time.
|
# them (spec 7.3, 9) — the main driver of /data growth over time.
|
||||||
SEND_LOG_RETENTION_DAYS: "${SEND_LOG_RETENTION_DAYS:-90}"
|
SEND_LOG_RETENTION_DAYS: "${SEND_LOG_RETENTION_DAYS:-90}"
|
||||||
# Sliding idle timeout for the panel login session, in days.
|
# Sliding idle timeout for the panel login session, in days (spec 7.6.6).
|
||||||
PANEL_SESSION_IDLE_DAYS: "${PANEL_SESSION_IDLE_DAYS:-7}"
|
PANEL_SESSION_IDLE_DAYS: "${PANEL_SESSION_IDLE_DAYS:-7}"
|
||||||
# Resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly, so they
|
# 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
|
# report what the internet sees rather than what this host's own stub
|
||||||
@@ -58,36 +51,30 @@ services:
|
|||||||
# client spoof its rate-limit key.
|
# client spoof its rate-limit key.
|
||||||
TRUSTED_PROXY_CIDR: "${TRUSTED_PROXY_CIDR:-}"
|
TRUSTED_PROXY_CIDR: "${TRUSTED_PROXY_CIDR:-}"
|
||||||
volumes:
|
volumes:
|
||||||
# All persistent state lives under /data: SQLite DB, DKIM keys, sasldb2,
|
# All persistent state lives under /data (spec 9): SQLite DB, DKIM keys,
|
||||||
# sender map, setup token. Back this up (panel button or the
|
# 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
|
# selfpost-backup CLI) before you touch it directly.
|
||||||
# here too (./data/log, rotated daily, 14 files kept) so the delivery
|
# Postfix's mail.log is here too (./data/log, rotated daily, 14 files
|
||||||
# lines that resolve a queued send-log row survive a container recreate;
|
# kept) so the delivery lines that resolve a queued send-log row survive
|
||||||
# it is diagnostics and is left out of backups.
|
# a container recreate; it is diagnostics and is left out of backups.
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
# Read-only project root for full backups: docker-compose.yml, .env, and
|
# Read-only: SelfPost only ever reads certificates, never manages them
|
||||||
# certs/ are archived alongside /data (architecture.md § Persistence).
|
# (spec 10 p.2). Point this at wherever your reverse-proxy/certbot
|
||||||
- .:/selfpost-deploy:ro
|
# writes PEM files, e.g. /etc/letsencrypt on the host.
|
||||||
# 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
|
- ./certs:/etc/postfix/tls:ro
|
||||||
ports:
|
ports:
|
||||||
# 465 (smtps, primary) and optionally 587 (submission/STARTTLS) are
|
# 465 (smtps, primary) and optionally 587 (submission/STARTTLS) are
|
||||||
# published directly — mail traffic bypasses Apache entirely, it only ever
|
# published directly — mail traffic bypasses Apache entirely, it only
|
||||||
# proxies the panel's HTTP(S). The panel itself (8080) is intentionally
|
# ever proxies the panel's HTTP(S) (spec 10 p.2-3). The panel itself
|
||||||
# NOT published here: Apache reaches it over the host network at
|
# (8080) is intentionally NOT published here: Apache reaches it over
|
||||||
# 127.0.0.1:8080 (see the vhost fragment), so the panel is never directly
|
# the host network at 127.0.0.1:8080 (see the vhost fragment), so the
|
||||||
# reachable from the internet without TLS.
|
# panel is never directly reachable from the internet without TLS.
|
||||||
- "465:465"
|
- "465:465"
|
||||||
# 587 is mapped even when SUBMISSION_ENABLE=false; Postfix listens only
|
# 587 is mapped even when SUBMISSION_ENABLE=false; Postfix listens only
|
||||||
# when the variable is true — see docs/guide.md "Published ports".
|
# when the variable is true — see docs/guide.md "Published ports".
|
||||||
- "587:587"
|
- "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"
|
- "127.0.0.1:8080:8080"
|
||||||
# Hardening. SelfPost's entrypoint still needs to run as
|
# Hardening (spec 10 p.6). SelfPost's entrypoint still needs to run as
|
||||||
# root very briefly to fix /data ownership and normalise permissions
|
# root very briefly to fix /data ownership and normalise permissions
|
||||||
# under the shared `selfpost` group (see build/entrypoint.sh) before
|
# under the shared `selfpost` group (see build/entrypoint.sh) before
|
||||||
# supervisord drops the panel to an unprivileged user — so this cannot be
|
# supervisord drops the panel to an unprivileged user — so this cannot be
|
||||||
@@ -95,7 +82,7 @@ services:
|
|||||||
# self-healing. What IS applied: no privilege escalation past what the
|
# self-healing. What IS applied: no privilege escalation past what the
|
||||||
# image already grants, and every Linux capability dropped except the
|
# image already grants, and every Linux capability dropped except the
|
||||||
# small set the root startup phase and Postfix/OpenDKIM genuinely need:
|
# small set the root startup phase and Postfix/OpenDKIM genuinely need:
|
||||||
# - NET_BIND_SERVICE — bind 465/587 and inbound 25 below 1024;
|
# - NET_BIND_SERVICE — bind 465/587 (and 25 outbound) below 1024;
|
||||||
# - CHOWN — entrypoint re-owns /data (bind mount) to `panel`;
|
# - CHOWN — entrypoint re-owns /data (bind mount) to `panel`;
|
||||||
# - FOWNER — entrypoint then chmods those now panel-owned /data
|
# - FOWNER — entrypoint then chmods those now panel-owned /data
|
||||||
# dirs/files while still root (owner-check bypass);
|
# dirs/files while still root (owner-check bypass);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
# Alternative reverse-proxy: nginx. PEM files land on the host exactly like the
|
# Alternative reverse-proxy: nginx (spec 10.3). PEM files land on the host
|
||||||
# Apache+certbot scenario — nginx and certbot are close cousins here, both
|
# exactly like the Apache+certbot scenario — nginx and certbot are close
|
||||||
# writing/reading plain PEM under /etc/letsencrypt.
|
# cousins here, both writing/reading plain PEM under /etc/letsencrypt.
|
||||||
#
|
#
|
||||||
# This is a FRAGMENT, not a full replacement for ../docker-compose.yml: it
|
# 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
|
# adds an nginx + certbot pair and removes the panel's host port publish (nginx
|
||||||
@@ -23,15 +23,13 @@ services:
|
|||||||
ports: !override
|
ports: !override
|
||||||
- "465:465"
|
- "465:465"
|
||||||
- "587:587"
|
- "587:587"
|
||||||
- "25:25"
|
|
||||||
# No host publish for 8080 here: nginx reaches it over the compose
|
# No host publish for 8080 here: nginx reaches it over the compose
|
||||||
# network at selfpost:8080 instead (see nginx.conf.example).
|
# network at selfpost:8080 instead (see nginx.conf.example).
|
||||||
volumes: !override
|
volumes: !override
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
- .:/selfpost-deploy:ro
|
# Same host directory certbot below writes into — plain bind mount,
|
||||||
# Same host directory certbot below writes into — plain bind mount, no
|
# no named volume, so the PEM files are as directly inspectable as in
|
||||||
# named volume, so the PEM files are as directly inspectable as in the
|
# the Apache scenario (spec 10.3).
|
||||||
# Apache scenario.
|
|
||||||
- ./nginx/certbot-etc/live/mail.example.com:/etc/postfix/tls:ro
|
- ./nginx/certbot-etc/live/mail.example.com:/etc/postfix/tls:ro
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# nginx vhost for the SelfPost panel. Proxies HTTPS to the panel over the
|
# nginx vhost for the SelfPost panel (spec 10.3). Proxies HTTPS to the panel
|
||||||
# compose network; mail (465/587) is published directly by the selfpost
|
# over the compose network; mail (465/587) is published directly by the
|
||||||
# container and never touches nginx.
|
# selfpost container and never touches nginx.
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
# Alternative reverse-proxy: Traefik. Traefik does NOT write plain PEM files —
|
# Alternative reverse-proxy: Traefik (spec 10.3). Traefik does NOT write plain
|
||||||
# everything (cert + key) lives bundled inside acme.json. SelfPost needs
|
# PEM files — everything (cert + key) lives bundled inside acme.json. SelfPost
|
||||||
# separate PEM files for Postfix, so an extraction step is required; see
|
# 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.
|
# 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
|
# Merge with the base file — run this from the deploy/ directory (Compose
|
||||||
@@ -15,10 +15,8 @@ services:
|
|||||||
ports: !override
|
ports: !override
|
||||||
- "465:465"
|
- "465:465"
|
||||||
- "587:587"
|
- "587:587"
|
||||||
- "25:25"
|
|
||||||
volumes: !override
|
volumes: !override
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
- .:/selfpost-deploy:ro
|
|
||||||
# Populated by extract-cert.sh from traefik's acme.json — see below.
|
# Populated by extract-cert.sh from traefik's acme.json — see below.
|
||||||
- ./traefik/extracted-certs:/etc/postfix/tls:ro
|
- ./traefik/extracted-certs:/etc/postfix/tls:ro
|
||||||
labels:
|
labels:
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json —
|
# 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.
|
# (spec 10.3: "Traefik keeps certificates in acme.json, so a PEM extraction
|
||||||
# Run this on the host, after Traefik has issued or renewed the certificate, and
|
# step is required"). Run this on the host, after Traefik has issued or
|
||||||
# again on a schedule (cron/systemd timer) since acme.json is not itself watched
|
# renewed the certificate, and again on a schedule (cron/systemd timer) since
|
||||||
# by SelfPost/Postfix.
|
# acme.json is not itself watched by SelfPost/Postfix.
|
||||||
#
|
#
|
||||||
# Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
|
# Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
|
||||||
set -eu
|
set -eu
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ Managed programs ([build/supervisord.conf](../build/supervisord.conf)):
|
|||||||
| Program | User | Priority | Role |
|
| Program | User | Priority | Role |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `opendkim` | root → `opendkim` (`UserID` in opendkim.conf) | 100 | DKIM signing milter |
|
| `opendkim` | root → `opendkim` (`UserID` in opendkim.conf) | 100 | DKIM signing milter |
|
||||||
| `panel` | panel | 200 | HTTP UI + journal-milter + log-tailer + rate-limit recalc |
|
| `panel` | panel | 200 | HTTP UI + journal-milter + log-tailer goroutine |
|
||||||
| `postfix` | root (wrapper) | 300 | MTA — started only after both milter sockets exist |
|
| `postfix` | root (wrapper) | 300 | MTA — started only after both milter sockets exist |
|
||||||
| `postfix-reload` | root | — | On-demand `postfix reload` (autostart off) |
|
| `postfix-reload` | root | — | On-demand `postfix reload` (autostart off) |
|
||||||
| `cert-reload` | root | 400 | Daily `postfix reload` for renewed TLS certs |
|
| `cert-reload` | root | 400 | Daily `postfix reload` for renewed TLS certs |
|
||||||
@@ -53,45 +53,13 @@ Client ──TLS+SASL──► Postfix (465 smtps, optional 587 submission)
|
|||||||
├─► OpenDKIM milter (sign, tempfail on failure)
|
├─► OpenDKIM milter (sign, tempfail on failure)
|
||||||
├─► journal-milter (send log + L2 rate limits, fail-open)
|
├─► journal-milter (send log + L2 rate limits, fail-open)
|
||||||
└─► outbound MX delivery (port 25 client)
|
└─► 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))
|
### Postfix ([build/postfix-config.sh](../build/postfix-config.sh))
|
||||||
|
|
||||||
- **465/smtps** — implicit TLS, SASL required; primary listener.
|
- **465/smtps** — implicit TLS, SASL required; primary listener.
|
||||||
- **587/submission** — only when `SUBMISSION_ENABLE=true`; STARTTLS with
|
- **587/submission** — only when `SUBMISSION_ENABLE=true`; STARTTLS with
|
||||||
`smtpd_tls_security_level=encrypt`.
|
`smtpd_tls_security_level=encrypt`.
|
||||||
- **25/smtp inet** — when `INBOUND_RELAY_ENABLE=true` and/or
|
|
||||||
`DMARC_REPORTS_ENABLE=true`. No SASL, no OpenDKIM, no journal-milter —
|
|
||||||
`smtpd_milters` is always overridden on this service (to the antispam milter
|
|
||||||
or to nothing), because main.cf's submission chain would otherwise let a
|
|
||||||
signing outage defer relayed mail and would file inbound mail into the
|
|
||||||
outbound send log under the sender's domain.
|
|
||||||
**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`;
|
- **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`.
|
||||||
- **Level-1 rate limit** — `smtpd_client_message_rate_limit` /
|
- **Level-1 rate limit** — `smtpd_client_message_rate_limit` /
|
||||||
@@ -107,34 +75,22 @@ by the panel. Socket `/run/opendkim/opendkim.sock`.
|
|||||||
|
|
||||||
### Panel binary ([cmd/panel](../cmd/panel))
|
### Panel binary ([cmd/panel](../cmd/panel))
|
||||||
|
|
||||||
One process, four roles:
|
One process, three roles:
|
||||||
|
|
||||||
1. **HTTP server** — `:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse
|
1. **HTTP server** — `:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse
|
||||||
proxy only. On start it runs `postconf -h` once for the deferred-mail retry
|
proxy only.
|
||||||
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. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
|
2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
|
||||||
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open**
|
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open**
|
||||||
(`default_action=accept`) so milter failure does not stop mail. Domain
|
(`default_action=accept`) so milter failure does not stop mail. Domain
|
||||||
ceilings apply to every client IP; an application ceiling **overrides** the
|
ceilings apply to every client IP; an application ceiling with trusted IPs
|
||||||
domain one for that login, higher or lower (guide § Rate limiting). Since
|
raises the limit for those IPs only and skips the domain check (guide § Rate
|
||||||
1.9.0 the client IP allow-list is a separate, restrictive control on the
|
limiting). The level-2 count is the stored send-log rows plus the messages
|
||||||
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`),
|
this process has admitted but not yet written (`internal/milter/inflight.go`),
|
||||||
so concurrent sessions cannot each spend the same last slot; a reservation
|
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.
|
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
|
3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by
|
||||||
queue-id. Send-log `queued → sent` transitions depend on this goroutine alone
|
queue-id. Send-log `queued → sent` transitions depend on this goroutine alone
|
||||||
(`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)).
|
(`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)).
|
||||||
4. **rate-limit recalc** — every six hours (and on demand from Domain settings and
|
|
||||||
the application form), recomputes level-2 **Auto** rate limits from send-log statistics.
|
|
||||||
|
|
||||||
Milter chain in Postfix: OpenDKIM (tempfail) then journal (accept on failure).
|
Milter chain in Postfix: OpenDKIM (tempfail) then journal (accept on failure).
|
||||||
|
|
||||||
@@ -159,7 +115,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
|
switching descriptors; the panel treats a missing log file as an empty tail, not
|
||||||
an error.
|
an error.
|
||||||
|
|
||||||
**Read offset is persisted** (`logtail_state` table, in the baseline schema): the
|
**Read offset is persisted** (`logtail_state` table, migration `0003`): the
|
||||||
tailer stores its position plus a fingerprint of the log's first 512 bytes, and
|
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
|
on start resumes from it, parsing the tail written while the panel was down. If
|
||||||
the fingerprint no longer matches (rotated or recreated in the meantime) it reads
|
the fingerprint no longer matches (rotated or recreated in the meantime) it reads
|
||||||
@@ -185,8 +141,8 @@ rotated past fourteen files while the panel was down, or deleted.
|
|||||||
|
|
||||||
**Two one-shot reads** sit beside the follow loop and are unrelated to it, both
|
**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
|
serving panel pages on request: `TailLines` (the last *n* lines, for
|
||||||
`/server/log`) and `QueueLines` (the lines carrying one queue-id, for
|
`/system-log`) and `QueueLines` (the lines carrying one queue-id, for
|
||||||
`/outbound/log/{id}`). `QueueLines` scans a bounded tail of the current file —
|
`/deliveries/{id}`). `QueueLines` scans a bounded tail of the current file —
|
||||||
finding a message's lines means reading rather than seeking — and matches the id
|
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
|
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
|
shorter one is regularly the tail of a longer one. Send-log rows outlive the log
|
||||||
@@ -197,227 +153,56 @@ state for an older message and the page reports it as such, not as a failure.
|
|||||||
|
|
||||||
## Panel HTTP surface
|
## Panel HTTP surface
|
||||||
|
|
||||||
Canonical routes: [internal/web/web.go](../internal/web/web.go). The router is
|
Canonical routes: [internal/web/web.go](../internal/web/web.go). Authenticated
|
||||||
two flat `http.ServeMux`es built by `muxes()` — one pattern per route, method
|
unless noted. Routes marked **global** return **404** for domain administrators
|
||||||
included (`GET /outbound/log/{id}`), nothing mounted as a sub-router, and
|
(`requireGlobal()` in
|
||||||
building them only takes method values, so the route guard test can run it on
|
[internal/web/handlers/authz.go](../internal/web/handlers/authz.go)). The table
|
||||||
a bare `Server` and ask which pattern answers a path:
|
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`.
|
||||||
|
|
||||||
- **public** — `/healthz` (liveness), `/license` (the embedded `LICENSE`),
|
| Route | Purpose |
|
||||||
`/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 |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| 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) |
|
| `/healthz` | Liveness (no auth) |
|
||||||
| Overview | `GET /overview`, `GET /overview/fragment` |
|
| `/license` | Embedded `LICENSE` text (no auth) |
|
||||||
| Outbound › Domains | `GET, POST /outbound/domains` · `/outbound/domains/{id}` · `POST …/dns-recheck` · `GET, POST …/delete` · `GET …/settings` · `POST …/settings/reports`, `…/ratelimit`, `…/ratelimit/recalc`, `…/export` |
|
| `/setup/*` | One-time admin bootstrap |
|
||||||
| Outbound › Applications | `GET, POST /outbound/domains/{id}/applications/new` · `GET, POST …/applications/{aid}` · `POST …/applications/{aid}/password`, `…/{aid}/ratelimit/recalc`, `…/{aid}/delete` |
|
| `/login`, `/logout` | Session auth |
|
||||||
| Outbound › Log, Queue | `GET /outbound/log` · `/outbound/log/fragment` · `/outbound/log/{id}` · `GET /outbound/queue` · `/outbound/queue/fragment` |
|
| `/account` | 308 redirect to `/settings` (pre-1.2.3 route, kept as a compat shim) |
|
||||||
| Outbound › DMARC reports | `GET /outbound/dmarc` · `…/dmarc/domains/{id}` · `…/dmarc/reports/{id}` — registered only with `DMARC_REPORTS_ENABLE=true` |
|
| `/status`, `/status/*` | **Global.** Process, cert, socket, PTR checks; machine CPU/memory/network |
|
||||||
| Inbound › Domains | `GET, POST /inbound/domains` · `/inbound/domains/{id}` · `POST …/dns-recheck`, `…/upstream`, `…/recipients` · `GET, POST …/delete` — registered only with `INBOUND_RELAY_ENABLE=true` |
|
| `/domains` | Domain list; `POST /domains` (add domain) is **global** |
|
||||||
| 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` |
|
| `/domains/{id}`, `/domains/{id}/*` | Assigned-domain detail for domain-admins; delete domain is **global** |
|
||||||
| Account | `GET /account` · `POST /account/profile`, `/account/password` |
|
| `/domains/import` | **Global.** Domain import (`POST`; form on the Backup page) |
|
||||||
| User menu | `GET /help` · `POST /logout` (public mux) |
|
| `/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 |
|
||||||
|
|
||||||
An application is addressed under its domain, and an application id that does
|
HTMX polling refreshes monitoring fragments (5 s while the operator is active on
|
||||||
not belong to the domain in the path is a 404. The application form is one
|
the page, 30 s when the tab is visible but idle, none when hidden — scheduled in
|
||||||
`POST`: sender, client IPs and rate limit are validated and saved together or
|
`panel.js` via `data-poll`, not `hx-trigger="every …"`); polling does not extend
|
||||||
not at all, also at creation. The page that shows a new password is the
|
session idle timeout (only non-`HX-Request` GET and mutating requests count as
|
||||||
response to the `POST` that created or regenerated it; it has no `GET` path and
|
activity).
|
||||||
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
|
### Sessions
|
||||||
|
|
||||||
Stored in SQLite (`sessions` table of the baseline, `0001_init.sql`): the cookie
|
Stored in SQLite (`sessions` table, migration `0002`): cookie holds a random
|
||||||
holds a random token; the database stores **SHA-256 of the token**, not the
|
token; the database stores **SHA-256 of the token**, not the token itself — a
|
||||||
token itself — a stolen DB or backup archive does not alone grant login, but a
|
stolen DB or backup archive does not alone grant login, but a browser that still
|
||||||
browser that still holds the cookie works after process restart, redeploy, or
|
holds the cookie works after process restart, redeploy, or full backup restore.
|
||||||
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
|
- **Idle timeout** — sliding window, `PANEL_SESSION_IDLE_DAYS` (default 7); no
|
||||||
absolute cap (regular use keeps the session alive indefinitely).
|
absolute cap (regular use keeps the session alive indefinitely).
|
||||||
- **Renewal** — the DB `expires_at` and the cookie `Max-Age` update at most once per
|
- **Renewal** — DB `last_seen` and cookie `Max-Age` update at most once per hour
|
||||||
hour (`renewThreshold` in
|
(`renewThreshold` in [internal/web/auth/session.go](../internal/web/auth/session.go)).
|
||||||
[internal/web/auth/session.go](../internal/web/auth/session.go)).
|
- **Password change on `/settings`** — changing your own password deletes
|
||||||
- **Password change on Account** — deletes every other session of that user;
|
every other session for that user; the current session stays active
|
||||||
the current one stays active
|
|
||||||
([internal/store/sessions.go](../internal/store/sessions.go),
|
([internal/store/sessions.go](../internal/store/sessions.go),
|
||||||
[handlers_account.go](../internal/web/handlers/handlers_account.go)).
|
[handlers_settings.go](../internal/web/handlers/handlers_settings.go)).
|
||||||
- **A password set by a global user on the user form** deletes all of that
|
A global administrator resetting another user's password on `/users` updates
|
||||||
user's sessions (`DestroyUserSessions`); set for oneself, it keeps the
|
the hash but does not delete that user's existing sessions.
|
||||||
current one
|
|
||||||
([handlers_users.go](../internal/web/handlers/handlers_users.go),
|
|
||||||
`endSessionsAfterPasswordSet`).
|
|
||||||
|
|
||||||
Restoring an **older** backup also restores session rows: a session removed
|
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
|
after that backup was taken can become valid again if the browser still holds
|
||||||
@@ -430,7 +215,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,
|
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
|
`sasldb2` entry, Postfix map, OpenDKIM table) go through a service, which is
|
||||||
also where the rollback of a partial failure lives. Handlers may call
|
also where the rollback of a partial failure lives. Handlers may call
|
||||||
`store` directly for single-table reads and simple writes (sessions, users,
|
`store` directly for single-table reads and simple writes (sessions, admin,
|
||||||
send-log queries); the first-run setup-token file is read and written in
|
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
|
`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
|
about Postfix, OpenDKIM, DNS or the log file, which is what makes them
|
||||||
@@ -457,11 +242,9 @@ flowchart TB
|
|||||||
subgraph services ["Services — multi-store operations + rollback"]
|
subgraph services ["Services — multi-store operations + rollback"]
|
||||||
domainSvc["internal/domain"]
|
domainSvc["internal/domain"]
|
||||||
appSvc["internal/app"]
|
appSvc["internal/app"]
|
||||||
inboundSvc["internal/inbound"]
|
|
||||||
dmarcSvc["internal/dmarc"]
|
|
||||||
end
|
end
|
||||||
subgraph persistence ["Persistence"]
|
subgraph persistence ["Persistence"]
|
||||||
store["internal/store — SQLite, embedded schema"]
|
store["internal/store — SQLite, embedded migrations"]
|
||||||
end
|
end
|
||||||
subgraph adapters ["Adapters — the only infrastructure-aware code"]
|
subgraph adapters ["Adapters — the only infrastructure-aware code"]
|
||||||
postfix["internal/postfix"]
|
postfix["internal/postfix"]
|
||||||
@@ -480,27 +263,20 @@ flowchart TB
|
|||||||
web --> store
|
web --> store
|
||||||
web --> domainSvc
|
web --> domainSvc
|
||||||
web --> appSvc
|
web --> appSvc
|
||||||
web --> inboundSvc
|
|
||||||
web --> dmarcSvc
|
|
||||||
web --> backupPkg
|
web --> backupPkg
|
||||||
web --> dnscheck
|
web --> dnscheck
|
||||||
web --> health
|
web --> health
|
||||||
web --> secretfile
|
web --> secretfile
|
||||||
domainSvc --> store
|
domainSvc --> store
|
||||||
appSvc --> store
|
appSvc --> store
|
||||||
inboundSvc --> store
|
|
||||||
dmarcSvc --> store
|
|
||||||
milterPkg --> store
|
milterPkg --> store
|
||||||
logtail --> store
|
logtail --> store
|
||||||
domainSvc --> postfix
|
domainSvc --> postfix
|
||||||
appSvc --> postfix
|
appSvc --> postfix
|
||||||
inboundSvc --> postfix
|
|
||||||
dmarcSvc --> postfix
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The four roles inside the `panel` process (HTTP server, journal-milter,
|
The three roles inside the `panel` process (HTTP server, journal-milter,
|
||||||
log-tailer, rate-limit recalc) share one binary and one SQLite handle on
|
log-tailer goroutine) share one binary and one SQLite handle on purpose — see
|
||||||
purpose — see
|
|
||||||
[Panel binary](#panel-binary-cmdpanel) for why, and *Persistence* below for the
|
[Panel binary](#panel-binary-cmdpanel) for why, and *Persistence* below for the
|
||||||
single-connection trade-off that follows from it.
|
single-connection trade-off that follows from it.
|
||||||
|
|
||||||
@@ -510,98 +286,37 @@ single-connection trade-off that follows from it.
|
|||||||
|
|
||||||
| Path | Contents |
|
| Path | Contents |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `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) |
|
| `selfpost.db` | SQLite: domains, apps, admin, sessions, send log, L2 limits, log-tailer offset |
|
||||||
| `setup-token` | First-run setup token file |
|
| `setup-token` | First-run setup token file |
|
||||||
| `opendkim/` | DKIM keys + tables |
|
| `opendkim/` | DKIM keys + tables |
|
||||||
| `sasl/sasldb2` | Application SASL credentials |
|
| `sasl/sasldb2` | Application SASL credentials |
|
||||||
| `postfix/sender_login_maps` | Login → From binding |
|
| `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) |
|
| `log/mail.log` | Postfix delivery log + rotated copies (excluded from backups) |
|
||||||
| `manifest.json` | Backup version stamp (consumed on restore) |
|
| `manifest.json` | Backup version stamp (consumed on restore) |
|
||||||
|
|
||||||
Not in `/data`: TLS certificates for the panel (reverse-proxy mount) — though
|
Not in `/data`: TLS certificates (reverse-proxy mount), Postfix queue
|
||||||
full backups also archive the operator's `./certs` PEM files when present.
|
(transit mail not migrated by design).
|
||||||
|
|
||||||
### Database
|
|
||||||
|
|
||||||
The schema is one embedded baseline, `internal/store/migrations/0001_init.sql`,
|
|
||||||
applied by `store.Open` into an empty file; `PRAGMA user_version` is `1` and
|
|
||||||
`PRAGMA application_id` is `0x53503230` ("SP20"). There is no 1.x migration
|
|
||||||
chain. **2.0 starts from an empty data directory:** a database with a schema
|
|
||||||
version but without the 2.x `application_id` — what every 1.x file looks like —
|
|
||||||
is refused at start with `ErrForeignSchema` and left untouched, as is one whose
|
|
||||||
version is newer than the build knows. A restored 1.x backup fails the same
|
|
||||||
way. Domains are carried across with a domain export, whose format is not the
|
|
||||||
database. Later schema changes are new files (`0002_*.sql`, …); once `2.0.0` is
|
|
||||||
released the baseline is never edited. History and the per-table list:
|
|
||||||
[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`,
|
|
||||||
`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);
|
**Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90);
|
||||||
`mail.log` via logrotate (14 rotated files, check every 6h, rename +
|
`mail.log` via logrotate (14 rotated files, check every 6h, rename +
|
||||||
`postfix reload` in `postrotate` — see § Log tailer above).
|
`postfix reload` in `postrotate` — see § Log tailer above).
|
||||||
|
|
||||||
**Restore:** panel button or `selfpost-backup` CLI — self-contained archive:
|
**Restore:** panel button or `selfpost-backup` CLI — SQLite snapshot + tar of
|
||||||
`data/` (SQLite snapshot + tree minus `log/`, the setup token and any `tls/`
|
`/data` tree, minus `log/`, the setup token and any `tls/`; version check on
|
||||||
under `/data`), `docker-compose.yml`, `.env`, and `certs/` when present;
|
restore. On the first successful boot after restore, the panel runs one
|
||||||
version check on restore. Requires the project directory mounted read-only at
|
**Resync** — OpenDKIM's tables and Postfix's sender map are re-derived from
|
||||||
`SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` in the default compose file). On the
|
SQLite and both daemons are reloaded, so drift between the extracted archive
|
||||||
first successful boot after restore, the panel runs one **Resync** — OpenDKIM's
|
and the database is healed before mail flows (same step as `POST /reload` on
|
||||||
tables, Postfix's sender map, inbound relay maps (when
|
demand). Stopped-container `tar` of `./data` is safe (see guide).
|
||||||
`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
|
**Optional encryption** of the two secret-bearing downloads
|
||||||
([internal/secretfile](../internal/secretfile/secretfile.go)): password →
|
([internal/secretfile](../internal/secretfile/secretfile.go)): password →
|
||||||
scrypt → AES-256-GCM over 64 KiB chunks, each authenticated with the header,
|
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
|
its counter and an end-of-stream flag (so truncation and reordering fail to
|
||||||
open). Full backup `.tar.gz` → `.spbk` (SelfPost backup), domain export
|
open). Full backup `.tar.gz` → `.spbk` (SelfPost backup), domain export
|
||||||
`.json` → `.spde` (SelfPost domain export). In the panel the *Encrypt with a
|
`.json` → `.spde` (SelfPost domain export); the plain forms remain the
|
||||||
password* box is ticked by default on both downloads and the plain form is
|
default. Domain import detects the envelope by magic bytes; an encrypted full
|
||||||
what an unticked box gives; the `selfpost-backup` CLI stays plain unless it is
|
backup is converted back with `selfpost-backup -decrypt` before restore.
|
||||||
given a password. Domain import detects the envelope by magic bytes; an
|
|
||||||
encrypted full backup is converted back with `selfpost-backup -decrypt` before
|
|
||||||
restore.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -635,19 +350,7 @@ unsupported rather than as a missing doc:
|
|||||||
`POSTFIX_DIR` (`/data/postfix`), `POSTFIX_SENDER_LOGIN_MAPS`
|
`POSTFIX_DIR` (`/data/postfix`), `POSTFIX_SENDER_LOGIN_MAPS`
|
||||||
(`/data/postfix/sender_login_maps` — read by Postfix config only; the panel
|
(`/data/postfix/sender_login_maps` — read by Postfix config only; the panel
|
||||||
always writes `<POSTFIX_DIR>/sender_login_maps`, so overriding this env alone
|
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 and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`),
|
||||||
`MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`),
|
`MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`),
|
||||||
`MILTER_WAIT_TIMEOUT` (`30` seconds).
|
`MILTER_WAIT_TIMEOUT` (`30` seconds).
|
||||||
|
|||||||
|
After Width: | Height: | Size: 854 KiB |
@@ -1,164 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="light">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>example.org · Outbound domains · SelfPost</title>
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
|
|
||||||
<link rel="stylesheet" href="../shared/brand.css">
|
|
||||||
<link rel="stylesheet" href="theme.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<header class="sp-bar">
|
|
||||||
<div class="sp-wrap">
|
|
||||||
<nav>
|
|
||||||
<ul>
|
|
||||||
<li><a class="sp-brand" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a></li>
|
|
||||||
</ul>
|
|
||||||
<ul class="sp-sections">
|
|
||||||
<li><a href="status.html">Overview</a></li>
|
|
||||||
<li><a href="domain.html" aria-current="true">Outbound</a></li>
|
|
||||||
<li><a href="#">Inbound</a></li>
|
|
||||||
<li><a href="#">Server</a></li>
|
|
||||||
</ul>
|
|
||||||
<ul class="sp-user">
|
|
||||||
<li>
|
|
||||||
<details class="dropdown">
|
|
||||||
<summary>admin</summary>
|
|
||||||
<ul dir="rtl">
|
|
||||||
<li><a href="#">Account</a></li>
|
|
||||||
<li><a href="#">Help</a></li>
|
|
||||||
<li><a href="login.html">Sign out</a></li>
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div class="sp-pages">
|
|
||||||
<div class="sp-wrap">
|
|
||||||
<a href="#" aria-current="true">Domains</a>
|
|
||||||
<a href="#">Log</a>
|
|
||||||
<a href="#">Queue</a>
|
|
||||||
<a href="#">DMARC reports</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main class="sp-wrap">
|
|
||||||
<div class="sp-head">
|
|
||||||
<div>
|
|
||||||
<p class="sp-kicker"><a href="#">Outbound domains</a> /</p>
|
|
||||||
<h1>example.org</h1>
|
|
||||||
</div>
|
|
||||||
<small>1 284 messages in 30 days · peak 96 msg/h · average 1.8 msg/h</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="sp-sheet">
|
|
||||||
<header><span class="sp-no">01</span><h2>Connection</h2><small>The same for every domain</small></header>
|
|
||||||
<div class="sp-body sp-cols">
|
|
||||||
<dl class="sp-kv">
|
|
||||||
<dt>Server</dt>
|
|
||||||
<dd><span class="sp-value">mail.example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
|
|
||||||
</dl>
|
|
||||||
<dl class="sp-kv">
|
|
||||||
<dt>Ports</dt>
|
|
||||||
<dd><span class="sp-value">465 — SSL/TLS (implicit)<br>587 — STARTTLS (submission)</span></dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="sp-sheet">
|
|
||||||
<header>
|
|
||||||
<span class="sp-no">02</span><h2>DNS records</h2>
|
|
||||||
<small>Checked 3 min ago</small>
|
|
||||||
<form method="post" action="#"><button type="submit" class="outline secondary sp-btn-sm">Re-check</button></form>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="sp-record">
|
|
||||||
<div class="sp-record-head"><h3>DKIM</h3><span class="sp-tag ok">published</span><small>Not a secret · selector <code>sp2026</code></small></div>
|
|
||||||
<dl class="sp-kv">
|
|
||||||
<dt>Host</dt><dd><span class="sp-value">sp2026._domainkey.example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
|
|
||||||
<dt>Type</dt><dd><span class="sp-value plain">TXT</span></dd>
|
|
||||||
<dt>Value</dt><dd><span class="sp-value">v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3kqV0mJd8e2f1Yb7Qw5uT9sLr4nHc6pZaXo1vKe0yWm2gBt8RjN5dCq3hUf7lPs9aEi4oMz6xTb1kGv0nYw…IDAQAB</span><button type="button" class="outline sp-copy">Copy</button></dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sp-record">
|
|
||||||
<div class="sp-record-head"><h3>SPF</h3><span class="sp-tag fail">mismatch</span><small>Merge into the existing record — never publish a second one</small></div>
|
|
||||||
<dl class="sp-kv">
|
|
||||||
<dt>Host</dt><dd><span class="sp-value">example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
|
|
||||||
<dt>Type</dt><dd><span class="sp-value plain">TXT</span></dd>
|
|
||||||
<dt>Value</dt><dd><span class="sp-value">v=spf1 ip4:203.0.113.25 ~all</span><button type="button" class="outline sp-copy">Copy</button></dd>
|
|
||||||
<dt>In DNS now</dt><dd><span class="sp-value">v=spf1 include:_spf.google.com ~all</span></dd>
|
|
||||||
</dl>
|
|
||||||
<p class="sp-note fail">The published record does not authorize 203.0.113.25. Add <code>ip4:203.0.113.25</code> before <code>~all</code>.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sp-record">
|
|
||||||
<div class="sp-record-head"><h3>DMARC</h3><span class="sp-tag ok">published</span><small>Report address from <a href="#">Settings</a></small></div>
|
|
||||||
<dl class="sp-kv">
|
|
||||||
<dt>Host</dt><dd><span class="sp-value">_dmarc.example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
|
|
||||||
<dt>Type</dt><dd><span class="sp-value plain">TXT</span></dd>
|
|
||||||
<dt>Value</dt><dd><span class="sp-value">v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
|
|
||||||
</dl>
|
|
||||||
<p class="sp-note"><code>p=none</code> does not affect delivery. Tighten to <code>quarantine</code>, then <code>reject</code>, once the <a href="#">reports</a> look clean.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="sp-sheet">
|
|
||||||
<header>
|
|
||||||
<span class="sp-no">03</span><h2>Applications</h2>
|
|
||||||
<small>SASL logins that may send as this domain</small>
|
|
||||||
<a href="#" role="button" class="sp-btn-sm">Add application</a>
|
|
||||||
</header>
|
|
||||||
<table class="sp-apps">
|
|
||||||
<thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th></th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td class="sp-login">prod-server</td>
|
|
||||||
<td>*@example.org</td>
|
|
||||||
<td>1 102 msg · peak 96/h</td>
|
|
||||||
<td><span class="sp-tag ok">200 / h</span></td>
|
|
||||||
<td><a class="sp-link-btn" href="#">Edit</a><a class="sp-link-btn" href="#">New password</a><a class="sp-link-btn danger" href="#">Delete</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="sp-login">alerts</td>
|
|
||||||
<td>alerts@example.org, noc@example.org</td>
|
|
||||||
<td>182 msg · peak 14/h</td>
|
|
||||||
<td><span class="sp-tag">domain</span> <span class="sp-tag ok">2 IPs</span></td>
|
|
||||||
<td><a class="sp-link-btn" href="#">Edit</a><a class="sp-link-btn" href="#">New password</a><a class="sp-link-btn danger" href="#">Delete</a></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="sp-sheet sp-ledger">
|
|
||||||
<header><span class="sp-no">04</span><h2>Domain settings</h2><small>Rarely changed</small></header>
|
|
||||||
<details>
|
|
||||||
<summary><strong>DMARC reports</strong><span class="sp-muted">Same as Settings — dmarc@mail.example.org</span><span class="sp-tag">default</span></summary>
|
|
||||||
<div class="sp-detail"><p class="sp-muted">The form opens here.</p></div>
|
|
||||||
</details>
|
|
||||||
<details>
|
|
||||||
<summary><strong>Rate limit</strong><span class="sp-muted">Auto ×3 — 288 messages / hour, recalculated yesterday</span><span class="sp-tag ok">active</span></summary>
|
|
||||||
<div class="sp-detail"><p class="sp-muted">The form opens here.</p></div>
|
|
||||||
</details>
|
|
||||||
<details>
|
|
||||||
<summary><strong>Export</strong><span class="sp-muted">DKIM key and applications as one file, optionally encrypted</span><span></span></summary>
|
|
||||||
<div class="sp-detail"><p class="sp-muted">The form opens here.</p></div>
|
|
||||||
</details>
|
|
||||||
<details>
|
|
||||||
<summary><strong>Delete domain</strong><span class="sp-muted">Removes the DKIM key and both applications</span><span></span></summary>
|
|
||||||
<div class="sp-detail"><p class="sp-muted">The confirmation opens here.</p></div>
|
|
||||||
</details>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer class="sp-foot">
|
|
||||||
SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty
|
|
||||||
</footer>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="light">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Sign in · SelfPost</title>
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css">
|
|
||||||
<link rel="stylesheet" href="../shared/brand.css">
|
|
||||||
<link rel="stylesheet" href="theme.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="sp-signin">
|
|
||||||
<div class="sp-signin-card">
|
|
||||||
<img src="../../selfpost-stamp.svg" alt="SelfPost">
|
|
||||||
<section class="sp-sheet">
|
|
||||||
<header><span class="sp-no">→</span><h2>Sign in</h2></header>
|
|
||||||
<div class="sp-body">
|
|
||||||
<form method="post" action="status.html">
|
|
||||||
<label for="username">Username</label>
|
|
||||||
<input id="username" name="username" autocomplete="username" required>
|
|
||||||
<label for="password">Password</label>
|
|
||||||
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
|
||||||
<button type="submit">Sign in</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<p class="sp-tagline">YOUR SMTP. YOUR RULES.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="sp-wrap sp-foot">
|
|
||||||
© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="light">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Overview · SelfPost</title>
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
|
|
||||||
<link rel="stylesheet" href="../shared/brand.css">
|
|
||||||
<link rel="stylesheet" href="theme.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<header class="sp-bar">
|
|
||||||
<div class="sp-wrap">
|
|
||||||
<nav>
|
|
||||||
<ul>
|
|
||||||
<li><a class="sp-brand" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a></li>
|
|
||||||
</ul>
|
|
||||||
<ul class="sp-sections">
|
|
||||||
<li><a href="status.html" aria-current="page">Overview</a></li>
|
|
||||||
<li><a href="domain.html">Outbound</a></li>
|
|
||||||
<li><a href="#">Inbound</a></li>
|
|
||||||
<li><a href="#">Server</a></li>
|
|
||||||
</ul>
|
|
||||||
<ul class="sp-user">
|
|
||||||
<li>
|
|
||||||
<details class="dropdown">
|
|
||||||
<summary>admin</summary>
|
|
||||||
<ul dir="rtl">
|
|
||||||
<li><a href="#">Account</a></li>
|
|
||||||
<li><a href="#">Help</a></li>
|
|
||||||
<li><a href="login.html">Sign out</a></li>
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="sp-wrap">
|
|
||||||
<div class="sp-head">
|
|
||||||
<div>
|
|
||||||
<p class="sp-kicker">mail.example.org</p>
|
|
||||||
<h1>Overview</h1>
|
|
||||||
</div>
|
|
||||||
<small>Updated 14:02:31 UTC · refreshes every 10 s</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="sp-sheet sp-verdict">
|
|
||||||
<div class="sp-postmark warn"><span>SELFPOST</span><b>WARN</b><span>1 OF 6 CHECKS</span></div>
|
|
||||||
<div>
|
|
||||||
<h2>Mail is flowing. One thing needs attention.</h2>
|
|
||||||
<p>The TLS certificate expires in 12 days and has not renewed yet. Everything else is healthy.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="sp-sheet sp-ledger">
|
|
||||||
<header><span class="sp-no">01</span><h2>Checks</h2><small>Open a row for its detail</small></header>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>Processes</strong><span class="sp-muted">5 of 5 running</span><span class="sp-tag ok">ok</span></summary>
|
|
||||||
<div class="sp-detail">
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>postfix</td><td><span class="sp-tag ok">running</span></td><td class="sp-muted">pid 41, uptime 18 days</td></tr>
|
|
||||||
<tr><td>opendkim</td><td><span class="sp-tag ok">running</span></td><td class="sp-muted">pid 39, uptime 18 days</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details open>
|
|
||||||
<summary><strong>TLS certificate</strong><span class="sp-muted">Expires in 12 days — renewal has not run</span><span class="sp-tag warn">warn</span></summary>
|
|
||||||
<div class="sp-detail">
|
|
||||||
<dl class="sp-kv">
|
|
||||||
<dt>Expires</dt><dd><span class="sp-value">2026-10-03 09:14 UTC</span></dd>
|
|
||||||
<dt>Names</dt><dd><span class="sp-value">mail.example.org</span></dd>
|
|
||||||
</dl>
|
|
||||||
<p class="sp-note">Renewal normally runs 30 days before expiry. Check that port 80 is reachable and look for <code>acme</code> lines in the <a href="#">system log</a>.</p>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>Machine</strong><span class="sp-muted">CPU 12 % · memory 41 % · ↓ 18 kB/s ↑ 44 kB/s</span><span class="sp-tag ok">ok</span></summary>
|
|
||||||
<div class="sp-detail">
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>CPU</td><td><meter value="12" min="0" max="100" low="70" high="90" optimum="10"></meter>12 %</td><td class="sp-muted">2 cores, load 0.21</td></tr>
|
|
||||||
<tr><td>Memory</td><td><meter value="41" min="0" max="100" low="70" high="90" optimum="10"></meter>41 %</td><td class="sp-muted">812 MB of 1.9 GB</td></tr>
|
|
||||||
<tr><td>Network</td><td>↓ 18 kB/s · ↑ 44 kB/s</td><td class="sp-muted">eth0: 2.1 GB in, 6.4 GB out</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>Queues</strong><span class="sp-muted">Outbound empty · inbound empty</span><span class="sp-tag ok">ok</span></summary>
|
|
||||||
<div class="sp-detail"><p>No messages are waiting. Open the <a href="#">outbound queue</a> or the <a href="#">inbound queue</a>.</p></div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>Milter sockets</strong><span class="sp-muted">2 of 2 answering</span><span class="sp-tag ok">ok</span></summary>
|
|
||||||
<div class="sp-detail">
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Milter</th><th>State</th><th>Detail</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>opendkim</td><td><span class="sp-tag ok">ok</span></td><td class="sp-muted">inet:127.0.0.1:8891</td></tr>
|
|
||||||
<tr><td>journal-milter</td><td><span class="sp-tag ok">ok</span></td><td class="sp-muted">unix:/run/selfpost/journal.sock</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>Hostname and reverse DNS</strong><span class="sp-muted">mail.example.org ⇄ 203.0.113.25</span><span class="sp-tag ok">ok</span></summary>
|
|
||||||
<div class="sp-detail">
|
|
||||||
<dl class="sp-kv">
|
|
||||||
<dt>Hostname</dt><dd><span class="sp-value">mail.example.org</span></dd>
|
|
||||||
<dt>Lookup</dt><dd><span class="sp-value">mail.example.org → 203.0.113.25<br>203.0.113.25 → mail.example.org</span></dd>
|
|
||||||
</dl>
|
|
||||||
<form method="post" action="#"><button type="submit" class="outline secondary sp-btn-sm">Re-check DNS</button></form>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer class="sp-foot">
|
|
||||||
SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty
|
|
||||||
</footer>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
/* Direction A — "Blank" (the postal form). Pico CSS v2 carries every generic
|
|
||||||
control (type, buttons, inputs, tables, details, dropdown); this file maps
|
|
||||||
the brand onto Pico's variables and adds only what is SelfPost's own:
|
|
||||||
the two-row header, the sheet, numbered form sections, the postmark and
|
|
||||||
the DNS record block. */
|
|
||||||
|
|
||||||
/* :root[...] rather than [data-theme=light] alone: Pico declares its light
|
|
||||||
palette on :root:not([data-theme=dark]), which a bare attribute selector
|
|
||||||
loses to. */
|
|
||||||
:root[data-theme=light] {
|
|
||||||
--pico-font-family-sans-serif: var(--sp-sans);
|
|
||||||
--pico-font-family-monospace: var(--sp-mono);
|
|
||||||
--pico-font-family: var(--sp-sans);
|
|
||||||
--pico-border-radius: 2px;
|
|
||||||
--pico-spacing: .9rem;
|
|
||||||
--pico-typography-spacing-vertical: .9rem;
|
|
||||||
--pico-form-element-spacing-vertical: .5rem;
|
|
||||||
--pico-form-element-spacing-horizontal: .75rem;
|
|
||||||
|
|
||||||
--pico-background-color: var(--sp-paper);
|
|
||||||
--pico-color: var(--sp-ink);
|
|
||||||
--pico-muted-color: var(--sp-muted);
|
|
||||||
--pico-muted-border-color: var(--sp-rule);
|
|
||||||
--pico-h1-color: var(--sp-ink); --pico-h2-color: var(--sp-ink);
|
|
||||||
--pico-h3-color: var(--sp-ink); --pico-h4-color: var(--sp-ink);
|
|
||||||
|
|
||||||
--pico-primary: var(--sp-brick);
|
|
||||||
--pico-primary-background: var(--sp-brick);
|
|
||||||
--pico-primary-border: var(--sp-brick);
|
|
||||||
--pico-primary-underline: rgb(122 59 46 / .35);
|
|
||||||
--pico-primary-hover: var(--sp-brick-dark);
|
|
||||||
--pico-primary-hover-background: var(--sp-brick-dark);
|
|
||||||
--pico-primary-hover-border: var(--sp-brick-dark);
|
|
||||||
--pico-primary-hover-underline: var(--sp-brick-dark);
|
|
||||||
--pico-primary-focus: rgb(122 59 46 / .25);
|
|
||||||
--pico-primary-inverse: #fff;
|
|
||||||
|
|
||||||
--pico-secondary: var(--sp-ink-2);
|
|
||||||
--pico-secondary-background: var(--sp-ink-2);
|
|
||||||
--pico-secondary-border: var(--sp-ink-2);
|
|
||||||
--pico-secondary-hover: var(--sp-ink);
|
|
||||||
--pico-secondary-hover-background: var(--sp-ink);
|
|
||||||
--pico-secondary-hover-border: var(--sp-ink);
|
|
||||||
--pico-secondary-focus: rgb(18 22 28 / .2);
|
|
||||||
--pico-secondary-inverse: #fff;
|
|
||||||
|
|
||||||
--pico-form-element-background-color: #fff;
|
|
||||||
--pico-form-element-border-color: var(--sp-rule-strong);
|
|
||||||
--pico-form-element-active-border-color: var(--sp-brick);
|
|
||||||
--pico-form-element-focus-color: rgb(122 59 46 / .25);
|
|
||||||
--pico-table-border-color: var(--sp-rule);
|
|
||||||
--pico-code-background-color: var(--sp-well);
|
|
||||||
--pico-code-color: var(--sp-ink);
|
|
||||||
--pico-dropdown-background-color: #fff;
|
|
||||||
--pico-dropdown-border-color: var(--sp-rule);
|
|
||||||
--pico-dropdown-hover-background-color: var(--sp-brick-tint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pico grows the root font with the viewport (up to 131%). An ops panel wants
|
|
||||||
one size everywhere. */
|
|
||||||
:root { font-size: 15px; }
|
|
||||||
body { min-height: 100vh; display: flex; flex-direction: column; }
|
|
||||||
h1 { font-size: 1.7rem; font-weight: 600; margin: 0; }
|
|
||||||
small, .sp-muted { color: var(--sp-muted); }
|
|
||||||
code, .sp-mono { font-family: var(--sp-mono); font-size: .88em; }
|
|
||||||
|
|
||||||
/* ---- header: sections on the first row, the section's pages on the second */
|
|
||||||
.sp-wrap { width: 100%; max-width: 72rem; margin-inline: auto; padding-inline: 1.5rem; }
|
|
||||||
.sp-bar { background: var(--sp-sheet); border-bottom: 1px solid var(--sp-rule); }
|
|
||||||
.sp-bar nav { align-items: center; }
|
|
||||||
.sp-bar nav li { padding-block: .45rem; }
|
|
||||||
.sp-brand img { height: 46px; width: auto; display: block; }
|
|
||||||
.sp-sections a {
|
|
||||||
font-family: var(--sp-mono); font-size: .8rem; font-weight: 600;
|
|
||||||
letter-spacing: .09em; text-transform: uppercase; text-decoration: none;
|
|
||||||
color: var(--sp-muted); padding: .5rem .8rem; margin: 0; border-radius: 0;
|
|
||||||
}
|
|
||||||
.sp-sections a:hover { color: var(--sp-ink); }
|
|
||||||
.sp-sections a[aria-current] { color: var(--sp-brick); box-shadow: inset 0 -3px 0 var(--sp-brick); }
|
|
||||||
.sp-user summary { font-size: .9rem; }
|
|
||||||
.sp-pages { background: var(--sp-paper); border-bottom: 1px solid var(--sp-rule); }
|
|
||||||
.sp-pages .sp-wrap { display: flex; gap: 1.6rem; }
|
|
||||||
.sp-pages a { color: var(--sp-muted); text-decoration: none; padding: .6rem 0; font-size: .95rem; }
|
|
||||||
.sp-pages a:hover { color: var(--sp-ink); }
|
|
||||||
.sp-pages a[aria-current] { color: var(--sp-ink); font-weight: 600; box-shadow: inset 0 -2px 0 var(--sp-ink); }
|
|
||||||
|
|
||||||
/* ---- the sheet: one column, one width, every page */
|
|
||||||
main.sp-wrap { flex: 1; padding-block: 1.8rem 2.5rem; }
|
|
||||||
.sp-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.4rem; }
|
|
||||||
.sp-head .sp-kicker { font-family: var(--sp-mono); font-size: .75rem; letter-spacing: .1em; text-transform: uppercase; color: var(--sp-muted); margin: 0 0 .15rem; }
|
|
||||||
.sp-head .sp-kicker a { color: inherit; }
|
|
||||||
.sp-head-actions { display: flex; gap: .5rem; align-items: center; }
|
|
||||||
.sp-head-actions > * { margin: 0; }
|
|
||||||
.sp-sheet { background: var(--sp-sheet); border: 1px solid var(--sp-rule); margin-bottom: 1.4rem; }
|
|
||||||
.sp-sheet > header {
|
|
||||||
display: flex; align-items: baseline; gap: .8rem; padding: .7rem 1.2rem;
|
|
||||||
border-bottom: 1px solid var(--sp-rule);
|
|
||||||
}
|
|
||||||
.sp-sheet > header .sp-no { font-family: var(--sp-mono); font-size: .75rem; color: var(--sp-brick); font-weight: 600; }
|
|
||||||
.sp-sheet > header h2 {
|
|
||||||
font-family: var(--sp-mono); font-size: .8rem; font-weight: 600; letter-spacing: .1em;
|
|
||||||
text-transform: uppercase; margin: 0; flex: 1;
|
|
||||||
}
|
|
||||||
.sp-sheet > header small { font-size: .85rem; }
|
|
||||||
.sp-sheet > .sp-body { padding: 1.1rem 1.2rem; }
|
|
||||||
.sp-sheet > .sp-body > :last-child { margin-bottom: 0; }
|
|
||||||
.sp-foot { color: var(--sp-muted); font-size: .8rem; border-top: 1px solid var(--sp-rule); padding-block: 1rem; }
|
|
||||||
.sp-foot a { color: inherit; }
|
|
||||||
|
|
||||||
/* ---- status tag and the postmark */
|
|
||||||
.sp-tag {
|
|
||||||
display: inline-block; font-family: var(--sp-mono); font-size: .72rem; font-weight: 600;
|
|
||||||
letter-spacing: .08em; text-transform: uppercase; padding: .1rem .5rem;
|
|
||||||
border: 1px solid var(--sp-idle-rule); background: var(--sp-idle-bg); color: var(--sp-idle);
|
|
||||||
border-radius: 2px; white-space: nowrap; text-decoration: none;
|
|
||||||
}
|
|
||||||
.sp-tag.ok { color: var(--sp-ok); background: var(--sp-ok-bg); border-color: var(--sp-ok-rule); }
|
|
||||||
.sp-tag.warn { color: var(--sp-warn); background: var(--sp-warn-bg); border-color: var(--sp-warn-rule); }
|
|
||||||
.sp-tag.fail { color: var(--sp-fail); background: var(--sp-fail-bg); border-color: var(--sp-fail-rule); }
|
|
||||||
|
|
||||||
.sp-verdict { display: flex; align-items: center; gap: 1.5rem; padding: 1.3rem 1.4rem; }
|
|
||||||
.sp-verdict h2 { margin: 0 0 .2rem; font-size: 1.35rem; font-weight: 600; }
|
|
||||||
.sp-verdict p { margin: 0; color: var(--sp-muted); }
|
|
||||||
.sp-postmark {
|
|
||||||
flex: none; width: 5.6rem; height: 5.6rem; border-radius: 50%;
|
|
||||||
border: 2px solid currentColor; outline: 1px solid currentColor; outline-offset: 3px;
|
|
||||||
display: grid; place-content: center; text-align: center; transform: rotate(-9deg);
|
|
||||||
font-family: var(--sp-mono); font-weight: 600; letter-spacing: .08em; line-height: 1.15;
|
|
||||||
color: var(--sp-ok);
|
|
||||||
}
|
|
||||||
.sp-postmark.warn { color: var(--sp-warn); }
|
|
||||||
.sp-postmark.fail { color: var(--sp-fail); }
|
|
||||||
.sp-postmark b { font-size: 1.25rem; display: block; }
|
|
||||||
.sp-postmark span { font-size: .55rem; display: block; }
|
|
||||||
|
|
||||||
/* ---- ledger: one row per check, the row opens into its detail */
|
|
||||||
.sp-ledger details { margin: 0; padding: 0; border-bottom: 1px solid var(--sp-rule); }
|
|
||||||
.sp-ledger details:last-child { border-bottom: 0; }
|
|
||||||
.sp-ledger summary {
|
|
||||||
display: grid; grid-template-columns: 13rem 1fr auto 1.2rem; gap: 1rem; align-items: center;
|
|
||||||
padding: .75rem 1.2rem; margin: 0; color: var(--sp-ink);
|
|
||||||
}
|
|
||||||
.sp-ledger summary::after { grid-column: 4; margin: 0; float: none; }
|
|
||||||
.sp-ledger summary:hover { background: var(--sp-paper); }
|
|
||||||
.sp-ledger summary strong { font-weight: 600; }
|
|
||||||
.sp-ledger summary span.sp-muted { font-size: .93rem; }
|
|
||||||
.sp-ledger .sp-detail { padding: .2rem 1.2rem 1.1rem 15.2rem; }
|
|
||||||
.sp-ledger .sp-detail table { margin: 0; font-size: .93rem; }
|
|
||||||
.sp-ledger .sp-detail > :last-child { margin-bottom: 0; }
|
|
||||||
.sp-ledger meter { width: 9rem; height: .7rem; vertical-align: middle; margin-right: .5rem; }
|
|
||||||
|
|
||||||
/* ---- label / value / copy: the DNS record block and connection settings */
|
|
||||||
.sp-record { padding: 1.1rem 1.2rem; border-bottom: 1px solid var(--sp-rule); }
|
|
||||||
.sp-record:last-child { border-bottom: 0; }
|
|
||||||
.sp-record-head { display: flex; align-items: center; gap: .7rem; margin-bottom: .7rem; }
|
|
||||||
.sp-record-head h3 { margin: 0; font-size: 1.05rem; font-weight: 600; }
|
|
||||||
.sp-record-head small { margin-left: auto; }
|
|
||||||
.sp-kv { display: grid; grid-template-columns: 6.5rem 1fr; gap: .45rem 1rem; align-items: start; margin: 0; }
|
|
||||||
.sp-kv dt {
|
|
||||||
font-family: var(--sp-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase;
|
|
||||||
color: var(--sp-muted); padding-top: .5rem; margin: 0;
|
|
||||||
}
|
|
||||||
.sp-kv dd { margin: 0; display: flex; gap: .4rem; align-items: stretch; min-width: 0; }
|
|
||||||
.sp-value {
|
|
||||||
flex: 1; min-width: 0; font-family: var(--sp-mono); font-size: .86rem; line-height: 1.5;
|
|
||||||
background: var(--sp-well); border: 1px solid var(--sp-rule); padding: .4rem .65rem;
|
|
||||||
overflow-wrap: anywhere; border-radius: 2px;
|
|
||||||
}
|
|
||||||
.sp-value.plain { background: none; border-color: transparent; padding-left: 0; font-family: var(--sp-sans); font-size: .95rem; }
|
|
||||||
.sp-copy {
|
|
||||||
flex: none; align-self: flex-start; height: 2.15rem; margin: 0; padding: 0 .7rem; font-size: .8rem;
|
|
||||||
--pico-background-color: #fff; --pico-border-color: var(--sp-rule-strong); --pico-color: var(--sp-ink-2);
|
|
||||||
}
|
|
||||||
.sp-copy:hover { --pico-background-color: var(--sp-paper); --pico-border-color: var(--sp-ink-2); --pico-color: var(--sp-ink); }
|
|
||||||
.sp-note { margin: .7rem 0 0 7.5rem; font-size: .9rem; color: var(--sp-muted); }
|
|
||||||
.sp-note.fail { color: var(--sp-fail); }
|
|
||||||
.sp-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); gap: 0 2.4rem; }
|
|
||||||
|
|
||||||
/* ---- applications */
|
|
||||||
.sp-apps { margin: 0; font-size: .95rem; }
|
|
||||||
.sp-apps td, .sp-apps th { padding-inline: 1.2rem .4rem; vertical-align: middle; }
|
|
||||||
.sp-apps th { font-family: var(--sp-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); font-weight: 400; }
|
|
||||||
.sp-apps td:last-child { text-align: right; padding-right: 1.2rem; white-space: nowrap; }
|
|
||||||
.sp-apps .sp-login { font-family: var(--sp-mono); font-weight: 600; }
|
|
||||||
.sp-link-btn { font-size: .9rem; margin-left: .9rem; }
|
|
||||||
.sp-link-btn.danger { color: var(--sp-fail); --pico-underline: rgb(180 35 24 / .35); text-decoration-color: rgb(180 35 24 / .35); }
|
|
||||||
.sp-btn-sm { padding: .35rem .8rem; font-size: .9rem; margin: 0; width: auto; }
|
|
||||||
|
|
||||||
/* ---- sign-in */
|
|
||||||
.sp-signin { flex: 1; display: grid; place-content: center; padding: 2rem 1.5rem; }
|
|
||||||
.sp-signin-card { width: 23rem; max-width: 100%; }
|
|
||||||
.sp-signin-card img { display: block; width: 15rem; height: auto; margin: 0 auto 1.6rem; }
|
|
||||||
.sp-signin-card .sp-body { padding: 1.4rem 1.4rem 1.2rem; }
|
|
||||||
.sp-signin-card button { margin-bottom: 0; }
|
|
||||||
.sp-tagline { text-align: center; font-family: var(--sp-mono); font-size: .75rem; letter-spacing: .16em; color: var(--sp-muted); margin-top: 1.2rem; }
|
|
||||||
|
|
||||||
@media (max-width: 48rem) {
|
|
||||||
.sp-ledger summary { grid-template-columns: 1fr auto 1.2rem; }
|
|
||||||
.sp-ledger summary span.sp-muted { grid-column: 1 / -1; grid-row: 2; }
|
|
||||||
.sp-ledger summary::after { grid-column: 3; }
|
|
||||||
.sp-ledger .sp-detail { padding-left: 1.2rem; }
|
|
||||||
.sp-kv { grid-template-columns: 1fr; gap: .15rem; }
|
|
||||||
.sp-kv dt { padding-top: .5rem; }
|
|
||||||
.sp-note { margin-left: 0; }
|
|
||||||
.sp-pages .sp-wrap { overflow-x: auto; white-space: nowrap; }
|
|
||||||
}
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-bs-theme="light">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>example.org · Outbound domains · SelfPost</title>
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.5.1/dist/css/tabler.min.css">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
|
|
||||||
<link rel="stylesheet" href="../shared/brand.css">
|
|
||||||
<link rel="stylesheet" href="theme.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="page">
|
|
||||||
|
|
||||||
<aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<a class="navbar-brand" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a>
|
|
||||||
<div class="navbar-collapse collapse show">
|
|
||||||
<ul class="navbar-nav">
|
|
||||||
<li class="nav-item"><a class="nav-link" href="status.html"><span class="nav-link-icon"><i class="ti ti-activity-heartbeat"></i></span><span class="nav-link-title">Overview</span></a></li>
|
|
||||||
|
|
||||||
<li class="sp-nav-label">Outbound</li>
|
|
||||||
<li class="nav-item active"><a class="nav-link" href="domain.html"><span class="nav-link-icon"><i class="ti ti-world-upload"></i></span><span class="nav-link-title">Domains</span></a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-list-details"></i></span><span class="nav-link-title">Log</span></a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-stack-2"></i></span><span class="nav-link-title">Queue</span></a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-report-analytics"></i></span><span class="nav-link-title">DMARC reports</span></a></li>
|
|
||||||
|
|
||||||
<li class="sp-nav-label">Inbound</li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-world-download"></i></span><span class="nav-link-title">Domains</span></a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-list-details"></i></span><span class="nav-link-title">Log</span></a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-stack-2"></i></span><span class="nav-link-title">Queue</span></a></li>
|
|
||||||
|
|
||||||
<li class="sp-nav-label">Server</li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-file-text"></i></span><span class="nav-link-title">System log</span></a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-archive"></i></span><span class="nav-link-title">Backup</span></a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-users"></i></span><span class="nav-link-title">Users</span></a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-settings"></i></span><span class="nav-link-title">Settings</span></a></li>
|
|
||||||
</ul>
|
|
||||||
<ul class="navbar-nav sp-nav-foot">
|
|
||||||
<li class="sp-nav-user">Signed in as admin</li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-user-circle"></i></span><span class="nav-link-title">Account</span></a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-help"></i></span><span class="nav-link-title">Help</span></a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="login.html"><span class="nav-link-icon"><i class="ti ti-logout"></i></span><span class="nav-link-title">Sign out</span></a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<div class="page-wrapper">
|
|
||||||
<div class="page-header">
|
|
||||||
<div class="container-xl">
|
|
||||||
<div class="row g-2 align-items-center">
|
|
||||||
<div class="col">
|
|
||||||
<div class="page-pretitle"><a href="#" class="text-reset">Outbound domains</a> /</div>
|
|
||||||
<h2 class="page-title">example.org <span class="badge bg-red-lt ms-2">DNS: 1 mismatch</span></h2>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<div class="btn-list">
|
|
||||||
<form method="post" action="#"><button type="submit" class="btn"><i class="ti ti-refresh me-1"></i>Re-check DNS</button></form>
|
|
||||||
<a href="#" class="btn btn-primary"><i class="ti ti-plus me-1"></i>Add application</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="page-body">
|
|
||||||
<div class="container-xl">
|
|
||||||
<div class="row row-cards">
|
|
||||||
|
|
||||||
<div class="col-lg-8">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><h3 class="card-title">DNS records</h3><div class="card-actions text-secondary small">Checked 3 min ago</div></div>
|
|
||||||
|
|
||||||
<div class="card-body sp-record">
|
|
||||||
<div class="d-flex align-items-center mb-3">
|
|
||||||
<h4 class="m-0 me-2">DKIM</h4><span class="badge bg-green-lt">published</span>
|
|
||||||
<span class="ms-auto text-secondary small">Not a secret · selector <code>sp2026</code></span>
|
|
||||||
</div>
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-9">
|
|
||||||
<label class="form-label">Host / name</label>
|
|
||||||
<div class="input-group"><input class="form-control" readonly value="sp2026._domainkey.example.org"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Type</label>
|
|
||||||
<input class="form-control" readonly value="TXT">
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<label class="form-label">Value to publish</label>
|
|
||||||
<div class="input-group"><textarea class="form-control" rows="3" readonly>v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3kqV0mJd8e2f1Yb7Qw5uT9sLr4nHc6pZaXo1vKe0yWm2gBt8RjN5dCq3hUf7lPs9aEi4oMz6xTb1kGv0nYw…IDAQAB</textarea><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-body sp-record">
|
|
||||||
<div class="d-flex align-items-center mb-3">
|
|
||||||
<h4 class="m-0 me-2">SPF</h4><span class="badge bg-red-lt">mismatch</span>
|
|
||||||
<span class="ms-auto text-secondary small">Merge into the existing record — never publish a second one</span>
|
|
||||||
</div>
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-9">
|
|
||||||
<label class="form-label">Host / name</label>
|
|
||||||
<div class="input-group"><input class="form-control" readonly value="example.org"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Type</label>
|
|
||||||
<input class="form-control" readonly value="TXT">
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<label class="form-label">Value to publish</label>
|
|
||||||
<div class="input-group"><input class="form-control" readonly value="v=spf1 ip4:203.0.113.25 ~all"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<label class="form-label">In DNS now</label>
|
|
||||||
<input class="form-control" readonly value="v=spf1 include:_spf.google.com ~all">
|
|
||||||
<div class="text-danger small mt-2">The published record does not authorize 203.0.113.25. Add <code>ip4:203.0.113.25</code> before <code>~all</code>.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-body sp-record">
|
|
||||||
<div class="d-flex align-items-center mb-3">
|
|
||||||
<h4 class="m-0 me-2">DMARC</h4><span class="badge bg-green-lt">published</span>
|
|
||||||
<span class="ms-auto text-secondary small">Report address from <a href="#">Settings</a></span>
|
|
||||||
</div>
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-9">
|
|
||||||
<label class="form-label">Host / name</label>
|
|
||||||
<div class="input-group"><input class="form-control" readonly value="_dmarc.example.org"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Type</label>
|
|
||||||
<input class="form-control" readonly value="TXT">
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<label class="form-label">Value to publish</label>
|
|
||||||
<div class="input-group"><input class="form-control" readonly value="v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-4">
|
|
||||||
<div class="card mb-3">
|
|
||||||
<div class="card-header"><h3 class="card-title">Connection</h3></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="datagrid">
|
|
||||||
<div class="datagrid-item"><div class="datagrid-title">Server</div><div class="datagrid-content font-monospace">mail.example.org</div></div>
|
|
||||||
<div class="datagrid-item"><div class="datagrid-title">Ports</div><div class="datagrid-content font-monospace">465 SSL/TLS<br>587 STARTTLS</div></div>
|
|
||||||
</div>
|
|
||||||
<p class="text-secondary small mt-3 mb-0">The same for every domain. Authenticate with an application login.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card mb-3">
|
|
||||||
<div class="card-header"><h3 class="card-title">Sent, 30 days</h3></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="datagrid">
|
|
||||||
<div class="datagrid-item"><div class="datagrid-title">Messages</div><div class="datagrid-content h2 m-0">1 284</div></div>
|
|
||||||
<div class="datagrid-item"><div class="datagrid-title">Peak</div><div class="datagrid-content h2 m-0">96 <span class="text-secondary fs-5">/h</span></div></div>
|
|
||||||
<div class="datagrid-item"><div class="datagrid-title">Average</div><div class="datagrid-content h2 m-0">1.8 <span class="text-secondary fs-5">/h</span></div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><h3 class="card-title">Domain settings</h3></div>
|
|
||||||
<div class="list-group list-group-flush">
|
|
||||||
<a href="#" class="list-group-item list-group-item-action d-flex align-items-center"><div><div>DMARC reports</div><div class="text-secondary small">Same as Settings</div></div><i class="ti ti-chevron-right ms-auto text-secondary"></i></a>
|
|
||||||
<a href="#" class="list-group-item list-group-item-action d-flex align-items-center"><div><div>Rate limit <span class="badge bg-green-lt ms-1">active</span></div><div class="text-secondary small">Auto ×3 — 288 messages / hour</div></div><i class="ti ti-chevron-right ms-auto text-secondary"></i></a>
|
|
||||||
<a href="#" class="list-group-item list-group-item-action d-flex align-items-center"><div><div>Export</div><div class="text-secondary small">Key and applications as one file</div></div><i class="ti ti-chevron-right ms-auto text-secondary"></i></a>
|
|
||||||
<a href="#" class="list-group-item list-group-item-action d-flex align-items-center text-danger"><div>Delete domain</div><i class="ti ti-chevron-right ms-auto"></i></a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><h3 class="card-title">Applications</h3><div class="card-actions text-secondary small">SASL logins that may send as this domain</div></div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-vcenter card-table">
|
|
||||||
<thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th class="w-1"></th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td class="font-monospace fw-semibold">prod-server</td><td>*@example.org</td><td class="text-secondary">1 102 msg · peak 96/h</td>
|
|
||||||
<td><span class="badge bg-green-lt">200 / h</span></td>
|
|
||||||
<td><div class="btn-list flex-nowrap"><a href="#" class="btn btn-sm">Edit</a><a href="#" class="btn btn-sm">New password</a><a href="#" class="btn btn-sm btn-ghost-danger">Delete</a></div></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="font-monospace fw-semibold">alerts</td><td>alerts@example.org, noc@example.org</td><td class="text-secondary">182 msg · peak 14/h</td>
|
|
||||||
<td><span class="badge bg-secondary-lt">domain</span> <span class="badge bg-green-lt">2 IPs</span></td>
|
|
||||||
<td><div class="btn-list flex-nowrap"><a href="#" class="btn btn-sm">Edit</a><a href="#" class="btn btn-sm">New password</a><a href="#" class="btn btn-sm btn-ghost-danger">Delete</a></div></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="footer footer-transparent">
|
|
||||||
<div class="container-xl sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-bs-theme="light">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Sign in · SelfPost</title>
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.5.1/dist/css/tabler.min.css">
|
|
||||||
<link rel="stylesheet" href="../shared/brand.css">
|
|
||||||
<link rel="stylesheet" href="theme.css">
|
|
||||||
</head>
|
|
||||||
<body class="d-flex flex-column">
|
|
||||||
<div class="page page-center">
|
|
||||||
<div class="container container-tight py-4">
|
|
||||||
<div class="text-center mb-4"><img class="sp-signin-mark" src="../../selfpost-stamp.svg" alt="SelfPost"></div>
|
|
||||||
<div class="card card-md">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="h2 text-center mb-4">Sign in</h2>
|
|
||||||
<form method="post" action="status.html">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label" for="username">Username</label>
|
|
||||||
<input class="form-control" id="username" name="username" autocomplete="username" required>
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label" for="password">Password</label>
|
|
||||||
<input class="form-control" id="password" name="password" type="password" autocomplete="current-password" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-footer"><button type="submit" class="btn btn-primary w-100">Sign in</button></div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-center sp-tagline mt-4">YOUR SMTP. YOUR RULES.</div>
|
|
||||||
<div class="text-center sp-foot mt-3">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-bs-theme="light">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Overview · SelfPost</title>
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.5.1/dist/css/tabler.min.css">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
|
|
||||||
<link rel="stylesheet" href="../shared/brand.css">
|
|
||||||
<link rel="stylesheet" href="theme.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="page">
|
|
||||||
|
|
||||||
<aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<a class="navbar-brand" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a>
|
|
||||||
<div class="navbar-collapse collapse show">
|
|
||||||
<ul class="navbar-nav">
|
|
||||||
<li class="nav-item active"><a class="nav-link" href="status.html"><span class="nav-link-icon"><i class="ti ti-activity-heartbeat"></i></span><span class="nav-link-title">Overview</span></a></li>
|
|
||||||
|
|
||||||
<li class="sp-nav-label">Outbound</li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="domain.html"><span class="nav-link-icon"><i class="ti ti-world-upload"></i></span><span class="nav-link-title">Domains</span></a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-list-details"></i></span><span class="nav-link-title">Log</span></a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-stack-2"></i></span><span class="nav-link-title">Queue</span></a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-report-analytics"></i></span><span class="nav-link-title">DMARC reports</span></a></li>
|
|
||||||
|
|
||||||
<li class="sp-nav-label">Inbound</li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-world-download"></i></span><span class="nav-link-title">Domains</span></a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-list-details"></i></span><span class="nav-link-title">Log</span></a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-stack-2"></i></span><span class="nav-link-title">Queue</span></a></li>
|
|
||||||
|
|
||||||
<li class="sp-nav-label">Server</li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-file-text"></i></span><span class="nav-link-title">System log</span></a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-archive"></i></span><span class="nav-link-title">Backup</span></a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-users"></i></span><span class="nav-link-title">Users</span></a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-settings"></i></span><span class="nav-link-title">Settings</span></a></li>
|
|
||||||
</ul>
|
|
||||||
<ul class="navbar-nav sp-nav-foot">
|
|
||||||
<li class="sp-nav-user">Signed in as admin</li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-user-circle"></i></span><span class="nav-link-title">Account</span></a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-help"></i></span><span class="nav-link-title">Help</span></a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="login.html"><span class="nav-link-icon"><i class="ti ti-logout"></i></span><span class="nav-link-title">Sign out</span></a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<div class="page-wrapper">
|
|
||||||
<div class="page-header">
|
|
||||||
<div class="container-xl">
|
|
||||||
<div class="row g-2 align-items-center">
|
|
||||||
<div class="col">
|
|
||||||
<div class="page-pretitle">mail.example.org</div>
|
|
||||||
<h2 class="page-title">Overview</h2>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto text-secondary small">Updated 14:02:31 UTC · refreshes every 10 s</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="page-body">
|
|
||||||
<div class="container-xl">
|
|
||||||
|
|
||||||
<div class="alert alert-warning bg-white" role="alert">
|
|
||||||
<div class="d-flex">
|
|
||||||
<div><i class="ti ti-alert-triangle alert-icon"></i></div>
|
|
||||||
<div>
|
|
||||||
<h4 class="alert-title">Mail is flowing. One thing needs attention.</h4>
|
|
||||||
<div class="text-secondary">The TLS certificate expires in 12 days and has not renewed yet.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row row-cards mb-3">
|
|
||||||
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-cpu sp-tile-icon"></i><div><div class="fw-semibold">Machine</div><div class="text-secondary small">CPU 12 % · mem 41 %</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
|
|
||||||
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-server-cog sp-tile-icon"></i><div><div class="fw-semibold">Processes</div><div class="text-secondary small">5 of 5 running</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
|
|
||||||
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-stack-2 sp-tile-icon"></i><div><div class="fw-semibold">Queues</div><div class="text-secondary small">Both empty</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
|
|
||||||
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-certificate sp-tile-icon"></i><div><div class="fw-semibold">TLS certificate</div><div class="text-secondary small">Expires in 12 days</div></div><span class="status status-yellow"><span class="status-dot"></span>Warn</span></div></div></div>
|
|
||||||
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-plug-connected sp-tile-icon"></i><div><div class="fw-semibold">Milter sockets</div><div class="text-secondary small">2 of 2 answering</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
|
|
||||||
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-arrows-exchange sp-tile-icon"></i><div><div class="fw-semibold">Reverse DNS</div><div class="text-secondary small">Forward = reverse</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row row-deck row-cards">
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><h3 class="card-title">Machine</h3><div class="card-actions"><span class="badge bg-green-lt">ok</span></div></div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-vcenter card-table">
|
|
||||||
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>CPU</td><td><progress class="sp-bar" value="12" max="100"></progress>12 %</td><td class="text-secondary">2 cores, load 0.21</td></tr>
|
|
||||||
<tr><td>Memory</td><td><progress class="sp-bar" value="41" max="100"></progress>41 %</td><td class="text-secondary">812 MB of 1.9 GB</td></tr>
|
|
||||||
<tr><td>Network</td><td>↓ 18 kB/s · ↑ 44 kB/s</td><td class="text-secondary">eth0: 2.1 GB in, 6.4 GB out</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><h3 class="card-title">Processes</h3><div class="card-actions"><span class="badge bg-green-lt">ok</span></div></div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-vcenter card-table">
|
|
||||||
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>postfix</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 41, uptime 18 days</td></tr>
|
|
||||||
<tr><td>opendkim</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 39, uptime 18 days</td></tr>
|
|
||||||
<tr><td>journal-milter</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 40, uptime 18 days</td></tr>
|
|
||||||
<tr><td>panel</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 44, uptime 18 days</td></tr>
|
|
||||||
<tr><td>dmarc-ingest</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 47, uptime 18 days</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-4">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><h3 class="card-title">TLS certificate</h3><div class="card-actions"><span class="badge bg-yellow-lt">warn</span></div></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="datagrid">
|
|
||||||
<div class="datagrid-item"><div class="datagrid-title">Expires</div><div class="datagrid-content font-monospace">2026-10-03 09:14 UTC</div></div>
|
|
||||||
<div class="datagrid-item"><div class="datagrid-title">Names</div><div class="datagrid-content font-monospace">mail.example.org</div></div>
|
|
||||||
</div>
|
|
||||||
<p class="text-secondary mt-3 mb-0">Renewal normally runs 30 days before expiry. Check port 80 and the <a href="#">system log</a>.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-4">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><h3 class="card-title">Milter sockets</h3><div class="card-actions"><span class="badge bg-green-lt">ok</span></div></div>
|
|
||||||
<table class="table table-vcenter card-table">
|
|
||||||
<thead><tr><th>Milter</th><th>State</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>opendkim<div class="text-secondary small font-monospace">inet:127.0.0.1:8891</div></td><td><span class="badge bg-green-lt">ok</span></td></tr>
|
|
||||||
<tr><td>journal-milter<div class="text-secondary small font-monospace">unix:/run/selfpost/journal.sock</div></td><td><span class="badge bg-green-lt">ok</span></td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-4">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><h3 class="card-title">Hostname and reverse DNS</h3><div class="card-actions"><span class="badge bg-green-lt">ok</span></div></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="datagrid">
|
|
||||||
<div class="datagrid-item"><div class="datagrid-title">Hostname</div><div class="datagrid-content font-monospace">mail.example.org</div></div>
|
|
||||||
<div class="datagrid-item"><div class="datagrid-title">Lookup</div><div class="datagrid-content font-monospace">→ 203.0.113.25<br>← mail.example.org</div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer"><form method="post" action="#"><button type="submit" class="btn btn-sm"><i class="ti ti-refresh me-1"></i>Re-check DNS</button></form></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="footer footer-transparent">
|
|
||||||
<div class="container-xl sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
/* Direction B — "Console". Tabler (Bootstrap 5 admin kit) carries the shell,
|
|
||||||
cards, tables, badges, input groups and the sign-in page as shipped; this
|
|
||||||
file only re-points Tabler's variables at the brand and adds the two things
|
|
||||||
Tabler has no word for: a sidebar group label and the DNS record block. */
|
|
||||||
|
|
||||||
:root, [data-bs-theme=light] {
|
|
||||||
--tblr-font-sans-serif: var(--sp-sans);
|
|
||||||
--tblr-font-monospace: var(--sp-mono);
|
|
||||||
--tblr-body-bg: var(--sp-paper);
|
|
||||||
--tblr-body-color: var(--sp-ink);
|
|
||||||
--tblr-secondary: var(--sp-muted);
|
|
||||||
--tblr-border-color: var(--sp-rule);
|
|
||||||
--tblr-border-radius: 3px;
|
|
||||||
--tblr-border-radius-lg: 4px;
|
|
||||||
|
|
||||||
--tblr-primary: #7a3b2e;
|
|
||||||
--tblr-primary-rgb: 122, 59, 46;
|
|
||||||
--tblr-link-color: #7a3b2e;
|
|
||||||
--tblr-link-color-rgb: 122, 59, 46;
|
|
||||||
--tblr-link-hover-color: #632f25;
|
|
||||||
--tblr-link-hover-color-rgb: 99, 47, 37;
|
|
||||||
|
|
||||||
--tblr-green: #2c6b43; --tblr-green-rgb: 44, 107, 67;
|
|
||||||
--tblr-yellow: #b5700f; --tblr-yellow-rgb: 181, 112, 15;
|
|
||||||
--tblr-red: #b42318; --tblr-red-rgb: 180, 35, 24;
|
|
||||||
--tblr-success: #2c6b43; --tblr-success-rgb: 44, 107, 67;
|
|
||||||
--tblr-warning: #b5700f; --tblr-warning-rgb: 181, 112, 15;
|
|
||||||
--tblr-danger: #b42318; --tblr-danger-rgb: 180, 35, 24;
|
|
||||||
}
|
|
||||||
body { font-feature-settings: normal; }
|
|
||||||
.btn-primary { --tblr-btn-hover-bg: #632f25; --tblr-btn-active-bg: #632f25; }
|
|
||||||
code, .font-monospace { font-family: var(--sp-mono); }
|
|
||||||
|
|
||||||
/* ---- sidebar: ink, with the stamp on its own paper */
|
|
||||||
.navbar-vertical {
|
|
||||||
--tblr-navbar-bg: var(--sp-ink); background: var(--sp-ink);
|
|
||||||
--tblr-navbar-color: #aab2bd; --tblr-navbar-hover-color: #fff; --tblr-navbar-active-color: #fff;
|
|
||||||
--tblr-navbar-border-color: transparent;
|
|
||||||
}
|
|
||||||
.navbar-vertical .navbar-brand { padding: 1.1rem 0 .9rem; justify-content: flex-start; }
|
|
||||||
.navbar-vertical .navbar-brand img { height: 52px; width: auto; }
|
|
||||||
.navbar-vertical .nav-link { padding-block: .42rem; }
|
|
||||||
.navbar-vertical .nav-link-icon { font-size: 1.15rem; }
|
|
||||||
.navbar-vertical .nav-item.active > .nav-link {
|
|
||||||
background: rgb(255 255 255 / .07); box-shadow: inset 3px 0 0 #c4705e; color: #fff;
|
|
||||||
}
|
|
||||||
.sp-nav-label {
|
|
||||||
font-family: var(--sp-mono); font-size: .66rem; font-weight: 600; letter-spacing: .14em;
|
|
||||||
text-transform: uppercase; color: #6f7a88; padding: 1.1rem 1.5rem .3rem;
|
|
||||||
}
|
|
||||||
.sp-nav-foot { margin-top: auto; padding-bottom: .8rem; border-top: 1px solid rgb(255 255 255 / .08); padding-top: .6rem; }
|
|
||||||
.sp-nav-user { color: #6f7a88; font-size: .8rem; padding: .3rem 1.5rem; }
|
|
||||||
|
|
||||||
/* ---- page */
|
|
||||||
.page-pretitle { font-family: var(--sp-mono); letter-spacing: .1em; }
|
|
||||||
.card-title { font-weight: 600; }
|
|
||||||
.table thead th { font-family: var(--sp-mono); letter-spacing: .08em; }
|
|
||||||
.sp-tile .card-body { display: flex; align-items: center; gap: .8rem; padding: .85rem 1rem; }
|
|
||||||
.sp-tile .sp-tile-icon { font-size: 1.5rem; color: var(--sp-muted); }
|
|
||||||
.sp-tile .fw-semibold { line-height: 1.2; }
|
|
||||||
.sp-tile .status { margin-left: auto; }
|
|
||||||
progress.sp-bar {
|
|
||||||
appearance: none; -webkit-appearance: none; width: 8rem; height: .4rem; border: 0;
|
|
||||||
border-radius: 1rem; overflow: hidden; vertical-align: middle; margin-right: .6rem; background: var(--sp-well);
|
|
||||||
}
|
|
||||||
progress.sp-bar::-webkit-progress-bar { background: var(--sp-well); }
|
|
||||||
progress.sp-bar::-webkit-progress-value { background: var(--tblr-green); }
|
|
||||||
progress.sp-bar::-moz-progress-bar { background: var(--tblr-green); }
|
|
||||||
|
|
||||||
/* ---- the DNS record block: Tabler's read-only input group, plus a status */
|
|
||||||
.sp-record + .sp-record { border-top: 1px solid var(--sp-rule); }
|
|
||||||
.sp-record .form-control[readonly], .sp-record textarea[readonly] {
|
|
||||||
font-family: var(--sp-mono); font-size: .8rem; background: var(--sp-well);
|
|
||||||
}
|
|
||||||
.sp-record textarea { resize: none; }
|
|
||||||
.sp-record .form-label { font-size: .75rem; color: var(--sp-muted); margin-bottom: .25rem; }
|
|
||||||
|
|
||||||
.sp-foot { color: var(--sp-muted); font-size: .8rem; }
|
|
||||||
.sp-signin-mark { width: 15rem; height: auto; }
|
|
||||||
.sp-tagline { font-family: var(--sp-mono); font-size: .72rem; letter-spacing: .16em; color: var(--sp-muted); }
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="light">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>example.org · Outbound domains · SelfPost</title>
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
|
|
||||||
<link rel="stylesheet" href="../shared/brand.css">
|
|
||||||
<link rel="stylesheet" href="theme.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<nav class="navbar is-primary" aria-label="main navigation">
|
|
||||||
<div class="container">
|
|
||||||
<div class="navbar-brand">
|
|
||||||
<a class="navbar-item" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a>
|
|
||||||
</div>
|
|
||||||
<div class="navbar-menu is-active">
|
|
||||||
<div class="navbar-start">
|
|
||||||
<a class="navbar-item" href="status.html">Overview</a>
|
|
||||||
<div class="navbar-item has-dropdown is-hoverable sp-current">
|
|
||||||
<a class="navbar-link" href="domain.html">Outbound</a>
|
|
||||||
<div class="navbar-dropdown">
|
|
||||||
<a class="navbar-item is-active" href="domain.html">Domains</a>
|
|
||||||
<a class="navbar-item" href="#">Log</a>
|
|
||||||
<a class="navbar-item" href="#">Queue</a>
|
|
||||||
<a class="navbar-item" href="#">DMARC reports</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="navbar-item has-dropdown is-hoverable">
|
|
||||||
<a class="navbar-link" href="#">Inbound</a>
|
|
||||||
<div class="navbar-dropdown">
|
|
||||||
<a class="navbar-item" href="#">Domains</a>
|
|
||||||
<a class="navbar-item" href="#">Log</a>
|
|
||||||
<a class="navbar-item" href="#">Queue</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="navbar-item has-dropdown is-hoverable">
|
|
||||||
<a class="navbar-link" href="#">Server</a>
|
|
||||||
<div class="navbar-dropdown">
|
|
||||||
<a class="navbar-item" href="#">System log</a>
|
|
||||||
<a class="navbar-item" href="#">Backup</a>
|
|
||||||
<a class="navbar-item" href="#">Users</a>
|
|
||||||
<a class="navbar-item" href="#">Settings</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="navbar-end">
|
|
||||||
<div class="navbar-item has-dropdown is-hoverable">
|
|
||||||
<a class="navbar-link" href="#">admin</a>
|
|
||||||
<div class="navbar-dropdown is-right">
|
|
||||||
<a class="navbar-item" href="#">Account</a>
|
|
||||||
<a class="navbar-item" href="#">Help</a>
|
|
||||||
<hr class="navbar-divider">
|
|
||||||
<a class="navbar-item" href="login.html">Sign out</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<div class="container sp-page">
|
|
||||||
<div class="level mb-5">
|
|
||||||
<div class="level-left">
|
|
||||||
<div>
|
|
||||||
<p class="sp-kicker"><a href="status.html">Outbound domains</a> /</p>
|
|
||||||
<h1 class="title is-3 mb-0">example.org</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="level-right">
|
|
||||||
<div class="buttons">
|
|
||||||
<form method="post" action="#"><button type="submit" class="button">Re-check DNS</button></form>
|
|
||||||
<a class="button is-primary" href="#">Add application</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="columns">
|
|
||||||
<div class="column is-3">
|
|
||||||
<aside class="menu sp-side">
|
|
||||||
<p class="menu-label">This domain</p>
|
|
||||||
<ul class="menu-list">
|
|
||||||
<li><a class="is-active" href="#dns">DNS records <span class="tag is-danger is-light">1</span></a></li>
|
|
||||||
<li><a href="#apps">Applications <span class="tag is-light">2</span></a></li>
|
|
||||||
<li><a href="#connection">Connection</a></li>
|
|
||||||
</ul>
|
|
||||||
<p class="menu-label">Rarely changed</p>
|
|
||||||
<ul class="menu-list">
|
|
||||||
<li><a href="#">DMARC reports</a></li>
|
|
||||||
<li><a href="#">Rate limit <span class="tag is-success is-light">active</span></a></li>
|
|
||||||
<li><a href="#">Export</a></li>
|
|
||||||
<li><a class="has-text-danger" href="#">Delete domain</a></li>
|
|
||||||
</ul>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-9">
|
|
||||||
<div class="box" id="dns">
|
|
||||||
<div class="sp-box-head"><h2>DNS records</h2><div class="sp-end">Checked 3 min ago</div></div>
|
|
||||||
|
|
||||||
<div class="sp-record">
|
|
||||||
<div class="sp-record-head"><h3>DKIM</h3><span class="tag is-success is-light">published</span><small>Not a secret · selector <code>sp2026</code></small></div>
|
|
||||||
<div class="sp-row">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">Host / name</label>
|
|
||||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="sp2026._domainkey.example.org"></div><div class="control"><button type="button" class="button">Copy</button></div></div>
|
|
||||||
</div>
|
|
||||||
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">Value to publish</label>
|
|
||||||
<div class="field has-addons"><div class="control is-expanded"><textarea class="textarea" rows="3" readonly>v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3kqV0mJd8e2f1Yb7Qw5uT9sLr4nHc6pZaXo1vKe0yWm2gBt8RjN5dCq3hUf7lPs9aEi4oMz6xTb1kGv0nYw…IDAQAB</textarea></div><div class="control"><button type="button" class="button">Copy</button></div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sp-record">
|
|
||||||
<div class="sp-record-head"><h3>SPF</h3><span class="tag is-danger is-light">mismatch</span><small>Merge into the existing record — never publish a second one</small></div>
|
|
||||||
<div class="sp-row">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">Host / name</label>
|
|
||||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="example.org"></div><div class="control"><button type="button" class="button">Copy</button></div></div>
|
|
||||||
</div>
|
|
||||||
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">Value to publish</label>
|
|
||||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=spf1 ip4:203.0.113.25 ~all"></div><div class="control"><button type="button" class="button">Copy</button></div></div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">In DNS now</label>
|
|
||||||
<div class="control"><input class="input" readonly value="v=spf1 include:_spf.google.com ~all"></div>
|
|
||||||
<p class="help is-danger">The published record does not authorize 203.0.113.25. Add <code>ip4:203.0.113.25</code> before <code>~all</code>.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sp-record">
|
|
||||||
<div class="sp-record-head"><h3>DMARC</h3><span class="tag is-success is-light">published</span><small>Report address from <a href="#">Settings</a></small></div>
|
|
||||||
<div class="sp-row">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">Host / name</label>
|
|
||||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="_dmarc.example.org"></div><div class="control"><button type="button" class="button">Copy</button></div></div>
|
|
||||||
</div>
|
|
||||||
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">Value to publish</label>
|
|
||||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org"></div><div class="control"><button type="button" class="button">Copy</button></div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="box" id="apps">
|
|
||||||
<div class="sp-box-head"><h2>Applications</h2><div class="sp-end">SASL logins that may send as this domain</div></div>
|
|
||||||
<table class="table is-fullwidth is-hoverable">
|
|
||||||
<thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th></th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td class="sp-mono"><strong>prod-server</strong></td><td>*@example.org</td><td class="sp-muted">1 102 msg · peak 96/h</td>
|
|
||||||
<td><span class="tag is-success is-light">200 / h</span></td>
|
|
||||||
<td><div class="buttons are-small is-right is-flex-wrap-nowrap"><a class="button" href="#">Edit</a><a class="button" href="#">New password</a><a class="button is-danger is-light" href="#">Delete</a></div></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="sp-mono"><strong>alerts</strong></td><td>alerts@example.org, noc@example.org</td><td class="sp-muted">182 msg · peak 14/h</td>
|
|
||||||
<td><span class="tag is-light">domain</span> <span class="tag is-success is-light">2 IPs</span></td>
|
|
||||||
<td><div class="buttons are-small is-right is-flex-wrap-nowrap"><a class="button" href="#">Edit</a><a class="button" href="#">New password</a><a class="button is-danger is-light" href="#">Delete</a></div></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="columns">
|
|
||||||
<div class="column">
|
|
||||||
<div class="box" id="connection">
|
|
||||||
<div class="sp-box-head"><h2>Connection</h2><div class="sp-end">Same for every domain</div></div>
|
|
||||||
<p class="sp-mono">mail.example.org</p>
|
|
||||||
<p class="sp-mono sp-muted">465 SSL/TLS · 587 STARTTLS</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column">
|
|
||||||
<div class="box">
|
|
||||||
<div class="sp-box-head"><h2>Sent, 30 days</h2></div>
|
|
||||||
<div class="sp-stats">
|
|
||||||
<div><b>1 284</b><span class="sp-muted">messages</span></div>
|
|
||||||
<div><b>96</b><span class="sp-muted">peak / h</span></div>
|
|
||||||
<div><b>1.8</b><span class="sp-muted">average / h</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="light">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Sign in · SelfPost</title>
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
|
|
||||||
<link rel="stylesheet" href="../shared/brand.css">
|
|
||||||
<link rel="stylesheet" href="theme.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="columns is-gapless sp-signin">
|
|
||||||
<div class="column is-half sp-signin-brand">
|
|
||||||
<div>
|
|
||||||
<img src="../../selfpost-stamp.svg" alt="SelfPost">
|
|
||||||
<p>YOUR SMTP. YOUR RULES.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-half sp-signin-form">
|
|
||||||
<form method="post" action="status.html">
|
|
||||||
<h1 class="title is-3">Sign in</h1>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label" for="username">Username</label>
|
|
||||||
<div class="control"><input class="input" id="username" name="username" autocomplete="username" required></div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label" for="password">Password</label>
|
|
||||||
<div class="control"><input class="input" id="password" name="password" type="password" autocomplete="current-password" required></div>
|
|
||||||
</div>
|
|
||||||
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Sign in</button></div>
|
|
||||||
</form>
|
|
||||||
<p class="sp-foot">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="light">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Overview · SelfPost</title>
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
|
|
||||||
<link rel="stylesheet" href="../shared/brand.css">
|
|
||||||
<link rel="stylesheet" href="theme.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<nav class="navbar is-primary" aria-label="main navigation">
|
|
||||||
<div class="container">
|
|
||||||
<div class="navbar-brand">
|
|
||||||
<a class="navbar-item" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a>
|
|
||||||
</div>
|
|
||||||
<div class="navbar-menu is-active">
|
|
||||||
<div class="navbar-start">
|
|
||||||
<a class="navbar-item is-active" href="status.html">Overview</a>
|
|
||||||
<div class="navbar-item has-dropdown is-hoverable">
|
|
||||||
<a class="navbar-link" href="domain.html">Outbound</a>
|
|
||||||
<div class="navbar-dropdown">
|
|
||||||
<a class="navbar-item" href="domain.html">Domains</a>
|
|
||||||
<a class="navbar-item" href="#">Log</a>
|
|
||||||
<a class="navbar-item" href="#">Queue</a>
|
|
||||||
<a class="navbar-item" href="#">DMARC reports</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="navbar-item has-dropdown is-hoverable">
|
|
||||||
<a class="navbar-link" href="#">Inbound</a>
|
|
||||||
<div class="navbar-dropdown">
|
|
||||||
<a class="navbar-item" href="#">Domains</a>
|
|
||||||
<a class="navbar-item" href="#">Log</a>
|
|
||||||
<a class="navbar-item" href="#">Queue</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="navbar-item has-dropdown is-hoverable">
|
|
||||||
<a class="navbar-link" href="#">Server</a>
|
|
||||||
<div class="navbar-dropdown">
|
|
||||||
<a class="navbar-item" href="#">System log</a>
|
|
||||||
<a class="navbar-item" href="#">Backup</a>
|
|
||||||
<a class="navbar-item" href="#">Users</a>
|
|
||||||
<a class="navbar-item" href="#">Settings</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="navbar-end">
|
|
||||||
<div class="navbar-item has-dropdown is-hoverable">
|
|
||||||
<a class="navbar-link" href="#">admin</a>
|
|
||||||
<div class="navbar-dropdown is-right">
|
|
||||||
<a class="navbar-item" href="#">Account</a>
|
|
||||||
<a class="navbar-item" href="#">Help</a>
|
|
||||||
<hr class="navbar-divider">
|
|
||||||
<a class="navbar-item" href="login.html">Sign out</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<div class="container sp-page">
|
|
||||||
<div class="level mb-5">
|
|
||||||
<div class="level-left">
|
|
||||||
<div>
|
|
||||||
<p class="sp-kicker">mail.example.org</p>
|
|
||||||
<h1 class="title is-3 mb-0">Overview</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="level-right sp-muted is-size-7">Updated 14:02:31 UTC · refreshes every 10 s</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="box sp-health">
|
|
||||||
<a href="#"><div class="sp-h-name">Machine</div><div class="sp-h-value">12 % CPU</div><div class="sp-h-sub">41 % memory</div></a>
|
|
||||||
<a href="#"><div class="sp-h-name">Processes</div><div class="sp-h-value">5 of 5</div><div class="sp-h-sub">running</div></a>
|
|
||||||
<a href="#"><div class="sp-h-name">Queues</div><div class="sp-h-value">Empty</div><div class="sp-h-sub">outbound and inbound</div></a>
|
|
||||||
<a href="#" class="warn"><div class="sp-h-name">TLS certificate</div><div class="sp-h-value">12 days</div><div class="sp-h-sub">renewal has not run</div></a>
|
|
||||||
<a href="#"><div class="sp-h-name">Milter sockets</div><div class="sp-h-value">2 of 2</div><div class="sp-h-sub">answering</div></a>
|
|
||||||
<a href="#"><div class="sp-h-name">Reverse DNS</div><div class="sp-h-value">Match</div><div class="sp-h-sub">203.0.113.25</div></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="columns">
|
|
||||||
<div class="column is-7">
|
|
||||||
<div class="box">
|
|
||||||
<div class="sp-box-head"><h2>Outbound domains</h2><div class="sp-end"><a class="button is-small is-primary" href="#">Add domain</a></div></div>
|
|
||||||
<table class="table is-fullwidth is-hoverable">
|
|
||||||
<thead><tr><th>Domain</th><th>DNS</th><th>Apps</th><th>30 days</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td><a href="domain.html"><strong>example.org</strong></a></td><td><span class="tag is-success is-light">DKIM</span> <span class="tag is-danger is-light">SPF</span> <span class="tag is-success is-light">DMARC</span></td><td>2</td><td class="sp-muted">1 284 msg</td></tr>
|
|
||||||
<tr><td><a href="#"><strong>shop.example.org</strong></a></td><td><span class="tag is-success is-light">DKIM</span> <span class="tag is-success is-light">SPF</span> <span class="tag is-success is-light">DMARC</span></td><td>1</td><td class="sp-muted">18 920 msg</td></tr>
|
|
||||||
<tr><td><a href="#"><strong>notify.acme.io</strong></a></td><td><span class="tag is-success is-light">DKIM</span> <span class="tag is-success is-light">SPF</span> <span class="tag is-warning is-light">DMARC</span></td><td>3</td><td class="sp-muted">402 msg</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-5">
|
|
||||||
<div class="box">
|
|
||||||
<div class="sp-box-head"><h2>Inbound domains</h2><div class="sp-end"><a class="button is-small" href="#">Add domain</a></div></div>
|
|
||||||
<table class="table is-fullwidth is-hoverable">
|
|
||||||
<thead><tr><th>Domain</th><th>MX</th><th>Upstream</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td><a href="#"><strong>lists.example.org</strong></a></td><td><span class="tag is-success is-light">MX</span></td><td class="sp-muted sp-mono is-size-7">mx.internal:25</td></tr>
|
|
||||||
<tr><td><a href="#"><strong>acme.io</strong></a></td><td><span class="tag is-success is-light">MX</span></td><td class="sp-muted sp-mono is-size-7">10.0.4.12:2525</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
/* Direction C — "Counter". Bulma v1 (pure CSS, no JavaScript, no data: URIs)
|
|
||||||
carries the navbar with hover dropdowns, columns, boxes, tables, tags,
|
|
||||||
the side menu and input-with-addon fields. This file points Bulma's HSL
|
|
||||||
variables at the brand and adds the health strip and the record block. */
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--bulma-family-primary: var(--sp-sans);
|
|
||||||
--bulma-family-secondary: var(--sp-sans);
|
|
||||||
--bulma-family-code: var(--sp-mono);
|
|
||||||
--bulma-primary-h: 10deg; --bulma-primary-s: 45%; --bulma-primary-l: 33%;
|
|
||||||
--bulma-link-h: 10deg; --bulma-link-s: 45%; --bulma-link-l: 33%;
|
|
||||||
--bulma-success-h: 138deg; --bulma-success-s: 42%; --bulma-success-l: 30%;
|
|
||||||
--bulma-warning-h: 36deg; --bulma-warning-s: 85%; --bulma-warning-l: 55%;
|
|
||||||
--bulma-danger-h: 4deg; --bulma-danger-s: 76%; --bulma-danger-l: 40%;
|
|
||||||
--bulma-radius: 6px; --bulma-radius-large: 10px;
|
|
||||||
--bulma-body-size: 15px;
|
|
||||||
/* Bulma derives link text and the on-primary colour from lightness steps;
|
|
||||||
at the brick's 33 % both land off-brand, so they are pinned. */
|
|
||||||
--bulma-link-text: var(--sp-brick);
|
|
||||||
--bulma-link-text-hover: var(--sp-brick-dark);
|
|
||||||
}
|
|
||||||
.button.is-primary, .button.is-primary:hover { color: #fff; }
|
|
||||||
a strong { color: inherit; }
|
|
||||||
html { background: var(--sp-paper); }
|
|
||||||
body { background: var(--sp-paper); color: var(--sp-ink); min-height: 100vh; display: flex; flex-direction: column; }
|
|
||||||
main { flex: 1; }
|
|
||||||
code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; padding: .1em .35em; font-size: .85em; }
|
|
||||||
|
|
||||||
/* ---- navbar: brick, the stamp on it, hover dropdowns */
|
|
||||||
.navbar.is-primary { --bulma-navbar-height: 4rem; }
|
|
||||||
.navbar-brand img { max-height: 3rem; height: 3rem; }
|
|
||||||
.navbar.is-primary .navbar-item, .navbar.is-primary .navbar-link { color: var(--sp-on-brick); font-weight: 500; }
|
|
||||||
.navbar.is-primary .navbar-link::after { border-color: var(--sp-on-brick); }
|
|
||||||
.navbar.is-primary .navbar-start > .navbar-item.is-active,
|
|
||||||
.navbar.is-primary .navbar-start > .has-dropdown.sp-current > .navbar-link { box-shadow: inset 0 -3px 0 var(--sp-on-brick); }
|
|
||||||
.navbar-dropdown { border-top: 0; box-shadow: 0 8px 24px rgb(18 22 28 / .14); }
|
|
||||||
.navbar.is-primary .navbar-dropdown .navbar-item { color: var(--sp-ink); font-weight: 400; }
|
|
||||||
.navbar.is-primary .navbar-dropdown .navbar-item.is-active { color: var(--sp-brick); font-weight: 600; background: var(--sp-brick-tint); }
|
|
||||||
|
|
||||||
/* ---- page */
|
|
||||||
.sp-page { padding-block: 2rem 2.5rem; }
|
|
||||||
.sp-kicker { font-family: var(--sp-mono); font-size: .75rem; letter-spacing: .1em; text-transform: uppercase; color: var(--sp-muted); }
|
|
||||||
.sp-kicker a { color: inherit; }
|
|
||||||
.title { color: var(--sp-ink); font-weight: 600; }
|
|
||||||
.box { box-shadow: none; border: 1px solid var(--sp-rule); }
|
|
||||||
.box > .sp-box-head { display: flex; align-items: center; gap: .75rem; margin-bottom: 1rem; }
|
|
||||||
.box > .sp-box-head h2 { font-size: 1.1rem; font-weight: 600; margin: 0; }
|
|
||||||
.box > .sp-box-head .sp-end { margin-left: auto; color: var(--sp-muted); font-size: .85rem; display: flex; gap: .6rem; align-items: center; }
|
|
||||||
.table { background: transparent; }
|
|
||||||
.table thead th { font-family: var(--sp-mono); font-size: .7rem; font-weight: 400; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); }
|
|
||||||
.table td { vertical-align: middle; }
|
|
||||||
.tag { font-family: var(--sp-mono); font-weight: 600; letter-spacing: .04em; }
|
|
||||||
.sp-muted { color: var(--sp-muted); }
|
|
||||||
.sp-mono { font-family: var(--sp-mono); }
|
|
||||||
|
|
||||||
/* ---- health strip */
|
|
||||||
.sp-health { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0; padding: 0; overflow: hidden; }
|
|
||||||
.sp-health > a {
|
|
||||||
display: block; padding: 1rem 1.2rem; color: var(--sp-ink); border-right: 1px solid var(--sp-rule);
|
|
||||||
box-shadow: inset 0 3px 0 var(--sp-ok);
|
|
||||||
}
|
|
||||||
.sp-health > a:last-child { border-right: 0; }
|
|
||||||
.sp-health > a:hover { background: var(--sp-paper); }
|
|
||||||
.sp-health > a.warn { box-shadow: inset 0 3px 0 #d9962b; background: var(--sp-warn-bg); }
|
|
||||||
.sp-health > a.fail { box-shadow: inset 0 3px 0 var(--sp-fail); background: var(--sp-fail-bg); }
|
|
||||||
.sp-health .sp-h-name { font-family: var(--sp-mono); font-size: .68rem; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); }
|
|
||||||
.sp-health .sp-h-value { font-size: 1.15rem; font-weight: 600; line-height: 1.3; }
|
|
||||||
.sp-health .sp-h-sub { font-size: .8rem; color: var(--sp-muted); }
|
|
||||||
|
|
||||||
/* ---- domain page */
|
|
||||||
.sp-side { position: sticky; top: 1.5rem; }
|
|
||||||
.menu-label { font-family: var(--sp-mono); letter-spacing: .1em; }
|
|
||||||
.menu-list a { display: flex; align-items: center; gap: .5rem; }
|
|
||||||
.menu-list a .tag { margin-left: auto; }
|
|
||||||
.sp-record + .sp-record { border-top: 1px solid var(--sp-rule); margin-top: 1.25rem; padding-top: 1.25rem; }
|
|
||||||
.sp-record-head { display: flex; align-items: center; gap: .6rem; margin-bottom: .75rem; }
|
|
||||||
.sp-record-head h3 { font-size: 1rem; font-weight: 600; margin: 0; }
|
|
||||||
.sp-record-head small { margin-left: auto; color: var(--sp-muted); }
|
|
||||||
.sp-record .label { font-size: .75rem; font-weight: 400; color: var(--sp-muted); margin-bottom: .25rem; }
|
|
||||||
.sp-record .input[readonly], .sp-record .textarea[readonly] {
|
|
||||||
font-family: var(--sp-mono); font-size: .82rem; background: var(--sp-well); box-shadow: none;
|
|
||||||
}
|
|
||||||
.sp-record .textarea { resize: none; min-height: 0; }
|
|
||||||
.sp-record .field:not(:last-child) { margin-bottom: .6rem; }
|
|
||||||
.sp-row { display: grid; grid-template-columns: 1fr 7rem; gap: .75rem; }
|
|
||||||
.sp-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
|
|
||||||
.sp-stats b { display: block; font-size: 1.5rem; font-weight: 600; line-height: 1.2; }
|
|
||||||
|
|
||||||
/* ---- sign-in */
|
|
||||||
.sp-signin { min-height: 100vh; margin: 0; }
|
|
||||||
.sp-signin-brand { background: var(--sp-brick); display: grid; place-content: center; text-align: center; padding: 3rem; }
|
|
||||||
.sp-signin-brand img { width: 20rem; max-width: 100%; height: auto; }
|
|
||||||
.sp-signin-brand p { font-family: var(--sp-mono); letter-spacing: .18em; font-size: .8rem; color: var(--sp-on-brick); margin-top: 1.6rem; }
|
|
||||||
.sp-signin-form { display: flex; flex-direction: column; justify-content: center; padding: 3rem; }
|
|
||||||
.sp-signin-form form { width: 21rem; max-width: 100%; margin-inline: auto; }
|
|
||||||
.sp-foot { color: var(--sp-muted); font-size: .8rem; padding-block: 1.2rem; }
|
|
||||||
.sp-foot a { color: inherit; text-decoration: underline; }
|
|
||||||
.sp-signin-form .sp-foot { text-align: center; margin-top: 2.5rem; }
|
|
||||||
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 418 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 600 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 68 KiB |
@@ -1,22 +0,0 @@
|
|||||||
/* 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; }
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="light">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>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>
|
|
||||||
<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>
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
/* 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] };
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="light">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>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, the queue), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code> in one archive. Restore into an empty project directory and start the <strong>same SelfPost version</strong>. The reverse-proxy vhost is not included.</p>
|
|
||||||
<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>
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
"""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")
|
|
||||||
|
|
||||||
|
|
||||||
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())
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="light">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>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>form</dt><dd>page_head, then boxes in equal <code>columns</code>, one submit row under them. <a href="out-app.html">Example</a></dd></div>
|
|
||||||
<div><dt>signed out</dt><dd>The split brand / form screen, no navbar. <a href="login.html">Example</a></dd></div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="columns">
|
|
||||||
<div class="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>
|
|
||||||
</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>
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="light">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>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>
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="light">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>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>
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="light">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>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 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>
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="light">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>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">
|
|
||||||
<div class="column is-6">
|
|
||||||
<div class="box" id="machine">
|
|
||||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Machine</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#checks" title="What these readings mean"><i class="ti ti-help-circle"></i></a></div>
|
|
||||||
<div class="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="processes">
|
|
||||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Processes</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
|
|
||||||
<div class="table-container"><table class="table is-fullwidth">
|
|
||||||
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>postfix</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 41, uptime 18 days</td></tr>
|
|
||||||
<tr><td>opendkim</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 39, uptime 18 days</td></tr>
|
|
||||||
<tr><td>journal-milter</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 40, uptime 18 days</td></tr>
|
|
||||||
<tr><td>panel</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 44, uptime 18 days</td></tr>
|
|
||||||
<tr><td>dmarc-ingest</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 47, uptime 18 days</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="columns">
|
|
||||||
<div class="column is-4">
|
|
||||||
<div class="box" id="certificate">
|
|
||||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>TLS certificate</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div><a class="sp-help" href="help.html#certificate" title="When it warns and what to do"><i class="ti ti-help-circle"></i></a></div>
|
|
||||||
<div class="sp-box-body">
|
|
||||||
<dl class="sp-facts">
|
|
||||||
<div><dt>Expires</dt><dd class="sp-mono">2026-10-03 09:14 UTC</dd></div>
|
|
||||||
<div><dt>Names</dt><dd class="sp-mono">mail.example.org</dd></div>
|
|
||||||
</dl>
|
|
||||||
<p class="mt-4 has-text-danger">Expires in 12 days — renewal has not run.</p>
|
|
||||||
<p class="sp-muted">Renewal normally runs 30 days before expiry. Check that port 80 is reachable and look for <code>acme</code> lines in the <a href="system-log.html">system log</a>.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-4">
|
|
||||||
<div class="box" id="sockets">
|
|
||||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Milter sockets</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
|
|
||||||
<div class="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>rspamd <span class="sp-muted">· inbound spam filter</span><br><span class="sp-muted sp-mono sp-small">inet:antispam:11332</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-4">
|
|
||||||
<div class="box" id="hostname">
|
|
||||||
<div class="sp-box-head"><span class="sp-no">05</span><h2>Hostname and reverse DNS</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#rdns" title="Forward and reverse DNS"><i class="ti ti-help-circle"></i></a></div>
|
|
||||||
<div class="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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="box" id="configuration">
|
|
||||||
<div class="sp-box-head"><span class="sp-no">06</span><h2>Configuration</h2><div class="sp-end">Safe to run at any time</div></div>
|
|
||||||
<div class="sp-box-body">
|
|
||||||
<p class="mb-3">Regenerates the OpenDKIM and Postfix configuration from the database and reloads both daemons. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
|
|
||||||
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-reload"></i>Reload configuration</button></form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="light">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>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="#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>
|
|
||||||
</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>
|
|
||||||
</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="limits"><h3>Rate limits</h3><p>Level 1 is a per-IP ceiling set in <code>.env</code>. Level 2 is optional, per domain and per application, entered by hand or computed from the last 30 days of sending, and can never exceed level 1. An application's own limit replaces its domain's.</p></div>
|
|
||||||
<div class="sp-help-topic" id="log"><h3>Outbound log</h3><p>One row per message and recipient, with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out) and rejected (refused before queueing under a level-2 rate limit, so it never reached Postfix). A message's page lists these steps and the lines Postfix wrote about it in <code>mail.log</code>, found by queue id; that file is rotated daily and about two weeks are kept, so an old message may show none. Rows are deleted after the retention set in Server › Settings, 90 days by default. A domain administrator sees only the rows of their own domains.</p></div>
|
|
||||||
<div class="sp-help-topic" id="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>
|
|
||||||
<div class="box">
|
|
||||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div>
|
|
||||||
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys, application credentials and the queue), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
|
|
||||||
<div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out of their other sessions.</p></div>
|
|
||||||
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it.</p></div>
|
|
||||||
</div>
|
|
||||||
<div class="box">
|
|
||||||
<div class="sp-box-head"><span class="sp-no">05</span><h2>Account</h2></div>
|
|
||||||
<div class="sp-help-topic" id="account"><h3>Your account</h3><p>Account is your own, whatever your role: your username and e-mail, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected.</p></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="light">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>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>
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="light">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>lists.example.org · Inbound domains · 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-postmark"><span>DNS</span><b>OK</b><span>MX POINTS HERE</span></div>
|
|
||||||
<div class="sp-grow">
|
|
||||||
<p class="sp-kicker"><a href="in-domains.html">Inbound domains</a> /</p>
|
|
||||||
<h1 class="title is-3">lists.example.org</h1>
|
|
||||||
<p class="sp-lead">Accepted on port 25, handed to <span class="sp-mono">mx.internal:25</span> over required TLS · 4 recipients</p>
|
|
||||||
</div>
|
|
||||||
<div class="buttons">
|
|
||||||
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form>
|
|
||||||
</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"><i class="ti ti-world-check"></i>MX record <span class="tag is-success is-light">ok</span></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>Recipients <span class="tag is-light">4</span></a></li>
|
|
||||||
<li><a href="#filter"><i class="ti ti-shield-check"></i>Spam filter <span class="tag is-success is-light">on</span></a></li>
|
|
||||||
</ul>
|
|
||||||
<p class="menu-label">See also</p>
|
|
||||||
<ul class="menu-list">
|
|
||||||
<li><a href="in-log.html"><i class="ti ti-list-details"></i>Log for this domain</a></li>
|
|
||||||
</ul>
|
|
||||||
<p class="menu-label">Rarely changed</p>
|
|
||||||
<ul class="menu-list">
|
|
||||||
<li><a class="has-text-danger" href="in-domain-delete.html"><i class="ti ti-trash has-text-danger"></i>Delete domain</a></li>
|
|
||||||
</ul>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-9">
|
|
||||||
<div class="box" id="dns">
|
|
||||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>MX record</h2><div class="sp-end">Checked 1 min ago</div><a class="sp-help" href="help.html#inbound" title="Backup-MX and forwarding"><i class="ti ti-help-circle"></i></a></div>
|
|
||||||
<div class="sp-record">
|
|
||||||
<div class="sp-record-head"><h3>MX</h3><span class="tag is-success is-light">published</span><small>Keep any existing primary MX if this is a backup-MX</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="lists.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
|
||||||
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="MX"></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="10 mail.example.org."></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="columns">
|
|
||||||
<div class="column is-6">
|
|
||||||
<div class="box" id="upstream">
|
|
||||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Upstream</h2><div class="sp-end">Not a mailbox</div><a class="sp-help" href="help.html#upstream" title="Host, port and TLS"><i class="ti ti-help-circle"></i></a></div>
|
|
||||||
<div class="sp-box-body">
|
|
||||||
<form class="sp-form" method="post" action="#">
|
|
||||||
<div class="sp-row">
|
|
||||||
<div class="field"><label class="label" for="host">Host</label><div class="control"><input class="input sp-mono" id="host" name="host" value="mx.internal" autocomplete="off" spellcheck="false" required></div></div>
|
|
||||||
<div class="field"><label class="label" for="port">Port</label><div class="control"><input class="input sp-mono" id="port" name="port" inputmode="numeric" value="25" required></div></div>
|
|
||||||
</div>
|
|
||||||
<div class="field"><label class="label" for="tls">TLS to upstream</label>
|
|
||||||
<div class="control"><div class="select is-fullwidth"><select id="tls" name="tls_mode"><option>Opportunistic</option><option selected>Required</option><option>Off</option></select></div></div></div>
|
|
||||||
<button type="submit" class="button is-primary">Save upstream</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-6">
|
|
||||||
<div class="box" id="recipients">
|
|
||||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Valid recipients</h2><a class="sp-help" href="help.html#recipients" title="Listed addresses or any address"><i class="ti ti-help-circle"></i></a></div>
|
|
||||||
<div class="sp-box-body">
|
|
||||||
<form class="sp-form" method="post" action="#">
|
|
||||||
<div class="field"><label class="label" for="rmode">Who to accept</label>
|
|
||||||
<div class="control"><div class="select is-fullwidth"><select id="rmode" name="recipient_mode"><option selected>Listed addresses only</option><option>Any recipient at this domain</option></select></div></div></div>
|
|
||||||
<div class="field"><label class="label" for="addresses">Addresses</label>
|
|
||||||
<div class="control"><textarea class="textarea sp-mono" id="addresses" name="addresses" rows="4">announce@lists.example.org
|
|
||||||
dev@lists.example.org
|
|
||||||
owner@lists.example.org
|
|
||||||
security@lists.example.org</textarea></div>
|
|
||||||
<p class="help">Unknown recipients are rejected at RCPT, so this relay generates no backscatter. Prefer a list unless the upstream itself rejects unknowns.</p></div>
|
|
||||||
<button type="submit" class="button is-primary">Save recipients</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="box" id="filter">
|
|
||||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Spam filter</h2><div class="sp-end"><span class="tag is-success is-light">on</span></div><a class="sp-help" href="help.html#filter" title="A server setting"><i class="ti ti-help-circle"></i></a></div>
|
|
||||||
<div class="sp-box-body">
|
|
||||||
<dl class="sp-facts">
|
|
||||||
<div><dt>Engine</dt><dd>rspamd, for every inbound domain of this server</dd></div>
|
|
||||||
<div><dt>Suspicious mail</dt><dd>Held — <a href="in-quarantine.html">1 message in quarantine</a> for this domain</dd></div>
|
|
||||||
<div><dt>Spam</dt><dd>Refused at the door; see <em>reject</em> in the <a href="in-log.html">log</a></dd></div>
|
|
||||||
<div><dt>Allow and deny lists</dt><dd class="sp-muted">Kept by the global administrator, one pair for the whole server</dd></div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="light">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Inbound domains · 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">Inbound</p>
|
|
||||||
<h1 class="title is-3">Inbound domains</h1>
|
|
||||||
<p class="sp-lead">Backup-MX / forwarder. Port 25 accepts mail only for the domains listed here and hands it to their upstream. No mailboxes.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="box">
|
|
||||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Add a domain</h2></div>
|
|
||||||
<div class="sp-box-body">
|
|
||||||
<form class="sp-form" method="post" action="in-domain.html">
|
|
||||||
<div class="field has-addons">
|
|
||||||
<div class="control is-expanded has-icons-left"><input class="input" name="name" type="text" placeholder="lists.example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required><span class="icon is-left"><i class="ti ti-world"></i></span></div>
|
|
||||||
<div class="control"><button type="submit" class="button is-primary"><i class="ti ti-plus"></i>Add domain</button></div>
|
|
||||||
</div>
|
|
||||||
<p class="help">Mail is not accepted until an upstream host is saved on the domain's page. Adding and deleting domains is for a global administrator; a domain administrator sees the list without this form and without Delete.</p>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="box">
|
|
||||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Domains</h2><div class="sp-end">Spam filter <span class="tag is-success is-light">on</span> · 2 domains</div><a class="sp-help" href="help.html#inbound" title="Backup-MX and forwarding"><i class="ti ti-help-circle"></i></a></div>
|
|
||||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
|
||||||
<thead><tr><th>Domain</th><th>DNS</th><th>Upstream</th><th>Recipients</th><th>TLS to upstream</th><th></th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td><a href="in-domain.html"><strong>lists.example.org</strong></a></td><td><span class="tag is-success is-light">MX</span></td><td class="sp-mono sp-muted">mx.internal:25</td><td>4 addresses</td><td><span class="tag is-success is-light">required</span></td><td class="sp-actions"><a href="in-domain.html">Open</a><a class="has-text-danger" href="in-domain-delete.html">Delete</a></td></tr>
|
|
||||||
<tr><td><a href="in-domain.html"><strong>acme.io</strong></a></td><td><span class="tag is-success is-light">MX</span></td><td class="sp-mono sp-muted">10.0.4.12:2525</td><td>any address</td><td><span class="tag is-light">opportunistic</span></td><td class="sp-actions"><a href="in-domain.html">Open</a><a class="has-text-danger" href="in-domain-delete.html">Delete</a></td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table></div>
|
|
||||||
<div class="sp-box-foot">The MX tag is green when at least one MX of the domain points at this server.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="light">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Filter lists · 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" 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 is-active" 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"><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" aria-current="page"><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">Inbound · global role</p>
|
|
||||||
<h1 class="title is-3">Filter lists</h1>
|
|
||||||
<p class="sp-lead">Correct the spam filter by hand: senders it must always let through, and senders it must always turn away. One pair of lists for the whole instance.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="box">
|
|
||||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Add an entry</h2><a class="sp-help" href="help.html#inbound" title="How the lists are applied"><i class="ti ti-help-circle"></i></a></div>
|
|
||||||
<div class="sp-box-body">
|
|
||||||
<form class="sp-form" method="post" action="#">
|
|
||||||
<div class="columns">
|
|
||||||
<div class="column is-3">
|
|
||||||
<div class="field"><label class="label" for="list">List</label>
|
|
||||||
<div class="control"><div class="select is-fullwidth"><select id="list" name="list_type"><option>Deny — always turn away</option><option>Allow — always let through</option></select></div></div></div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-3">
|
|
||||||
<div class="field"><label class="label" for="etype">Entry is</label>
|
|
||||||
<div class="control"><div class="select is-fullwidth"><select id="etype" name="entry_type"><option>a sender address</option><option>a sender domain</option><option>a client IP or CIDR</option></select></div></div></div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-6">
|
|
||||||
<div class="field"><label class="label" for="value">Value</label>
|
|
||||||
<div class="control"><input class="input sp-mono" id="value" name="value" placeholder="promo@bulk.example" autocomplete="off" autocapitalize="none" spellcheck="false" required></div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field"><label class="label" for="note">Note</label>
|
|
||||||
<div class="control"><input class="input" id="note" name="note" placeholder="Why — for whoever reads this list next year"></div></div>
|
|
||||||
<button type="submit" class="button is-primary"><i class="ti ti-plus"></i>Add entry</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="columns">
|
|
||||||
<div class="column is-6">
|
|
||||||
<div class="box">
|
|
||||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Deny</h2><div class="sp-end">3 entries · wins over Allow</div></div>
|
|
||||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
|
||||||
<thead><tr><th>Value</th><th>Note</th><th>Added</th><th></th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td class="sp-mono sp-small">@bulk.example <span class="tag is-light">domain</span></td><td class="sp-muted">newsletter nobody subscribed to</td><td class="sp-muted sp-nowrap sp-small">2026-09-02</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
|
|
||||||
<tr><td class="sp-mono sp-small">198.51.100.0/24 <span class="tag is-light">ip</span></td><td class="sp-muted">dictionary attack on lists.example.org</td><td class="sp-muted sp-nowrap sp-small">2026-08-19</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
|
|
||||||
<tr><td class="sp-mono sp-small">win@lottery.example <span class="tag is-light">address</span></td><td class="sp-muted">—</td><td class="sp-muted sp-nowrap sp-small">2026-07-30</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-6">
|
|
||||||
<div class="box">
|
|
||||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Allow</h2><div class="sp-end">2 entries</div></div>
|
|
||||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
|
||||||
<thead><tr><th>Value</th><th>Note</th><th>Added</th><th></th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td class="sp-mono sp-small">@partner.example <span class="tag is-light">domain</span></td><td class="sp-muted">invoices were scored as spam</td><td class="sp-muted sp-nowrap sp-small">2026-09-11</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
|
|
||||||
<tr><td class="sp-mono sp-small">noreply@github.example <span class="tag is-light">address</span></td><td class="sp-muted">security advisories</td><td class="sp-muted sp-nowrap sp-small">2026-06-04</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="box">
|
|
||||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Where the lists go</h2><div class="sp-end"><span class="tag is-success is-light">applied</span></div></div>
|
|
||||||
<div class="sp-box-body">
|
|
||||||
<dl class="sp-facts">
|
|
||||||
<div><dt>Applied by</dt><dd>rspamd, from map files</dd></div>
|
|
||||||
<div><dt>Files</dt><dd class="sp-mono sp-small">/data/antispam/allow.map<br>/data/antispam/deny.map</dd></div>
|
|
||||||
<div><dt>Last written</dt><dd>2026-09-11 08:40 UTC</dd></div>
|
|
||||||
<div><dt>When the filter is off</dt><dd class="sp-muted">The lists are kept and editable, but nothing reads them until <code>INBOUND_ANTISPAM_MILTER</code> is set.</dd></div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="light">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Inbound log · 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" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
|
|
||||||
<a class="navbar-item is-active" 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"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html" aria-current="page"><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">Inbound</p>
|
|
||||||
<h1 class="title is-3">Inbound log</h1>
|
|
||||||
<p class="sp-lead">What port 25 decided about each message, one row per recipient. Accepts come from the inbound journal, rejects from <code>mail.log</code>; the filter's reasons appear when the spam filter is on.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="box">
|
|
||||||
<div class="sp-box-head">
|
|
||||||
<span class="sp-no">01</span><h2>Messages</h2>
|
|
||||||
<form class="sp-end" method="get" action="#">
|
|
||||||
<div class="select"><select name="domain"><option>All domains</option><option>lists.example.org</option><option>acme.io</option></select></div>
|
|
||||||
<div class="select"><select name="decision"><option>Any decision</option><option>accept</option><option>reject</option><option>tempfail</option><option>quarantine</option></select></div>
|
|
||||||
<input class="input" type="date" name="from" value="2026-09-14" aria-label="From date">
|
|
||||||
<input class="input" type="date" name="to" value="2026-09-21" aria-label="To date">
|
|
||||||
<button type="submit" class="button"><i class="ti ti-filter"></i>Filter</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
|
||||||
<thead><tr><th>Time, UTC</th><th>From · client</th><th>To</th><th>Subject</th><th>Decision</th><th>Why</th><th></th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:00:18</td><td>t.berg@<wbr>uni.example<br><span class="sp-mono sp-muted sp-small">192.0.2.77</span></td><td>dev@<wbr>lists.example.org</td><td class="sp-clip">Re: [dev] patch series v3</td><td><span class="tag is-success is-light">accept</span></td><td class="sp-muted sp-small">rspamd · score 0.4</td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
|
|
||||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:59:02</td><td>invoice@<wbr>partner-billing.example<br><span class="sp-mono sp-muted sp-small">203.0.113.140</span></td><td>owner@<wbr>lists.example.org</td><td class="sp-clip">Outstanding invoice — action required</td><td><span class="tag is-primary is-light">quarantine</span></td><td class="sp-muted sp-small">rspamd · score 9.8 · PHISHING, FORGED_SENDER</td><td class="sp-actions"><a href="in-quarantine-message.html">Review</a></td></tr>
|
|
||||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:57:41</td><td>promo@<wbr>bulk.example<br><span class="sp-mono sp-muted sp-small">198.51.100.90</span></td><td>sales@<wbr>lists.example.org</td><td class="sp-clip sp-muted">—</td><td><span class="tag is-danger is-light">reject</span></td><td class="sp-muted sp-small">postfix · 550 5.1.1 unknown recipient</td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
|
|
||||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:51:30</td><td>billing@<wbr>partner.example<br><span class="sp-mono sp-muted sp-small">198.51.100.12</span></td><td>owner@<wbr>lists.example.org</td><td class="sp-clip">Invoice 2026-0917</td><td><span class="tag is-danger is-light">reject</span></td><td class="sp-muted sp-small">rspamd · score 16.2 · BAYES_SPAM</td><td class="sp-actions"><a href="in-filter-lists.html">Allow sender</a></td></tr>
|
|
||||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:44:02</td><td>hr@<wbr>partner.example<br><span class="sp-mono sp-muted sp-small">192.0.2.31</span></td><td>jobs@<wbr>acme.io</td><td class="sp-clip">Application: backend engineer</td><td><span class="tag is-warning is-light">tempfail</span></td><td class="sp-muted sp-small">selfpost · upstream 10.0.4.12 timed out</td><td class="sp-actions"></td></tr>
|
|
||||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:30:55</td><td>noreply@<wbr>github.example<br><span class="sp-mono sp-muted sp-small">192.0.2.200</span></td><td>security@<wbr>lists.example.org</td><td class="sp-clip">[advisory] new report</td><td><span class="tag is-success is-light">accept</span></td><td class="sp-muted sp-small">rspamd · allow list</td><td class="sp-actions"></td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table></div>
|
|
||||||
<div class="sp-box-foot"><span>Page 1 of 38</span><a href="#">Older →</a><span class="sp-end">Allow / Deny sender opens <a href="in-filter-lists.html">Filter lists</a> with the entry filled in · global administrator only</span></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="light">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Held message · Quarantine · 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" 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 is-active" 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"><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" aria-current="page"><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-quarantine.html">Quarantine</a> /</p>
|
|
||||||
<h1 class="title is-4">Outstanding invoice — action required</h1>
|
|
||||||
<p class="sp-route"><span>invoice@partner-billing.example</span><i class="ti ti-arrow-right sp-muted"></i><span>owner@lists.example.org</span><span class="tag is-primary is-light">quarantine</span></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="columns">
|
|
||||||
<div class="column is-7">
|
|
||||||
<div class="box">
|
|
||||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Message</h2></div>
|
|
||||||
<div class="sp-box-body">
|
|
||||||
<dl class="sp-facts">
|
|
||||||
<div><dt>Inbound domain</dt><dd><a href="in-domain.html">lists.example.org</a></dd></div>
|
|
||||||
<div><dt>Client</dt><dd class="sp-mono">203.0.113.140</dd></div>
|
|
||||||
<div><dt>Held</dt><dd>2026-09-21 13:59:02 UTC</dd></div>
|
|
||||||
<div><dt>Deleted on</dt><dd>2026-10-05 · in 14 days</dd></div>
|
|
||||||
<div><dt>Size</dt><dd>48 kB · 1 attachment (<span class="sp-mono">invoice.html</span>)</dd></div>
|
|
||||||
<div><dt>Would go to</dt><dd class="sp-mono">mx.internal:25</dd></div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-5">
|
|
||||||
<div class="box">
|
|
||||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Why it was held</h2><div class="sp-end">rspamd · score 9.8</div></div>
|
|
||||||
<div class="table-container"><table class="table is-fullwidth">
|
|
||||||
<thead><tr><th>Symbol</th><th>Score</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td class="sp-mono sp-small">PHISHING</td><td>5.0</td></tr>
|
|
||||||
<tr><td class="sp-mono sp-small">FORGED_SENDER</td><td>3.0</td></tr>
|
|
||||||
<tr><td class="sp-mono sp-small">HTML_ATTACHMENT</td><td>1.8</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="box">
|
|
||||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Headers</h2><div class="sp-end">The body and attachments are never rendered in the panel</div></div>
|
|
||||||
<pre class="sp-log"><span class="sp-d">Received:</span> from mail.partner-billing.example (unknown [203.0.113.140]) by mail.example.org
|
|
||||||
<span class="sp-d">From:</span> "Partner Billing" <invoice@partner-billing.example>
|
|
||||||
<span class="sp-d">Reply-To:</span> <span class="sp-w">payments.dept@freemail.example</span>
|
|
||||||
<span class="sp-d">To:</span> owner@lists.example.org
|
|
||||||
<span class="sp-d">Subject:</span> Outstanding invoice — action required
|
|
||||||
<span class="sp-d">Date:</span> Mon, 21 Sep 2026 13:58:57 +0000
|
|
||||||
<span class="sp-d">Authentication-Results:</span> <span class="sp-e">spf=fail dkim=none dmarc=fail</span></pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="columns">
|
|
||||||
<div class="column is-6">
|
|
||||||
<div class="box">
|
|
||||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Release</h2></div>
|
|
||||||
<div class="sp-box-body">
|
|
||||||
<p class="mb-3">Hands the message to <span class="sp-mono">mx.internal:25</span> unchanged, as if it had just arrived. It leaves the quarantine.</p>
|
|
||||||
<form class="sp-form" method="post" action="in-quarantine.html">
|
|
||||||
<div class="field"><label class="checkbox"><input type="checkbox" name="allow_sender"> Also add <span class="sp-mono">invoice@partner-billing.example</span> to the Allow list</label></div>
|
|
||||||
<button type="submit" class="button is-primary"><i class="ti ti-send"></i>Release to upstream</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-6">
|
|
||||||
<div class="box sp-danger-zone">
|
|
||||||
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-alert-triangle"></i></span><h2>Discard</h2></div>
|
|
||||||
<div class="sp-box-body">
|
|
||||||
<p class="mb-3">Deletes the message now instead of in 14 days. The sender is not told. This cannot be undone.</p>
|
|
||||||
<form class="sp-form" method="post" action="in-quarantine.html">
|
|
||||||
<div class="field"><label class="checkbox"><input type="checkbox" name="deny_sender" checked> Also add <span class="sp-mono">@partner-billing.example</span> to the Deny list</label></div>
|
|
||||||
<button type="submit" class="button is-danger"><i class="ti ti-trash"></i>Discard message</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>
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="light">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Quarantine · 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" 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 is-active" 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"><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" aria-current="page"><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">Inbound</p>
|
|
||||||
<h1 class="title is-3">Quarantine</h1>
|
|
||||||
<p class="sp-lead">Mail the filter found suspicious enough to hold, but not to refuse. Release hands a message to its upstream as if it had just arrived; Discard deletes it. Anything left is deleted after 14 days.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="box">
|
|
||||||
<div class="sp-box-head">
|
|
||||||
<span class="sp-no">01</span><h2>Held messages</h2>
|
|
||||||
<form class="sp-end" method="get" action="#">
|
|
||||||
<div class="select"><select name="domain"><option>All domains</option><option>lists.example.org</option><option>acme.io</option></select></div>
|
|
||||||
<button type="submit" class="button"><i class="ti ti-filter"></i>Filter</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
|
||||||
<thead><tr><th>Held, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Why</th><th>Size</th><th>Deleted in</th><th></th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:59:02</td><td>invoice@<wbr>partner-billing.example</td><td>owner@<wbr>lists.example.org</td><td class="sp-clip"><a href="in-quarantine-message.html"><strong>Outstanding invoice — action required</strong></a></td><td class="sp-muted sp-small">score 9.8 · PHISHING</td><td class="sp-muted sp-nowrap">48 kB</td><td class="sp-nowrap">14 days</td><td class="sp-actions"><a href="in-quarantine-message.html">Review</a></td></tr>
|
|
||||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-20 22:14:40</td><td>no-reply@<wbr>shipping-update.example</td><td>jobs@<wbr>acme.io</td><td class="sp-clip"><a href="in-quarantine-message.html"><strong>Your parcel could not be delivered</strong></a></td><td class="sp-muted sp-small">score 8.1 · URL_SHORTENER</td><td class="sp-muted sp-nowrap">12 kB</td><td class="sp-nowrap">13 days</td><td class="sp-actions"><a href="in-quarantine-message.html">Review</a></td></tr>
|
|
||||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-09 07:02:11</td><td>m.ortega@<wbr>supplier.example</td><td>owner@<wbr>lists.example.org</td><td class="sp-clip"><a href="in-quarantine-message.html"><strong>Fwd: scanned contract (3 attachments)</strong></a></td><td class="sp-muted sp-small">score 7.4 · MIME_ARCHIVE</td><td class="sp-muted sp-nowrap">2.9 MB</td><td class="sp-nowrap"><span class="tag is-warning is-light">2 days</span></td><td class="sp-actions"><a href="in-quarantine-message.html">Review</a></td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table></div>
|
|
||||||
<div class="sp-box-foot"><span>3 messages · 3.0 MB of 500 MB</span><span class="sp-end">A domain administrator sees only their assigned inbound domains</span></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||