Compare commits
234 Commits
4a54af15cc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b99f60c1d6 | |||
| 41c3e6e896 | |||
| c6a75ce775 | |||
| f47cf39802 | |||
| 9edb39a8ee | |||
| f517e8da41 | |||
| 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 |
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
description: Agent rules for the SelfPost repository (formerly spec §12)
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Agent rules
|
||||||
|
|
||||||
|
1. **No git commits** without an explicit instruction in the prompt.
|
||||||
|
2. After Go changes: `go build`, `go vet`; fix all issues. Run `go test` when
|
||||||
|
tests exist.
|
||||||
|
3. Before calling a container task done: the image builds and the container starts.
|
||||||
|
4. Iterate: minimal skeleton first, then features.
|
||||||
|
5. Security requirements — `docs/security.md`; implement with the feature, not deferred.
|
||||||
|
6. Do not implement out-of-scope items (`docs/product.md`); do not change fixed
|
||||||
|
assumptions without agreement.
|
||||||
|
7. For large tasks: propose a plan before coding unless the user already approved one.
|
||||||
|
8. Check licence compatibility of new Go dependencies (permissive or GPL-family for
|
||||||
|
AGPL-3.0).
|
||||||
|
|
||||||
|
Model routing — `docs/development.md` § «Model routing». Pre-release security
|
||||||
|
**review** (not authorship) — Fable.
|
||||||
|
|
||||||
|
Commit protocol, CHANGELOG, and phase closure — `docs/development.md` §
|
||||||
|
«Commits and release build» and «Phase closure».
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
description: Panel UI mockups use the design system primitives, not a reskin of live templates.
|
||||||
|
globs: docs/assets/panel-ui/**
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Panel UI system
|
||||||
|
|
||||||
|
Source of truth: [docs/assets/panel-ui/system.html](docs/assets/panel-ui/system.html) and `system.css`.
|
||||||
|
|
||||||
|
Do not copy `internal/web/view/templates` into mockups. Do not mark `<main>` as `ops`/`form` to pick width. Do not fix empty columns, Host/Type height, or split Save/Delete with one-off CSS.
|
||||||
|
|
||||||
|
Compose screens from `stack`, `pair`, `measure`, `fill`, `field-row`, `actions-row`:
|
||||||
|
- Two peer jobs → `pair` (one child shrinks to `measure`).
|
||||||
|
- Tables/logs/DNS → `fill` (nowrap + overflow-x on the card). Add-to-list is a `measure` row inside that card, not a second card above it.
|
||||||
|
- Confirm/login/user form → `measure`.
|
||||||
|
- Host ‖ Type and two equal inputs → `field-row` (shared grid row, not two `.code` paddings).
|
||||||
|
- Save + Delete → `actions-row` inside the card; never submit inside `<form>` and danger after `</form>`.
|
||||||
|
- Danger zone is the second column of the last `pair`, never a full-width `fill`.
|
||||||
|
- Card chrome is title + optional `?` + body + actions. Help is not sprinkled later.
|
||||||
|
- Phone: `pair` stacks; `field-row` does not; tables become lists.
|
||||||
|
- One screen is one HTML file. Shared chrome is `shell.js`. Do not put every screen in one hash-SPA.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Keep the build context minimal and free of dev-only material.
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
dev
|
||||||
|
docs
|
||||||
|
bin
|
||||||
|
*.exe
|
||||||
|
README.md
|
||||||
|
Makefile
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Normalize all text files to LF in the working tree.
|
||||||
|
# Critical: shell scripts, Dockerfile and configs run inside a Linux container
|
||||||
|
# and MUST NOT get CRLF line endings (a "#!/bin/sh\r" shebang breaks startup).
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Explicit LF for files copied into / executed in the image.
|
||||||
|
*.go text eol=lf
|
||||||
|
*.sh text eol=lf
|
||||||
|
*.conf text eol=lf
|
||||||
|
*.cf text eol=lf
|
||||||
|
*.cnf text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
*.md text eol=lf
|
||||||
|
*.txt text eol=lf
|
||||||
|
*.js text eol=lf
|
||||||
|
*.html text eol=lf
|
||||||
|
*.tmpl text eol=lf
|
||||||
|
Dockerfile text eol=lf
|
||||||
|
Makefile text eol=lf
|
||||||
|
go.mod text eol=lf
|
||||||
|
go.sum text eol=lf
|
||||||
|
LICENSE text eol=lf
|
||||||
|
|
||||||
|
# Binary assets — never touch.
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
name: release
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# GitHub Release runs this workflow directly (same pattern as gosentry / imap-scrub).
|
||||||
|
# You can also run it manually via workflow_dispatch with an explicit SemVer X.Y.Z.
|
||||||
|
#
|
||||||
|
# Native per-architecture builds (see docs/development.md), not qemu:
|
||||||
|
# running the full Postfix/OpenDKIM stack under emulation for the e2e gate
|
||||||
|
# below is impractically slow. Each arch builds, e2e-gates and pushes its own
|
||||||
|
# tag on its own native runner; a merge job then combines them into the one
|
||||||
|
# manifest tag documented in deploy/docker-compose.yml. "test, then push" (not
|
||||||
|
# push-by-digest then test) is deliberate: it means the bytes that get tagged
|
||||||
|
# are exactly the bytes that passed e2e. Per-arch tags are pushed only so
|
||||||
|
# imagetools can assemble the multi-arch manifest; merge removes them from GHCR
|
||||||
|
# so operators see a single version tag (spec 10.1).
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Image version as X.Y.Z (no v prefix)."
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prepare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.version.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- name: Derive a SemVer X.Y.Z version
|
||||||
|
id: version
|
||||||
|
env:
|
||||||
|
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||||
|
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||||
|
EVENT_NAME: ${{ github.event_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ "$EVENT_NAME" = "release" ]; then
|
||||||
|
raw="${RELEASE_TAG:-}"
|
||||||
|
else
|
||||||
|
raw="${INPUT_VERSION:-}"
|
||||||
|
fi
|
||||||
|
raw="${raw#v}"
|
||||||
|
if ! [[ "$raw" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo "::error::release version is not SemVer X.Y.Z (event=${EVENT_NAME}; tag=${RELEASE_TAG:-} input=${INPUT_VERSION:-}). Refusing to publish ghcr.io/${{ github.repository }}:${raw:-?}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "version=${raw}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: prepare
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- runner: ubuntu-latest
|
||||||
|
arch: amd64
|
||||||
|
- runner: ubuntu-24.04-arm
|
||||||
|
arch: arm64
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: v${{ needs.prepare.outputs.version }}
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build image (native, loaded locally for the e2e gate)
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: build/Dockerfile
|
||||||
|
load: true
|
||||||
|
provenance: false
|
||||||
|
build-args: |
|
||||||
|
VERSION=${{ needs.prepare.outputs.version }}
|
||||||
|
tags: selfpost:e2e
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.26"
|
||||||
|
cache-dependency-path: test/e2e/go.sum
|
||||||
|
|
||||||
|
- name: e2e (gates publishing — see docs/development.md)
|
||||||
|
run: cd test/e2e && go test -v -timeout 20m ./...
|
||||||
|
|
||||||
|
- name: Log in to ghcr.io
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Push per-arch tag
|
||||||
|
# GHCR sometimes rejects the final manifest with "unknown blob" after
|
||||||
|
# every layer reports Pushed (registry race / overwrite of the same
|
||||||
|
# version tag). Retries are idempotent for identical local bytes.
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
image="ghcr.io/${{ github.repository }}:${{ needs.prepare.outputs.version }}-${{ matrix.arch }}"
|
||||||
|
docker tag selfpost:e2e "$image"
|
||||||
|
attempt=1
|
||||||
|
max=4
|
||||||
|
backoff=15
|
||||||
|
while true; do
|
||||||
|
set +e
|
||||||
|
out=$(docker push "$image" 2>&1)
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
if [ "$rc" -eq 0 ]; then
|
||||||
|
printf '%s\n' "$out"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$out" >&2
|
||||||
|
if [ "$attempt" -ge "$max" ] || ! grep -qiE 'unknown blob|blob unknown|blob upload invalid|manifest unknown|received unexpected HTTP status: 5[0-9]{2}|429 Too Many Requests|temporarily unavailable' <<<"$out"; then
|
||||||
|
echo "::error::docker push failed for ${image} (attempt ${attempt}/${max}, exit ${rc})" >&2
|
||||||
|
exit "$rc"
|
||||||
|
fi
|
||||||
|
echo "::warning::transient GHCR error pushing ${image} (attempt ${attempt}/${max}); retry in ${backoff}s" >&2
|
||||||
|
sleep "$backoff"
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
backoff=$((backoff * 2))
|
||||||
|
done
|
||||||
|
|
||||||
|
merge:
|
||||||
|
needs: [prepare, build]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: v${{ needs.prepare.outputs.version }}
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to ghcr.io
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Merge per-arch tags into the version manifest
|
||||||
|
# Same GHCR "unknown blob" class of failure can hit imagetools create
|
||||||
|
# when assembling the multi-arch version tag.
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
version_tag="ghcr.io/${{ github.repository }}:${{ needs.prepare.outputs.version }}"
|
||||||
|
amd64="ghcr.io/${{ github.repository }}:${{ needs.prepare.outputs.version }}-amd64"
|
||||||
|
arm64="ghcr.io/${{ github.repository }}:${{ needs.prepare.outputs.version }}-arm64"
|
||||||
|
attempt=1
|
||||||
|
max=4
|
||||||
|
backoff=15
|
||||||
|
while true; do
|
||||||
|
set +e
|
||||||
|
out=$(docker buildx imagetools create -t "$version_tag" "$amd64" "$arm64" 2>&1)
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
if [ "$rc" -eq 0 ]; then
|
||||||
|
printf '%s\n' "$out"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$out" >&2
|
||||||
|
if [ "$attempt" -ge "$max" ] || ! grep -qiE 'unknown blob|blob unknown|blob upload invalid|manifest unknown|received unexpected HTTP status: 5[0-9]{2}|429 Too Many Requests|temporarily unavailable' <<<"$out"; then
|
||||||
|
echo "::error::imagetools create failed for ${version_tag} (attempt ${attempt}/${max}, exit ${rc})" >&2
|
||||||
|
exit "$rc"
|
||||||
|
fi
|
||||||
|
echo "::warning::transient GHCR error merging ${version_tag} (attempt ${attempt}/${max}); retry in ${backoff}s" >&2
|
||||||
|
sleep "$backoff"
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
backoff=$((backoff * 2))
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Remove per-arch tags from GHCR
|
||||||
|
# Side-effect tags for imagetools assembly only — not part of the public
|
||||||
|
# version surface (deploy/docker-compose.yml pins X.Y.Z, not X.Y.Z-amd64).
|
||||||
|
# imagetools has no "rm" subcommand; delete via the GitHub Packages API.
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
version="${{ needs.prepare.outputs.version }}"
|
||||||
|
owner="${{ github.repository_owner }}"
|
||||||
|
pkg="${{ github.event.repository.name }}"
|
||||||
|
api="/users/${owner}/packages/container/${pkg}/versions"
|
||||||
|
for suffix in amd64 arm64; do
|
||||||
|
tag="${version}-${suffix}"
|
||||||
|
mapfile -t ids < <(gh api "$api" --paginate \
|
||||||
|
--jq ".[] | select([.metadata.container.tags[]] | index(\"${tag}\")) | .id")
|
||||||
|
if [ "${#ids[@]}" -eq 0 ]; then
|
||||||
|
echo "no GHCR package version for tag ${tag}"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
for id in "${ids[@]}"; do
|
||||||
|
echo "deleting GHCR package version ${id} (tag ${tag})"
|
||||||
|
gh api -X DELETE "${api}/${id}"
|
||||||
|
done
|
||||||
|
done
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
name: test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.26"
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Format
|
||||||
|
run: |
|
||||||
|
fmt="$(gofmt -l .)"
|
||||||
|
if [ -n "$fmt" ]; then
|
||||||
|
echo "$fmt"
|
||||||
|
echo "gofmt needed on the files above; run 'gofmt -w .'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Vet
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: go test ./...
|
||||||
+12
@@ -1 +1,13 @@
|
|||||||
|
# Development-only material (secrets, server access notes) — never published.
|
||||||
dev/
|
dev/
|
||||||
|
|
||||||
|
# Build artifacts (not committed).
|
||||||
|
/bin/
|
||||||
|
*.exe
|
||||||
|
|
||||||
|
# Local Go caches / test output, if ever present.
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# e2e scratch stand (generated cert, DKIM zone, sink dump, /data bind mount)
|
||||||
|
# — recreated fresh by every `make e2e` run, never checked in.
|
||||||
|
/test/e2e/.stage/
|
||||||
|
|||||||
+1290
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# SelfPost build.
|
||||||
|
#
|
||||||
|
# The version is stamped into both binaries and MUST match the Docker image tag
|
||||||
|
# (spec 7.5.A: backup/restore compatibility check). Override on the command line:
|
||||||
|
#
|
||||||
|
# make build VERSION=1.3.0
|
||||||
|
#
|
||||||
|
# CGO is disabled to keep the binaries fully static (modernc.org/sqlite is pure
|
||||||
|
# Go, so no cgo is required) — see spec 7.1.
|
||||||
|
|
||||||
|
VERSION ?= dev
|
||||||
|
MODULE := github.com/mixeme/selfpost
|
||||||
|
LDFLAGS := -X $(MODULE)/internal/buildinfo.Version=$(VERSION)
|
||||||
|
GOFLAGS := -trimpath
|
||||||
|
|
||||||
|
.PHONY: all build vet test clean e2e
|
||||||
|
|
||||||
|
all: vet build
|
||||||
|
|
||||||
|
build:
|
||||||
|
CGO_ENABLED=0 go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o bin/panel ./cmd/panel
|
||||||
|
CGO_ENABLED=0 go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o bin/selfpost-backup ./cmd/selfpost-backup
|
||||||
|
|
||||||
|
vet:
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf bin
|
||||||
|
|
||||||
|
# Hermetic container e2e (see docs/development.md): separate Go module
|
||||||
|
# under test/e2e so its test-only dependencies (DKIM verification) never enter
|
||||||
|
# this module's build graph. Builds the image fresh from this checkout, brings
|
||||||
|
# up deploy/docker-compose.yml plus a test-only override on high ports and an
|
||||||
|
# isolated compose project (-p selfpost-e2e) so it never collides with a real
|
||||||
|
# deployment on the same host, then tears the stand down whether the suite
|
||||||
|
# passed or not.
|
||||||
|
e2e:
|
||||||
|
cd test/e2e && go test -v -timeout 20m ./...
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
SelfPost
|
||||||
|
Copyright (C) 2026 Mikhail Yenuchenko
|
||||||
|
|
||||||
|
This product is licensed under the GNU Affero General Public License,
|
||||||
|
version 3. The full licence text is in the LICENSE file in this
|
||||||
|
distribution (and at /license in the control panel).
|
||||||
|
|
||||||
|
Corresponding Source for the published releases is at:
|
||||||
|
https://github.com/mixeme/selfpost
|
||||||
|
|
||||||
|
If you modify SelfPost and let users interact with it over a network,
|
||||||
|
AGPL-3.0 §13 requires you to offer them the Corresponding Source of
|
||||||
|
your modified version. Update SourceURL in internal/legal/legal.go
|
||||||
|
so the panel footer points at your sources.
|
||||||
|
|
||||||
|
Third-party software included in this distribution
|
||||||
|
----------------------------------------------------
|
||||||
|
|
||||||
|
htmx 2.0.4 (internal/web/view/static/htmx.min.js)
|
||||||
|
Copyright (c) Big Sky Software
|
||||||
|
Licence: Zero-Clause BSD (0BSD)
|
||||||
|
https://github.com/bigskysoftware/htmx
|
||||||
|
|
||||||
|
IBM Plex Sans / IBM Plex Mono
|
||||||
|
Copyright © 2017 IBM Corp., with Reserved Font Name "Plex"
|
||||||
|
Licence: SIL Open Font License 1.1
|
||||||
|
Full text: internal/web/view/static/OFL.txt
|
||||||
|
(also /usr/share/doc/selfpost/OFL.txt in the image, and
|
||||||
|
/static/OFL.txt from the control panel)
|
||||||
|
https://github.com/IBM/plex
|
||||||
|
Distributed unmodified as three latin-subset WOFF2 files served by the
|
||||||
|
control panel:
|
||||||
|
internal/web/view/static/ibm-plex-sans.woff2 (variable, 100-700)
|
||||||
|
internal/web/view/static/ibm-plex-mono-400.woff2
|
||||||
|
internal/web/view/static/ibm-plex-mono-600.woff2
|
||||||
|
Subsetting and WOFF2 packaging by Fontsource (fontsource.org), from
|
||||||
|
the Google Fonts distribution. The wordmarks in the shipped SVG logos
|
||||||
|
are outlined paths derived from the same fonts.
|
||||||
|
|
||||||
|
Go module dependencies are listed with their licences in
|
||||||
|
docs/development.md § External libraries. Debian packages in the
|
||||||
|
runtime image carry their own copyright files as shipped by Debian.
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="docs/assets/selfpost-stamp.svg" alt="SelfPost" width="440">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
# SelfPost
|
||||||
|
|
||||||
|
Self-hosted outbound SMTP relay with a web control panel, shipped as a single
|
||||||
|
Docker image. Postfix, OpenDKIM, and a small Go panel run together under
|
||||||
|
`supervisord`; you configure domains, DKIM keys, and SASL applications once,
|
||||||
|
then point your apps at the SMTP endpoint.
|
||||||
|
|
||||||
|
SelfPost sends mail straight to the internet from **your own IP**, with per-domain
|
||||||
|
DKIM signing. It is **outbound only** — no inbound mail, mailboxes, or webmail.
|
||||||
|
|
||||||
|
**For:** operators who run their own VPS or home server and want a simple relay
|
||||||
|
they control, without a third-party SMTP provider.
|
||||||
|
|
||||||
|
**Key properties:** one container, multi-domain DKIM, SASL per application,
|
||||||
|
send log and DNS checks in the panel, encrypted backups.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Outbound SMTP (465/smtps; optional 587 submission) with per-domain DKIM signing
|
||||||
|
- Web panel — domains, applications, deliveries, mail queue, system log, backup
|
||||||
|
- Multi-domain relay — each SASL application is bound to one sending domain
|
||||||
|
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
|
||||||
|
- Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides
|
||||||
|
- Full-server backup and single-domain export/import (optional password encryption)
|
||||||
|
- Single Docker image; production data in a `./data` bind mount (the quick start below uses a named Docker volume instead)
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
| Document | Contents |
|
||||||
|
|---|---|
|
||||||
|
| [**Operator guide**](docs/guide.md) | Reverse proxy, environment variables, DNS, IP warmup, panel operations, rate limiting, backup/restore, ports, image tag |
|
||||||
|
| [Product boundaries](docs/product.md) | Purpose, deployment assumptions, out-of-scope items, multi-domain model |
|
||||||
|
| [Architecture](docs/architecture.md) | As-built technical design |
|
||||||
|
| [Security design](docs/security.md) | Mandatory requirements, accepted risks, the CSRF ADR |
|
||||||
|
| [Development](docs/development.md) | Building, testing, docs rules, model routing, commits |
|
||||||
|
| [Roadmap](docs/roadmap.md) | Open work (1.x+) — direction, not commitments |
|
||||||
|
| [CHANGELOG](CHANGELOG.md) | Release history |
|
||||||
|
|
||||||
|
Found a vulnerability? Do not open an issue — [SECURITY.md](SECURITY.md) has
|
||||||
|
the private reporting channel and the scope.
|
||||||
|
|
||||||
|
Repository: <https://github.com/mixeme/selfpost> — source, issues, releases, and
|
||||||
|
the `ghcr.io/mixeme/selfpost` image.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
Providing these is the operator's job — SelfPost cannot fix a blocked port or a
|
||||||
|
missing PTR record for you. Details: [Operator guide](docs/guide.md).
|
||||||
|
|
||||||
|
### Platform
|
||||||
|
|
||||||
|
- Docker + Compose v2 on the host
|
||||||
|
- A reverse proxy in front of the panel (SelfPost never terminates HTTPS itself)
|
||||||
|
- Rough sizing: **1 vCPU**, **512 MB–1 GB RAM**, **8–10 GB disk** (send log and
|
||||||
|
rotated `mail.log` are the main growth drivers; both sit in the `./data`
|
||||||
|
volume, and both are capped — 90 days and 14 files by default)
|
||||||
|
|
||||||
|
### Network and IP
|
||||||
|
|
||||||
|
- [ ] Static IP address
|
||||||
|
- [ ] Outbound TCP port 25 unblocked (many consumer/cloud hosts block it by
|
||||||
|
default)
|
||||||
|
- [ ] PTR/rDNS for that IP pointing at your mail hostname (`SELFPOST_HOSTNAME`)
|
||||||
|
- [ ] Reasonable starting IP reputation — a fresh IP still needs
|
||||||
|
[warmup](docs/guide.md#ip-warmup)
|
||||||
|
|
||||||
|
### Per sending domain
|
||||||
|
|
||||||
|
For every domain you add in the panel:
|
||||||
|
|
||||||
|
- [ ] SPF TXT record authorizing this server
|
||||||
|
- [ ] DKIM TXT record (value shown on the domain page)
|
||||||
|
- [ ] DMARC `_dmarc` TXT record
|
||||||
|
|
||||||
|
See [Domain-level DNS](docs/guide.md#domain-level-dns-spf-dkim-dmarc) in the operator guide.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
> **First boot — create the admin account.** On a fresh container SelfPost prints
|
||||||
|
> a **one-time setup URL** (valid ten minutes). Open it in a browser to choose
|
||||||
|
> the administrator username and password. Until you do, the panel has no login.
|
||||||
|
> Production deploy: [Full deployment](docs/guide.md#full-deployment) in the
|
||||||
|
> operator guide.
|
||||||
|
|
||||||
|
One container, panel at `http://127.0.0.1:8080` — no reverse proxy, no TLS
|
||||||
|
files, no compose files. Good for clicking through the UI on your machine;
|
||||||
|
outbound mail will not reach the real internet without DNS, PTR, and port 25.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run --rm -d --name selfpost-try \
|
||||||
|
-p 127.0.0.1:8080:8080 \
|
||||||
|
-e SELFPOST_HOSTNAME=mail.local.test \
|
||||||
|
-e PANEL_COOKIE_SECURE=false \
|
||||||
|
-v selfpost-try-data:/data \
|
||||||
|
ghcr.io/mixeme/selfpost:1.3.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Get the setup URL** (pick one):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker logs selfpost-try 2>&1 | grep -m1 'http'
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker exec selfpost-try cat /data/setup-token
|
||||||
|
```
|
||||||
|
|
||||||
|
The printed URL is `https://mail.local.test/setup/<token>`. For this local
|
||||||
|
trial rewrite it to `http://127.0.0.1:8080/setup/<token>` (same path token;
|
||||||
|
`PANEL_COOKIE_SECURE=false` so the cookie works over plain HTTP). Open it
|
||||||
|
before it expires.
|
||||||
|
|
||||||
|
When finished:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker rm -f selfpost-try && docker volume rm selfpost-try-data
|
||||||
|
```
|
||||||
|
|
||||||
|
More detail (limitations, optional throwaway TLS for local SMTP): [Local
|
||||||
|
trial](docs/guide.md#local-trial) in the operator guide.
|
||||||
|
|
||||||
|
## Reference deploy
|
||||||
|
|
||||||
|
Production layout: one `docker-compose.yml`, a `.env`, persistent `./data`, and
|
||||||
|
TLS PEM files at `./certs` (read by Postfix on 465/587). The panel is reached
|
||||||
|
only through a reverse proxy on 443 — port 8080 is bound to localhost in the
|
||||||
|
default compose file.
|
||||||
|
|
||||||
|
Full walkthrough — fetching the base files, setting up a reverse proxy and
|
||||||
|
TLS (Apache/nginx/Caddy/Traefik), starting the container, and wiring up
|
||||||
|
DNS — lives in the operator guide's [Full
|
||||||
|
deployment](docs/guide.md#full-deployment) section, with proxy-specific
|
||||||
|
commands under [Reverse proxy](docs/guide.md#reverse-proxy-mandatory).
|
||||||
|
|
||||||
|
The compose file always publishes **465** and **587**; Postfix listens on 587
|
||||||
|
only when `SUBMISSION_ENABLE=true` (see [Ports](docs/guide.md#ports)). Bump the
|
||||||
|
pinned image tag deliberately when upgrading, never `:latest` ([why](docs/guide.md#fixed-image-tag)). Optional
|
||||||
|
variables (`TRUSTED_PROXY_CIDR`, rate limits, retention): see [Environment
|
||||||
|
variables](docs/guide.md#environment-variables).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Copyright © 2026 Mikhail Yenuchenko.
|
||||||
|
|
||||||
|
[AGPL-3.0](LICENSE). The AGPL closes the "SaaS loophole": if you run a modified
|
||||||
|
version as a network-accessible service, you must make the modified source
|
||||||
|
available to its users — not only when you distribute copies of the code.
|
||||||
|
Third-party notices: [NOTICE](NOTICE).
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
# Security policy
|
||||||
|
|
||||||
|
## Supported versions
|
||||||
|
|
||||||
|
SelfPost follows SemVer. Fixes are issued for the **latest minor release of the
|
||||||
|
1.x line** only; there is no backporting to earlier minors. Upgrade before
|
||||||
|
reporting if you are behind — the image tag is `ghcr.io/mixeme/selfpost:X.Y.Z`.
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
|---|---|
|
||||||
|
| latest 1.x | yes |
|
||||||
|
| earlier 1.x | no — upgrade first |
|
||||||
|
| 0.x | no (pre-release) |
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
**Do not open a public issue.** Use GitHub's private vulnerability reporting:
|
||||||
|
the *Report a vulnerability* button under the repository's
|
||||||
|
[Security tab](https://github.com/mixeme/selfpost/security). If you cannot use
|
||||||
|
it, mail `public@mixeme.ru` instead.
|
||||||
|
|
||||||
|
Useful in a report: the image tag, the reverse proxy in front of the panel, the
|
||||||
|
steps to reproduce, and what an attacker gains. A relevant excerpt of
|
||||||
|
`mail.log` or the panel's system log helps; strip recipient addresses first.
|
||||||
|
|
||||||
|
**No response time is promised.** SelfPost is maintained by one person, and a
|
||||||
|
deadline that cannot be honoured is worse than none. Reports are read and
|
||||||
|
answered as soon as the maintainer is able; a fix ships in a patch release,
|
||||||
|
with the timeline agreed in the thread.
|
||||||
|
|
||||||
|
Disclosure is coordinated by request, not by demand: please hold public details
|
||||||
|
until a patch is out. If you get no reply, that is not a request for a
|
||||||
|
continued embargo — disclose at your own discretion. Reporters are credited in
|
||||||
|
the CHANGELOG unless they ask not to be.
|
||||||
|
|
||||||
|
## In scope
|
||||||
|
|
||||||
|
The relay's job is to accept authenticated mail from an application and hand it
|
||||||
|
to the internet as the operator's domain, and nothing else. Breaking that is in
|
||||||
|
scope:
|
||||||
|
|
||||||
|
- **Open relay** — mail accepted from an unauthenticated sender, or relayed for
|
||||||
|
a domain the sending application is not bound to
|
||||||
|
- **SASL bypass** — sending without valid credentials, or credential recovery
|
||||||
|
from anything the container exposes
|
||||||
|
- **Cross-domain access** — an application or a panel session reaching a domain
|
||||||
|
it was not granted
|
||||||
|
- **Secret disclosure** — DKIM private keys, the admin password hash, session
|
||||||
|
tokens, or backup encryption material leaking to an unauthorised party
|
||||||
|
- **Panel authentication and session flaws** — login bypass, session fixation,
|
||||||
|
CSRF on state-changing routes, privilege escalation
|
||||||
|
- **Rate-limit bypass** — evading either the Postfix-level backstop or the
|
||||||
|
per-domain and per-application limits
|
||||||
|
- **Container escape** or privilege escalation from the panel's unprivileged
|
||||||
|
user to root
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
These are the operator's responsibility or accepted trade-offs, documented in
|
||||||
|
[docs/security.md](docs/security.md) and the
|
||||||
|
[operator guide](docs/guide.md):
|
||||||
|
|
||||||
|
- Host configuration the operator controls: a blocked port 25, a missing or
|
||||||
|
wrong PTR record, DNS records not published, a self-signed or expired
|
||||||
|
certificate on the reverse proxy
|
||||||
|
- Anything requiring the attacker to already have root on the host or write
|
||||||
|
access to the `./data` bind mount
|
||||||
|
- Missing hardening headers or TLS options on the reverse proxy — SelfPost
|
||||||
|
never terminates HTTPS itself
|
||||||
|
- Deliverability outcomes: mail rejected or filtered by a receiving provider is
|
||||||
|
a policy decision of that provider, not a defect
|
||||||
|
- Denial of service through sheer volume against a single-tenant relay
|
||||||
|
- Vulnerabilities in upstream Postfix, OpenDKIM, or the base image — report
|
||||||
|
those upstream; if SelfPost's configuration makes an upstream issue
|
||||||
|
exploitable when it otherwise would not be, that *is* in scope
|
||||||
|
|
||||||
|
## Reports we cannot act on
|
||||||
|
|
||||||
|
Automated scanner output with no demonstrated impact, and reports whose only
|
||||||
|
content is a version number compared against a CVE list, are closed without
|
||||||
|
investigation.
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
#
|
||||||
|
# SelfPost — single Debian-slim image running postfix + opendkim + panel under
|
||||||
|
# supervisord (spec 4). Build from the repository root:
|
||||||
|
#
|
||||||
|
# docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .
|
||||||
|
|
||||||
|
# ---- build stage -------------------------------------------------------------
|
||||||
|
FROM golang:1.26-bookworm AS build
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Version stamped into both binaries; MUST match the image tag (spec 7.5.A).
|
||||||
|
ARG VERSION=dev
|
||||||
|
|
||||||
|
# Module metadata first for layer caching.
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY cmd ./cmd
|
||||||
|
COPY internal ./internal
|
||||||
|
|
||||||
|
ENV CGO_ENABLED=0
|
||||||
|
RUN go vet ./... \
|
||||||
|
&& go build -trimpath \
|
||||||
|
-ldflags "-X github.com/mixeme/selfpost/internal/buildinfo.Version=${VERSION}" \
|
||||||
|
-o /out/panel ./cmd/panel \
|
||||||
|
&& go build -trimpath \
|
||||||
|
-ldflags "-X github.com/mixeme/selfpost/internal/buildinfo.Version=${VERSION}" \
|
||||||
|
-o /out/selfpost-backup ./cmd/selfpost-backup
|
||||||
|
|
||||||
|
# ---- runtime stage -----------------------------------------------------------
|
||||||
|
FROM debian:bookworm-slim AS runtime
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Preseed Postfix so its install is non-interactive and yields a working
|
||||||
|
# main.cf. The real relay configuration is generated by the panel.
|
||||||
|
RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \
|
||||||
|
&& echo "postfix postfix/main_mailer_type string Internet Site" | debconf-set-selections \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
postfix \
|
||||||
|
opendkim \
|
||||||
|
opendkim-tools \
|
||||||
|
sasl2-bin \
|
||||||
|
libsasl2-modules \
|
||||||
|
db-util \
|
||||||
|
supervisor \
|
||||||
|
logrotate \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Unprivileged user for the panel process (spec 7.6.8).
|
||||||
|
RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel
|
||||||
|
|
||||||
|
# Shared group bridging the unprivileged services (spec 5.1, 6): the panel
|
||||||
|
# generates per-domain DKIM keys, application SASL accounts (sasldb2) and the
|
||||||
|
# Postfix sender map, while OpenDKIM and Postfix (different users) must read
|
||||||
|
# them. Membership in this group — plus setgid dirs under /data (set up in
|
||||||
|
# entrypoint.sh) — lets OpenDKIM read the panel-owned keys and lets Postfix read
|
||||||
|
# the sasldb2/sender map, and lets the panel reach the supervisor control socket
|
||||||
|
# to signal OpenDKIM/Postfix reloads without any process running as root.
|
||||||
|
RUN groupadd --system selfpost \
|
||||||
|
&& usermod -aG selfpost panel \
|
||||||
|
&& usermod -aG selfpost opendkim \
|
||||||
|
&& usermod -aG selfpost postfix
|
||||||
|
|
||||||
|
# The panel reads the mail queue with `postqueue -p` (spec 7.2.11, and the
|
||||||
|
# status page's queue card). postqueue is setgid postdrop, which normally gives
|
||||||
|
# it the group needed to reach Postfix's showq socket — but the documented
|
||||||
|
# deployment runs with `no-new-privileges`, which disables setgid transitions,
|
||||||
|
# so the panel would always see "Permission denied". Making `panel` a real
|
||||||
|
# member of postdrop grants the same access without relying on a setgid
|
||||||
|
# escalation the hardening deliberately forbids. postdrop membership is
|
||||||
|
# read-side only: it does not let the panel bypass any Postfix restriction that
|
||||||
|
# a local user does not already have through the world-executable sendmail.
|
||||||
|
RUN usermod -aG postdrop panel
|
||||||
|
|
||||||
|
# Runtime directories: milter sockets and the consolidated persistent root.
|
||||||
|
RUN mkdir -p /run/opendkim /run/selfpost /data \
|
||||||
|
&& chown opendkim:opendkim /run/opendkim \
|
||||||
|
&& chown panel:panel /run/selfpost /data
|
||||||
|
|
||||||
|
COPY --from=build /out/panel /usr/local/bin/panel
|
||||||
|
COPY --from=build /out/selfpost-backup /usr/local/bin/selfpost-backup
|
||||||
|
|
||||||
|
# Licence text shipped with the image (AGPL-3.0 conveyance). The panel also
|
||||||
|
# serves the same text at /license from an embedded copy. OFL.txt is the SIL
|
||||||
|
# Open Font License 1.1 that must travel with the IBM Plex WOFF2 files
|
||||||
|
# embedded in the panel binary (NOTICE).
|
||||||
|
COPY --chmod=0644 LICENSE NOTICE /usr/share/doc/selfpost/
|
||||||
|
COPY --chmod=0644 internal/web/view/static/OFL.txt /usr/share/doc/selfpost/OFL.txt
|
||||||
|
|
||||||
|
# File modes are pinned here so a build context copied from a checkout without
|
||||||
|
# POSIX permissions (e.g. Windows tar sync) cannot land group-writable config
|
||||||
|
# that logrotate would silently ignore — see docs/development.md § Building
|
||||||
|
# binaries and the image.
|
||||||
|
COPY --chmod=0644 build/opendkim.conf /etc/opendkim.conf
|
||||||
|
COPY --chmod=0644 build/logrotate-mail.conf /etc/logrotate.d/mail
|
||||||
|
COPY --chmod=0755 build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh
|
||||||
|
COPY --chmod=0755 build/postfix-config.sh /usr/local/bin/postfix-config.sh
|
||||||
|
COPY --chmod=0755 build/postfix-cert-reload.sh /usr/local/bin/postfix-cert-reload.sh
|
||||||
|
COPY --chmod=0755 build/logrotate-loop.sh /usr/local/bin/logrotate-loop.sh
|
||||||
|
COPY --chmod=0755 build/crashexit.py /usr/local/bin/crashexit.py
|
||||||
|
COPY --chmod=0755 build/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
COPY --chmod=0644 build/supervisord.conf /etc/supervisor/supervisord.conf
|
||||||
|
|
||||||
|
# Published submission ports: 465 (smtps, primary) and 587 (submission, optional)
|
||||||
|
# plus the panel on 8080. Outbound delivery dials remote MXs on 25 as a client,
|
||||||
|
# which needs no inbound listener or EXPOSE.
|
||||||
|
EXPOSE 8080 465 587
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
|
||||||
|
CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1
|
||||||
|
|
||||||
|
# The entrypoint fixes /data ownership (bind mount) as root, then execs
|
||||||
|
# supervisord, which becomes PID 1 and owns process supervision (spec 4).
|
||||||
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Supervisor event listener: bring the container down on unrecoverable failure.
|
||||||
|
|
||||||
|
If any managed program exhausts its restart retries and enters FATAL, this
|
||||||
|
listener signals supervisord (its parent) to terminate, so the whole container
|
||||||
|
exits and Docker's `restart` policy can recreate it cleanly — rather than
|
||||||
|
lingering "alive" with a dead Postfix/OpenDKIM/panel (spec 4).
|
||||||
|
|
||||||
|
Communication with supervisord uses the event listener protocol over stdin and
|
||||||
|
stdout, so stdout must carry only protocol tokens.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def write_stdout(s):
|
||||||
|
sys.stdout.write(s)
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
while True:
|
||||||
|
# Tell supervisord we are ready for the next event.
|
||||||
|
write_stdout("READY\n")
|
||||||
|
|
||||||
|
line = sys.stdin.readline()
|
||||||
|
if not line:
|
||||||
|
return
|
||||||
|
headers = dict(pair.split(":", 1) for pair in line.split())
|
||||||
|
payload_len = int(headers.get("len", 0))
|
||||||
|
if payload_len:
|
||||||
|
sys.stdin.read(payload_len)
|
||||||
|
|
||||||
|
# We only subscribe to PROCESS_STATE_FATAL, so any event means a managed
|
||||||
|
# program can no longer be restarted. Take the container down.
|
||||||
|
sys.stderr.write(
|
||||||
|
"crashexit: a managed process entered FATAL; shutting down container\n"
|
||||||
|
)
|
||||||
|
sys.stderr.flush()
|
||||||
|
os.kill(os.getppid(), signal.SIGTERM)
|
||||||
|
|
||||||
|
write_stdout("RESULT 2\nOK")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Container entrypoint (runs as root, PID 1 until it execs supervisord).
|
||||||
|
#
|
||||||
|
# SELFPOST_HOSTNAME is checked first (plan B.3): it must fail fast with a clear
|
||||||
|
# message before /data normalisation or Postfix config, so a bad identity never
|
||||||
|
# looks like a permissions or packaging problem (and so the e2e hostname-gate
|
||||||
|
# tests see the FATAL text rather than an earlier set -e abort).
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# SELFPOST_HOSTNAME is an identity, not a setting with a safe default: it must
|
||||||
|
# simultaneously match the PTR/rDNS record, the certificate CN/SAN, and the
|
||||||
|
# Cyrus SASL realm (spec 5.2 p.3, 8). The panel (main.go saslRealm()) and
|
||||||
|
# postfix-config.sh each fall back independently when it's unset — to
|
||||||
|
# `localhost` and to the container hostname respectively — so accounts get
|
||||||
|
# written under one realm and looked up under another and authentication
|
||||||
|
# silently fails for every application, while HELO also stops matching the
|
||||||
|
# PTR record and mail that does go out lands in spam. No fallback can be
|
||||||
|
# correct, so fail loudly here, before either side of that split has a chance
|
||||||
|
# to run, rather than leave a green panel with broken mail.
|
||||||
|
if [ -z "$SELFPOST_HOSTNAME" ]; then
|
||||||
|
cat >&2 <<'EOF'
|
||||||
|
FATAL: SELFPOST_HOSTNAME is not set.
|
||||||
|
|
||||||
|
This is the mail server's identity: it becomes the Postfix HELO/EHLO name,
|
||||||
|
the Cyrus SASL realm that application passwords are looked up under, and it
|
||||||
|
must match the TLS certificate's CN/SAN as well as this server's PTR (reverse
|
||||||
|
DNS) record. There is no safe default — guessing any one of these wrong
|
||||||
|
breaks authentication for every application or sends outgoing mail to spam,
|
||||||
|
silently.
|
||||||
|
|
||||||
|
Set it to the mail server's fully-qualified domain name, e.g.:
|
||||||
|
|
||||||
|
SELFPOST_HOSTNAME=mail.example.com
|
||||||
|
|
||||||
|
in the .env file next to your docker-compose.yml (see deploy/.env.example).
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$SELFPOST_HOSTNAME" in
|
||||||
|
*[\ \ ]* | *://* | *:* )
|
||||||
|
echo "FATAL: SELFPOST_HOSTNAME must be a bare hostname (no scheme, port, or spaces): \"$SELFPOST_HOSTNAME\"" >&2
|
||||||
|
echo 'Example: SELFPOST_HOSTNAME=mail.example.com' >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
*.*)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "FATAL: SELFPOST_HOSTNAME must be a fully-qualified domain name (at least one dot): \"$SELFPOST_HOSTNAME\"" >&2
|
||||||
|
echo 'Example: SELFPOST_HOSTNAME=mail.example.com' >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# The persistent root /data is a host bind mount (spec 9), so it arrives owned
|
||||||
|
# by the host user (typically root), not by the unprivileged panel user that
|
||||||
|
# actually writes the SQLite database, setup token and DKIM keys (spec 7.6.8).
|
||||||
|
# Fix its ownership here — the one place still running as root — before handing
|
||||||
|
# off to supervisord, which starts the panel as the panel user.
|
||||||
|
#
|
||||||
|
# Mode must stay world-traversable (0755): OpenDKIM and Postfix reach their
|
||||||
|
# trees under /data as other users. Go's testing.TempDir is 0700, and a bare
|
||||||
|
# chown would leave that mode in place — opendkim then cannot read KeyTable and
|
||||||
|
# the container crash-loops (e2e TestHostnameGate/valid_hostname_starts).
|
||||||
|
chown panel:panel /data
|
||||||
|
chmod 755 /data
|
||||||
|
# Restored backups or previously-created state may contain panel-owned files
|
||||||
|
# under /data; make sure they stay writable without disturbing anything that a
|
||||||
|
# later phase deliberately hands to another service. /data/log is exempt: it is
|
||||||
|
# deliberately owned by postfix (postlogd writes the delivery log there) and is
|
||||||
|
# normalised on its own below.
|
||||||
|
find /data -mindepth 1 -maxdepth 1 ! -user panel ! -name log ! -name postfix -exec chown -R panel:panel {} +
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# tree on every start so it is correct whether /data is fresh, restarted, or
|
||||||
|
# just restored from a backup:
|
||||||
|
# - group `selfpost` + setgid on directories so anything the panel creates
|
||||||
|
# inherits the shared group OpenDKIM traverses;
|
||||||
|
# - private keys and tables group-readable (0640);
|
||||||
|
# - both table files present (empty is fine) BEFORE OpenDKIM starts, so the
|
||||||
|
# daemon comes up cleanly with no domains yet.
|
||||||
|
mkdir -p /data/opendkim/keys
|
||||||
|
for t in /data/opendkim/KeyTable /data/opendkim/SigningTable; do
|
||||||
|
[ -e "$t" ] || : > "$t"
|
||||||
|
done
|
||||||
|
chown -R panel:selfpost /data/opendkim
|
||||||
|
find /data/opendkim -type d -exec chmod 2750 {} +
|
||||||
|
chmod 0640 /data/opendkim/KeyTable /data/opendkim/SigningTable
|
||||||
|
find /data/opendkim/keys -type f -name '*.private' -exec chmod 0640 {} +
|
||||||
|
|
||||||
|
# Application SASL accounts (spec 5.1, 9). The panel (user `panel`) writes the
|
||||||
|
# sasldb2 via saslpasswd2; Postfix (user `postfix`) reads it to authenticate SMTP
|
||||||
|
# clients. Share it through the `selfpost` group the same way as the DKIM tree:
|
||||||
|
# setgid directory so new files inherit the group, and the database itself
|
||||||
|
# group-readable (0640).
|
||||||
|
mkdir -p /data/sasl
|
||||||
|
chown -R panel:selfpost /data/sasl
|
||||||
|
chmod 2750 /data/sasl
|
||||||
|
[ -e /data/sasl/sasldb2 ] && chmod 0640 /data/sasl/sasldb2
|
||||||
|
|
||||||
|
# Postfix state under /data (spec 5.1, architecture.md § Persistence). The panel
|
||||||
|
# writes sender_login_maps; Postfix owns the on-disk queue tree under queue/.
|
||||||
|
mkdir -p /data/postfix/queue
|
||||||
|
[ -e /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps
|
||||||
|
chown panel:selfpost /data/postfix
|
||||||
|
chmod 2750 /data/postfix
|
||||||
|
chown panel:selfpost /data/postfix/sender_login_maps
|
||||||
|
chmod 0640 /data/postfix/sender_login_maps
|
||||||
|
|
||||||
|
# Delivery log (architecture.md § Log tailer). postlogd writes it as user
|
||||||
|
# `postfix`; the panel reads it for the log-tailer and the System log page. It
|
||||||
|
# lives under /data — not the ephemeral /var/log — so the delivery lines that
|
||||||
|
# resolve a "queued" send-log row survive a container recreate.
|
||||||
|
#
|
||||||
|
# postlogd creates a missing log itself, but at 0600, which the unprivileged
|
||||||
|
# panel cannot read; so create it here (and re-normalise an existing one, plus
|
||||||
|
# whatever logrotate left behind) at 0640 owned postfix:selfpost. The setgid
|
||||||
|
# directory keeps the shared group on anything created inside it later, and
|
||||||
|
# 2750 keeps it group-traversable but not group-writable — logrotate refuses to
|
||||||
|
# rotate a log whose directory is writable by a non-root group.
|
||||||
|
mkdir -p /data/log
|
||||||
|
[ -e /data/log/mail.log ] || : > /data/log/mail.log
|
||||||
|
chown -R postfix:selfpost /data/log
|
||||||
|
chmod 2750 /data/log
|
||||||
|
find /data/log -type f -exec chmod 0640 {} +
|
||||||
|
|
||||||
|
# Milter socket directories (spec 5 p.3, 7.3). Postfix (user `postfix`) must
|
||||||
|
# actually CONNECT to both milter sockets — OpenDKIM's and the panel's
|
||||||
|
# journal-milter — not just probe them at start-up. The sockets are
|
||||||
|
# created by the opendkim and panel users respectively, so bridge them to
|
||||||
|
# `postfix` through the shared `selfpost` group: group-owned + setgid dirs mean
|
||||||
|
# each socket created inside inherits group `selfpost`, and group-traversable
|
||||||
|
# (2750) lets postfix reach it. Without this, smtpd cannot talk to OpenDKIM and,
|
||||||
|
# because signing is strict (default_action=tempfail), rejects all mail.
|
||||||
|
mkdir -p /run/opendkim /run/selfpost
|
||||||
|
chown opendkim:selfpost /run/opendkim
|
||||||
|
chown panel:selfpost /run/selfpost
|
||||||
|
chmod 2750 /run/opendkim /run/selfpost
|
||||||
|
|
||||||
|
# Generate the outbound-relay Postfix configuration from the environment (spec
|
||||||
|
# 5). Kept out of the image build so cert paths, rate limits, hostname and the
|
||||||
|
# optional 587 service are all driven by env at run time, and re-derived on every
|
||||||
|
# start the same way the /data normalisation above is.
|
||||||
|
/usr/local/bin/postfix-config.sh
|
||||||
|
|
||||||
|
# Initialise the persistent queue tree on first start or after restore. postfix
|
||||||
|
# set-permissions reads queue_directory from main.cf (set by postfix-config.sh).
|
||||||
|
if [ ! -d /data/postfix/queue/active ]; then
|
||||||
|
postfix set-permissions
|
||||||
|
fi
|
||||||
|
chown -R postfix:postfix /data/postfix/queue
|
||||||
|
|
||||||
|
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Periodic logrotate for /data/log/mail.log (spec 9, 10). Rotation renames the
|
||||||
|
# file, recreates it (`create 0640 postfix selfpost`, matching a cold container
|
||||||
|
# start), then runs `postfix reload` (the same mechanism `postfix logrotate`
|
||||||
|
# uses): postlogd keeps writing to the renamed inode until reload, and the
|
||||||
|
# panel's log-tailer holds its own descriptor on that inode, so nothing
|
||||||
|
# written before the reload is lost. `create` (rather than `nocreate`) matters
|
||||||
|
# here beyond timing: a postlogd-triggered recreate lands the file at 0600
|
||||||
|
# owned by postfix, which the unprivileged panel process cannot read —
|
||||||
|
# confirmed on a live container — so logrotate must be the one to create it.
|
||||||
|
#
|
||||||
|
# logrotate itself only rotates once the configured "daily" period has elapsed
|
||||||
|
# (tracked in /var/lib/logrotate/status), so it is safe to invoke this more
|
||||||
|
# often than daily — polling merely bounds how late a legitimate rotation runs.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
CONFIG=/etc/logrotate.d/mail
|
||||||
|
INTERVAL="${LOGROTATE_INTERVAL_SECONDS:-21600}"
|
||||||
|
|
||||||
|
# logrotate refuses configs writable by group or others and exits 0 while
|
||||||
|
# ignoring them — fail here so supervisord reports the fault.
|
||||||
|
logrotate_config_ok() {
|
||||||
|
mode=$(stat -c '%a' "$CONFIG")
|
||||||
|
mode=${mode#0}
|
||||||
|
grp=$(( (mode / 10) % 10 ))
|
||||||
|
oth=$(( mode % 10 ))
|
||||||
|
case $grp in 2|3|6|7) return 1 ;; esac
|
||||||
|
case $oth in 2|3|6|7) return 1 ;; esac
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
logrotate_config_fatal() {
|
||||||
|
echo "logrotate-loop: refusing to run: $CONFIG mode $(stat -c '%a' "$CONFIG") is writable by group or others" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! logrotate_config_ok; then
|
||||||
|
logrotate_config_fatal
|
||||||
|
fi
|
||||||
|
|
||||||
|
run_logrotate() {
|
||||||
|
out=$(logrotate "$CONFIG" 2>&1) || {
|
||||||
|
echo "$out" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
case "$out" in
|
||||||
|
*Ignoring*|*Potentially\ dangerous\ mode*)
|
||||||
|
echo "$out" >&2
|
||||||
|
logrotate_config_fatal
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
if ! logrotate_config_ok; then
|
||||||
|
logrotate_config_fatal
|
||||||
|
fi
|
||||||
|
if run_logrotate; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
echo "logrotate-loop: logrotate failed, will retry after ${INTERVAL}s" >&2
|
||||||
|
fi
|
||||||
|
sleep "${INTERVAL}"
|
||||||
|
done
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# The delivery log lives under the persistent /data, not the ephemeral
|
||||||
|
# /var/log, so the lines that resolve a "queued" send-log row outlive the
|
||||||
|
# container (architecture.md § Log tailer). Path and ownership match
|
||||||
|
# build/postfix-config.sh (maillog_file) and build/entrypoint.sh: postlogd
|
||||||
|
# writes it as user postfix, the unprivileged panel reads it through the shared
|
||||||
|
# selfpost group, hence create 0640 postfix selfpost rather than 0644 root root.
|
||||||
|
/data/log/mail.log {
|
||||||
|
daily
|
||||||
|
rotate 14
|
||||||
|
missingok
|
||||||
|
notifempty
|
||||||
|
compress
|
||||||
|
delaycompress
|
||||||
|
create 0640 postfix selfpost
|
||||||
|
postrotate
|
||||||
|
/usr/sbin/postfix reload
|
||||||
|
endscript
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# OpenDKIM — per-domain signing (spec 6).
|
||||||
|
#
|
||||||
|
# Signing is strictly per-domain: each sending domain has its own key pair and
|
||||||
|
# selector. The panel generates keys under /data/opendkim/keys/<domain>/ and
|
||||||
|
# keeps KeyTable/SigningTable in sync as domains are added or removed, then asks
|
||||||
|
# supervisord to send this process SIGUSR1 to reload the tables (spec 6.2, 6.5).
|
||||||
|
#
|
||||||
|
# Both table files are created (empty) by entrypoint.sh before OpenDKIM starts,
|
||||||
|
# so the daemon comes up cleanly on a fresh /data with no domains yet and simply
|
||||||
|
# signs nothing until the first domain is added.
|
||||||
|
|
||||||
|
Syslog no
|
||||||
|
UMask 007
|
||||||
|
Mode s
|
||||||
|
|
||||||
|
# SigningTable uses refile: so the "*@example.com" left-hand patterns match any
|
||||||
|
# local-part for a domain; KeyTable maps each domain to its key and selector.
|
||||||
|
KeyTable /data/opendkim/KeyTable
|
||||||
|
SigningTable refile:/data/opendkim/SigningTable
|
||||||
|
|
||||||
|
# The private keys are owned by `panel` and read by `opendkim` through the shared
|
||||||
|
# `selfpost` group, so they are deliberately group-readable (mode 0640). That is
|
||||||
|
# safe on this single-tenant, private bind mount, but it trips OpenDKIM's default
|
||||||
|
# key-safety check, so the check is disabled here by design.
|
||||||
|
RequireSafeKeys no
|
||||||
|
|
||||||
|
Socket local:/run/opendkim/opendkim.sock
|
||||||
|
PidFile /run/opendkim/opendkim.pid
|
||||||
|
UserID opendkim
|
||||||
|
Background no
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Periodic `postfix reload` so refreshed TLS certificates are picked up (spec
|
||||||
|
# 5.2 p.4). The reverse-proxy renews the PEM files in the read-only mount every
|
||||||
|
# few months; Postfix only re-reads them on reload. A simple daily reload is
|
||||||
|
# more than enough (a day of staleness is harmless) and far simpler than an
|
||||||
|
# inotify watcher — the spec explicitly prefers this.
|
||||||
|
#
|
||||||
|
# Runs under supervisord as root, so it can reload Postfix directly. It sleeps
|
||||||
|
# first, then reloads in a loop: no reload at container start (the wrapper is
|
||||||
|
# still bringing Postfix up then) and none until at least one interval has
|
||||||
|
# passed. A reload is harmless when nothing changed.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
INTERVAL="${TLS_RELOAD_INTERVAL_SECONDS:-86400}"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
sleep "${INTERVAL}"
|
||||||
|
if postfix reload; then
|
||||||
|
echo "cert-reload: postfix reloaded (periodic TLS refresh)"
|
||||||
|
else
|
||||||
|
# Never exit non-zero: a transient reload failure must not trip the
|
||||||
|
# crashexit listener and take the container down. Log and retry next cycle.
|
||||||
|
echo "cert-reload: postfix reload failed, will retry after ${INTERVAL}s" >&2
|
||||||
|
fi
|
||||||
|
done
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Generate the outbound-relay Postfix configuration (spec 5, 5.1, 5.2).
|
||||||
|
#
|
||||||
|
# Run as root from entrypoint.sh on every container start, BEFORE supervisord
|
||||||
|
# starts the Postfix wrapper. Like the rest of SelfPost's runtime state it is
|
||||||
|
# re-derived from the environment on each start rather than persisted, so a
|
||||||
|
# fresh, restarted or restored container always ends up with the same config
|
||||||
|
# (the only panel-edited Postfix file, sender_login_maps, lives under /data and
|
||||||
|
# is generated separately by the panel — spec 5.1).
|
||||||
|
#
|
||||||
|
# main.cf is written with `postconf -e`, the master.cf submission services with
|
||||||
|
# `postconf -M`/`-P`. No user input is interpolated: every value here comes from
|
||||||
|
# a fixed literal or a container environment variable (spec 7.6.3).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# --- environment knobs (spec 8) ----------------------------------------------
|
||||||
|
# Server hostname: used as HELO name AND, crucially, as the Cyrus SASL realm the
|
||||||
|
# sasldb2 accounts are looked up under. The panel creates accounts under realm
|
||||||
|
# $SELFPOST_HOSTNAME (SASL_REALM), so myhostname MUST match or authentication
|
||||||
|
# fails. Fall back to the container hostname only outside a real deployment.
|
||||||
|
HOSTNAME_VALUE="${SELFPOST_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}"
|
||||||
|
|
||||||
|
# TLS material supplied by the reverse-proxy through a read-only bind mount
|
||||||
|
# (spec 5.2). The relay requires TLS on 465; if these files are absent the
|
||||||
|
# master still starts but TLS handshakes on 465 fail until they appear.
|
||||||
|
#
|
||||||
|
# Postfix insists the private key is root-owned and mode 0600. The bind mount
|
||||||
|
# is often :ro and owned by the host user (e2e TempDir / CI runner UID), so
|
||||||
|
# copy into a writable internal dir and normalise ownership before postconf
|
||||||
|
# and `postfix check`.
|
||||||
|
TLS_CERT_SRC="${TLS_CERT_FILE:-/etc/postfix/tls/fullchain.pem}"
|
||||||
|
TLS_KEY_SRC="${TLS_KEY_FILE:-/etc/postfix/tls/privkey.pem}"
|
||||||
|
TLS_INTERNAL_DIR=/etc/postfix/tls-internal
|
||||||
|
TLS_CERT="$TLS_CERT_SRC"
|
||||||
|
TLS_KEY="$TLS_KEY_SRC"
|
||||||
|
if [ -f "$TLS_CERT_SRC" ] && [ -f "$TLS_KEY_SRC" ]; then
|
||||||
|
mkdir -p "$TLS_INTERNAL_DIR"
|
||||||
|
cp -f "$TLS_CERT_SRC" "$TLS_INTERNAL_DIR/fullchain.pem"
|
||||||
|
cp -f "$TLS_KEY_SRC" "$TLS_INTERNAL_DIR/privkey.pem"
|
||||||
|
chown root:root "$TLS_INTERNAL_DIR/fullchain.pem" "$TLS_INTERNAL_DIR/privkey.pem"
|
||||||
|
chmod 0644 "$TLS_INTERNAL_DIR/fullchain.pem"
|
||||||
|
chmod 0600 "$TLS_INTERNAL_DIR/privkey.pem"
|
||||||
|
TLS_CERT="$TLS_INTERNAL_DIR/fullchain.pem"
|
||||||
|
TLS_KEY="$TLS_INTERNAL_DIR/privkey.pem"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Level-1 rate limit (native Postfix anvil, spec 5 p.5 / 7.4). Conservative
|
||||||
|
# defaults, sensible during IP warm-up (spec 10).
|
||||||
|
RATE_MSGS="${RATE_LIMIT_MESSAGES_PER_IP:-100}"
|
||||||
|
RATE_WINDOW="${RATE_LIMIT_WINDOW_SECONDS:-3600}"
|
||||||
|
|
||||||
|
# Milter sockets: OpenDKIM (signing, strict) and the panel journal-milter
|
||||||
|
# (monitoring, fail-open). Fixed container paths, matched by postfix-wrapper.sh.
|
||||||
|
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}"
|
||||||
|
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
|
||||||
|
|
||||||
|
# Persistent panel-managed sender map (spec 5.1); texthash needs no postmap, so
|
||||||
|
# the unprivileged panel can rewrite it and just ask for a reload.
|
||||||
|
SENDER_LOGIN_MAPS="${POSTFIX_SENDER_LOGIN_MAPS:-/data/postfix/sender_login_maps}"
|
||||||
|
# Transit mail queue under /data so deferred/active messages survive container
|
||||||
|
# recreate (architecture.md § Persistence). Distinct from sender_login_maps.
|
||||||
|
QUEUE_DIR="${POSTFIX_QUEUE_DIR:-/data/postfix/queue}"
|
||||||
|
SASLDB_PATH="${SASL_DB_PATH:-/data/sasl/sasldb2}"
|
||||||
|
|
||||||
|
# Optional submission service on 587 (spec 5 p.1: off by default, enabled only
|
||||||
|
# when a client library needs STARTTLS on 587 instead of implicit TLS on 465).
|
||||||
|
SUBMISSION_ENABLE="${SUBMISSION_ENABLE:-false}"
|
||||||
|
|
||||||
|
# Delivery log, written by postlogd and read by the panel's log-tailer. It lives
|
||||||
|
# under the persistent /data (not the ephemeral /var/log) so the delivery lines
|
||||||
|
# for messages still marked "queued" survive a container recreate — without
|
||||||
|
# them those rows could never be resolved (architecture.md § Log tailer). The
|
||||||
|
# default must match cmd/panel/main.go's MAIL_LOG; entrypoint.sh creates the
|
||||||
|
# directory and the file with the ownership postlogd writes and the panel reads.
|
||||||
|
MAIL_LOG_PATH="${MAIL_LOG:-/data/log/mail.log}"
|
||||||
|
|
||||||
|
# --- main.cf -----------------------------------------------------------------
|
||||||
|
# maillog_file lives under the persistent /data bind mount (architecture.md §
|
||||||
|
# Log tailer). Postfix's default maillog_file_prefixes are only /var and
|
||||||
|
# /dev/stdout — without /data, `postfix check` rejects the path.
|
||||||
|
postconf -e \
|
||||||
|
"myhostname=${HOSTNAME_VALUE}" \
|
||||||
|
"maillog_file=${MAIL_LOG_PATH}" \
|
||||||
|
"maillog_file_prefixes=/var,/dev/stdout,/data" \
|
||||||
|
"mydestination=" \
|
||||||
|
"relayhost=" \
|
||||||
|
"inet_interfaces=all" \
|
||||||
|
"inet_protocols=all"
|
||||||
|
|
||||||
|
# postlogd is mandatory whenever maillog_file is set (MAILLOG_README). Debian's
|
||||||
|
# stock master.cf usually has it; pin it explicitly so a stripped/upgraded
|
||||||
|
# image cannot lose the service.
|
||||||
|
postconf -M "postlog/unix-dgram=postlog unix-dgram n - n - 1 postlogd"
|
||||||
|
|
||||||
|
# This is an outbound relay: no local delivery, no per-user aliases. Empty
|
||||||
|
# these so a misfiled recipient never gets delivered locally.
|
||||||
|
postconf -e \
|
||||||
|
"local_recipient_maps=" \
|
||||||
|
"alias_maps=" \
|
||||||
|
"alias_database="
|
||||||
|
|
||||||
|
# Outbound delivery: straight to the recipient MX, opportunistic TLS (spec 5 p.2).
|
||||||
|
postconf -e \
|
||||||
|
"smtp_tls_security_level=may" \
|
||||||
|
"smtp_tls_note_starttls_offer=yes"
|
||||||
|
|
||||||
|
# TLS server material shared by every inbound service (spec 5.2). auth_only
|
||||||
|
# guarantees credentials are never accepted before TLS is up on any port.
|
||||||
|
postconf -e \
|
||||||
|
"smtpd_tls_cert_file=${TLS_CERT}" \
|
||||||
|
"smtpd_tls_key_file=${TLS_KEY}" \
|
||||||
|
"smtpd_tls_security_level=may" \
|
||||||
|
"smtpd_tls_auth_only=yes" \
|
||||||
|
"smtpd_tls_loglevel=1"
|
||||||
|
|
||||||
|
# SASL: Cyrus with the local sasldb2 the panel maintains (spec 5.1). The realm
|
||||||
|
# is left implicit (smtpd_sasl_local_domain empty) so the authenticated name
|
||||||
|
# Postfix uses for sender_login_maps is the BARE login the panel writes into the
|
||||||
|
# map; the sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname
|
||||||
|
# as the server realm, which equals the realm the accounts were created under.
|
||||||
|
postconf -e \
|
||||||
|
"smtpd_sasl_auth_enable=yes" \
|
||||||
|
"smtpd_sasl_type=cyrus" \
|
||||||
|
"smtpd_sasl_path=smtpd" \
|
||||||
|
"smtpd_sasl_local_domain=" \
|
||||||
|
"smtpd_sasl_security_options=noanonymous" \
|
||||||
|
"smtpd_sasl_tls_security_options=noanonymous" \
|
||||||
|
"broken_sasl_auth_clients=yes"
|
||||||
|
|
||||||
|
# Sender binding (spec 5.1 p.3, the critical anti-spoofing control). texthash
|
||||||
|
# resolves the full address first, then the "@domain" wildcard, so both address
|
||||||
|
# modes work from the same map.
|
||||||
|
postconf -e \
|
||||||
|
"smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}" \
|
||||||
|
"queue_directory=${QUEUE_DIR}"
|
||||||
|
|
||||||
|
# Restrictions: authenticated clients only, no relay to foreign destinations,
|
||||||
|
# and every authenticated sender address must be owned by its login. NO
|
||||||
|
# permit_mynetworks anywhere — authorisation is by credentials, never by network
|
||||||
|
# (spec 5 p.1/p.4, 5.1). This is what makes an open relay impossible.
|
||||||
|
postconf -e \
|
||||||
|
"smtpd_helo_required=yes" \
|
||||||
|
"smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
|
||||||
|
"smtpd_recipient_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
|
||||||
|
"smtpd_sender_restrictions=reject_sender_login_mismatch, permit"
|
||||||
|
|
||||||
|
# Level-1 rate limit by client IP (spec 5 p.5). Backstop that keeps working even
|
||||||
|
# if the journal-milter (level 2) is down.
|
||||||
|
postconf -e \
|
||||||
|
"smtpd_client_message_rate_limit=${RATE_MSGS}" \
|
||||||
|
"anvil_rate_time_unit=${RATE_WINDOW}s"
|
||||||
|
|
||||||
|
# Milter chain (spec 5 p.3, 7.3). OpenDKIM signs and is treated strictly
|
||||||
|
# (default_action=tempfail: if it is unreachable, defer rather than send
|
||||||
|
# unsigned). The journal-milter is monitoring only and is fail-open
|
||||||
|
# (default_action=accept): its failure must never block the relay. Per-milter
|
||||||
|
# settings use Postfix 3.0+ brace syntax.
|
||||||
|
postconf -e \
|
||||||
|
"milter_protocol=6" \
|
||||||
|
"milter_default_action=tempfail" \
|
||||||
|
"smtpd_milters={ unix:${OPENDKIM_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \
|
||||||
|
"non_smtpd_milters="
|
||||||
|
|
||||||
|
# Bounded milter timeouts (spec 7.3): a *hung* milter (socket accepts but never
|
||||||
|
# replies) must fail open just like a crash, not stall mail acceptance until the
|
||||||
|
# Postfix defaults (300s content) elapse. With default_action per milter, a
|
||||||
|
# journal-milter hang then resolves to accept and an OpenDKIM hang to tempfail,
|
||||||
|
# but within seconds rather than minutes. Values are well above any healthy
|
||||||
|
# response time (signing/DB insert are sub-second), so they never fire in normal
|
||||||
|
# operation.
|
||||||
|
postconf -e \
|
||||||
|
"milter_connect_timeout=${MILTER_CONNECT_TIMEOUT:-15s}" \
|
||||||
|
"milter_command_timeout=${MILTER_COMMAND_TIMEOUT:-15s}" \
|
||||||
|
"milter_content_timeout=${MILTER_CONTENT_TIMEOUT:-30s}"
|
||||||
|
|
||||||
|
# --- master.cf: inbound submission services ----------------------------------
|
||||||
|
# smtps (465, implicit/wrapper TLS) — the primary, always-on submission service
|
||||||
|
# (spec 5 p.1). chroot=n so smtpd can read the sasldb2 and sender map under /data
|
||||||
|
# and the Cyrus config outside any chroot.
|
||||||
|
postconf -M "smtps/inet=smtps inet n - n - - smtpd"
|
||||||
|
postconf -P \
|
||||||
|
"smtps/inet/smtpd_tls_wrappermode=yes" \
|
||||||
|
"smtps/inet/smtpd_sasl_auth_enable=yes" \
|
||||||
|
"smtps/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"
|
||||||
|
|
||||||
|
# submission (587, STARTTLS) — optional (spec 5 p.1). Same SASL/milter/limits;
|
||||||
|
# the only difference is TLS is negotiated via STARTTLS, so require encryption
|
||||||
|
# before auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a
|
||||||
|
# restart after disabling it does not leave the port listening.
|
||||||
|
if [ "${SUBMISSION_ENABLE}" = "true" ]; then
|
||||||
|
postconf -M "submission/inet=submission inet n - n - - smtpd"
|
||||||
|
postconf -P \
|
||||||
|
"submission/inet/smtpd_tls_security_level=encrypt" \
|
||||||
|
"submission/inet/smtpd_sasl_auth_enable=yes" \
|
||||||
|
"submission/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"
|
||||||
|
else
|
||||||
|
postconf -MX "submission/inet" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Disable chroot for every service (spec 5 p.2). Debian ships the smtp delivery
|
||||||
|
# agent and others chrooted to /var/spool/postfix, where they cannot read
|
||||||
|
# /etc/resolv.conf — so outbound MX lookups fail with "Host not found" and mail
|
||||||
|
# never leaves. Inside a container the chroot buys little (the container is the
|
||||||
|
# isolation boundary) and breaks DNS/TLS trust-store access, so turn it off
|
||||||
|
# uniformly. Our own smtps/submission services are already n; this covers the
|
||||||
|
# delivery agents and the rest.
|
||||||
|
postconf -F "*/*/chroot=n"
|
||||||
|
|
||||||
|
# --- Cyrus SASL app config for smtpd -----------------------------------------
|
||||||
|
# Tells the Cyrus library (invoked by smtpd via smtpd_sasl_path=smtpd) to verify
|
||||||
|
# passwords straight from the panel-maintained sasldb2 (spec 5.1). PLAIN/LOGIN
|
||||||
|
# only — both are safe because TLS is mandatory before auth on every port.
|
||||||
|
mkdir -p /etc/postfix/sasl
|
||||||
|
cat > /etc/postfix/sasl/smtpd.conf <<EOF
|
||||||
|
pwcheck_method: auxprop
|
||||||
|
auxprop_plugin: sasldb
|
||||||
|
sasldb_path: ${SASLDB_PATH}
|
||||||
|
mech_list: PLAIN LOGIN
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Validate the generated configuration; fail loudly if postconf produced
|
||||||
|
# anything Postfix rejects, before the wrapper tries to start it.
|
||||||
|
set +e
|
||||||
|
check_out=$(postfix check 2>&1)
|
||||||
|
ec=$?
|
||||||
|
set -e
|
||||||
|
if [ "$ec" -ne 0 ]; then
|
||||||
|
echo "postfix-config: postfix check failed exit $ec" >&2
|
||||||
|
printf '%s\n' "$check_out" >&2
|
||||||
|
exit "$ec"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Postfix start wrapper (spec 4): solves the cold-start race where Postfix would
|
||||||
|
# try to reach the milter sockets before they are listening.
|
||||||
|
#
|
||||||
|
# It blocks until BOTH milter sockets — OpenDKIM and the panel's journal-milter
|
||||||
|
# — are present, then execs `postfix start-fg`. If they are not ready within the
|
||||||
|
# timeout it exits non-zero WITHOUT starting Postfix, so supervisord/Docker see
|
||||||
|
# an explicit startup failure instead of a relay running blind.
|
||||||
|
#
|
||||||
|
# This handles cold start only. Runtime milter failures after a successful start
|
||||||
|
# are governed by fail-open (milter_default_action).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}"
|
||||||
|
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
|
||||||
|
TIMEOUT="${MILTER_WAIT_TIMEOUT:-30}"
|
||||||
|
INTERVAL=1
|
||||||
|
|
||||||
|
elapsed=0
|
||||||
|
for sock in "$OPENDKIM_SOCK" "$JOURNAL_SOCK"; do
|
||||||
|
while [ ! -S "$sock" ]; do
|
||||||
|
if [ "$elapsed" -ge "$TIMEOUT" ]; then
|
||||||
|
echo "postfix-wrapper: timed out after ${TIMEOUT}s waiting for milter socket $sock" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep "$INTERVAL"
|
||||||
|
elapsed=$((elapsed + INTERVAL))
|
||||||
|
done
|
||||||
|
echo "postfix-wrapper: milter socket ready: $sock"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "postfix-wrapper: both milter sockets ready, starting postfix"
|
||||||
|
exec postfix start-fg
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
; SelfPost process supervision (spec 4).
|
||||||
|
;
|
||||||
|
; Start ORDER is enforced by priority=: OpenDKIM, then the panel (which opens
|
||||||
|
; the journal-milter socket), then the Postfix wrapper — which additionally
|
||||||
|
; blocks until both milter sockets are ready before starting Postfix.
|
||||||
|
;
|
||||||
|
; If any managed process exhausts its restart retries (FATAL), the crashexit
|
||||||
|
; event listener brings the whole container down so Docker's restart policy can
|
||||||
|
; recreate it cleanly, rather than lingering with a dead component.
|
||||||
|
|
||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
user=root
|
||||||
|
logfile=/dev/null
|
||||||
|
logfile_maxbytes=0
|
||||||
|
pidfile=/run/supervisord.pid
|
||||||
|
loglevel=info
|
||||||
|
|
||||||
|
; The control socket is reachable by the shared `selfpost` group so the panel
|
||||||
|
; (running as the unprivileged `panel` user) can ask supervisord to send
|
||||||
|
; OpenDKIM a reload signal after it rewrites the DKIM tables (spec 6.2). It is
|
||||||
|
; still unreachable by world; only root and the two SelfPost services are in the
|
||||||
|
; group. No HTTP auth is configured because access is gated by socket
|
||||||
|
; permissions, not the network.
|
||||||
|
[unix_http_server]
|
||||||
|
file=/run/supervisor.sock
|
||||||
|
chown=root:selfpost
|
||||||
|
chmod=0770
|
||||||
|
|
||||||
|
[rpcinterface:supervisor]
|
||||||
|
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||||
|
|
||||||
|
[supervisorctl]
|
||||||
|
serverurl=unix:///run/supervisor.sock
|
||||||
|
|
||||||
|
[program:opendkim]
|
||||||
|
command=/usr/sbin/opendkim -f -x /etc/opendkim.conf
|
||||||
|
priority=100
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
startretries=3
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
[program:panel]
|
||||||
|
command=/usr/local/bin/panel
|
||||||
|
user=panel
|
||||||
|
priority=200
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
startretries=3
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
[program:postfix]
|
||||||
|
command=/usr/local/bin/postfix-wrapper.sh
|
||||||
|
priority=300
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
startretries=3
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
; One-shot reload helper (spec 5.2, 7.2.12). The panel runs unprivileged and
|
||||||
|
; cannot run `postfix reload` itself, and it cannot signal the Postfix master
|
||||||
|
; directly: `postfix start-fg` forks a separate master process, so a signal to
|
||||||
|
; the supervised foreground process never reaches it (unlike OpenDKIM, which
|
||||||
|
; runs in the foreground as the supervised process itself). Instead the panel
|
||||||
|
; asks supervisord — over the group-accessible control socket — to run this
|
||||||
|
; program, which executes the canonical `postfix reload` as root. autostart is
|
||||||
|
; off (it only runs on demand) and a fast, clean exit is expected, so it never
|
||||||
|
; trips the crashexit listener.
|
||||||
|
[program:postfix-reload]
|
||||||
|
command=/usr/sbin/postfix reload
|
||||||
|
autostart=false
|
||||||
|
autorestart=false
|
||||||
|
startsecs=0
|
||||||
|
startretries=1
|
||||||
|
exitcodes=0
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
; Periodic TLS-certificate refresh (spec 5.2 p.4). Runs a daily `postfix reload`
|
||||||
|
; so certificates the reverse-proxy renews in the read-only mount are picked up.
|
||||||
|
; Long-running (it loops), runs as root so it can reload Postfix, and never exits
|
||||||
|
; non-zero, so it neither trips the crashexit listener nor needs restarting.
|
||||||
|
[program:cert-reload]
|
||||||
|
command=/usr/local/bin/postfix-cert-reload.sh
|
||||||
|
priority=400
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
startsecs=0
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
; Periodic logrotate for /data/log/mail.log (spec 9, 10: daily, 7-14 files kept
|
||||||
|
; in the image). Runs as root so logrotate can read/rotate the log. Exits
|
||||||
|
; non-zero when the config is group/other-writable (logrotate would ignore it
|
||||||
|
; silently); autorestart surfaces BACKOFF on the Status page.
|
||||||
|
[program:logrotate]
|
||||||
|
command=/usr/local/bin/logrotate-loop.sh
|
||||||
|
priority=400
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
startsecs=0
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
[eventlistener:crashexit]
|
||||||
|
command=/usr/local/bin/crashexit.py
|
||||||
|
events=PROCESS_STATE_FATAL
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// documentedPublic matches docs/guide.md "Environment variables" table.
|
||||||
|
var documentedPublic = []string{
|
||||||
|
"SELFPOST_HOSTNAME",
|
||||||
|
"SUBMISSION_ENABLE",
|
||||||
|
"RATE_LIMIT_MESSAGES_PER_IP",
|
||||||
|
"RATE_LIMIT_WINDOW_SECONDS",
|
||||||
|
"SEND_LOG_RETENTION_DAYS",
|
||||||
|
"PANEL_SESSION_IDLE_DAYS",
|
||||||
|
"SELFPOST_DNS_RESOLVERS",
|
||||||
|
"TRUSTED_PROXY_CIDR",
|
||||||
|
}
|
||||||
|
|
||||||
|
// documentedInternal matches architecture.md § Configuration "Internal env vars".
|
||||||
|
var documentedInternal = []string{
|
||||||
|
"SELFPOST_DATA_DIR",
|
||||||
|
"SELFPOST_DB_PATH",
|
||||||
|
"SELFPOST_SETUP_TOKEN_FILE",
|
||||||
|
"PANEL_HTTP_ADDR",
|
||||||
|
"JOURNAL_MILTER_SOCKET",
|
||||||
|
"MAIL_LOG",
|
||||||
|
"PANEL_COOKIE_SECURE",
|
||||||
|
"OPENDKIM_SOCKET",
|
||||||
|
"OPENDKIM_DIR",
|
||||||
|
"DKIM_SELECTOR_DEFAULT",
|
||||||
|
"SASL_DB_PATH",
|
||||||
|
"SASL_REALM",
|
||||||
|
"POSTFIX_DIR",
|
||||||
|
"POSTFIX_SENDER_LOGIN_MAPS",
|
||||||
|
"POSTFIX_QUEUE_DIR",
|
||||||
|
"SELFPOST_DEPLOY_ROOT",
|
||||||
|
"MILTER_CONNECT_TIMEOUT",
|
||||||
|
"MILTER_COMMAND_TIMEOUT",
|
||||||
|
"MILTER_CONTENT_TIMEOUT",
|
||||||
|
"MILTER_WAIT_TIMEOUT",
|
||||||
|
"TLS_RELOAD_INTERVAL_SECONDS",
|
||||||
|
"LOGROTATE_INTERVAL_SECONDS",
|
||||||
|
}
|
||||||
|
|
||||||
|
// documentedComposeFixed are env keys documented outside the table (TLS paths fixed in compose).
|
||||||
|
var documentedComposeFixed = []string{
|
||||||
|
"TLS_CERT_FILE",
|
||||||
|
"TLS_KEY_FILE",
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadConfigKeys is every environment variable read by loadConfig() in main.go.
|
||||||
|
// Update together with loadConfig when adding a new key.
|
||||||
|
var loadConfigKeys = []string{
|
||||||
|
"SELFPOST_DATA_DIR",
|
||||||
|
"PANEL_HTTP_ADDR",
|
||||||
|
"JOURNAL_MILTER_SOCKET",
|
||||||
|
"MAIL_LOG",
|
||||||
|
"SEND_LOG_RETENTION_DAYS",
|
||||||
|
"SELFPOST_DB_PATH",
|
||||||
|
"SELFPOST_SETUP_TOKEN_FILE",
|
||||||
|
"SELFPOST_HOSTNAME",
|
||||||
|
"PANEL_COOKIE_SECURE",
|
||||||
|
"SUBMISSION_ENABLE",
|
||||||
|
"TRUSTED_PROXY_CIDR",
|
||||||
|
"PANEL_SESSION_IDLE_DAYS",
|
||||||
|
"SELFPOST_DNS_RESOLVERS",
|
||||||
|
"TLS_CERT_FILE",
|
||||||
|
"OPENDKIM_SOCKET",
|
||||||
|
"OPENDKIM_DIR",
|
||||||
|
"DKIM_SELECTOR_DEFAULT",
|
||||||
|
"SASL_DB_PATH",
|
||||||
|
"SASL_REALM",
|
||||||
|
"POSTFIX_DIR",
|
||||||
|
"SELFPOST_DEPLOY_ROOT",
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh
|
||||||
|
// but not necessarily in loadConfig. Update when startup scripts gain a new knob.
|
||||||
|
var buildScriptKeys = []string{
|
||||||
|
"SELFPOST_HOSTNAME",
|
||||||
|
"TLS_CERT_FILE",
|
||||||
|
"TLS_KEY_FILE",
|
||||||
|
"RATE_LIMIT_MESSAGES_PER_IP",
|
||||||
|
"RATE_LIMIT_WINDOW_SECONDS",
|
||||||
|
"OPENDKIM_SOCKET",
|
||||||
|
"JOURNAL_MILTER_SOCKET",
|
||||||
|
"MAIL_LOG",
|
||||||
|
"POSTFIX_SENDER_LOGIN_MAPS",
|
||||||
|
"POSTFIX_QUEUE_DIR",
|
||||||
|
"SASL_DB_PATH",
|
||||||
|
"SUBMISSION_ENABLE",
|
||||||
|
"MILTER_CONNECT_TIMEOUT",
|
||||||
|
"MILTER_COMMAND_TIMEOUT",
|
||||||
|
"MILTER_CONTENT_TIMEOUT",
|
||||||
|
"MILTER_WAIT_TIMEOUT",
|
||||||
|
"TLS_RELOAD_INTERVAL_SECONDS",
|
||||||
|
"LOGROTATE_INTERVAL_SECONDS",
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentedKeys() []string {
|
||||||
|
keys := append([]string{}, documentedPublic...)
|
||||||
|
keys = append(keys, documentedInternal...)
|
||||||
|
keys = append(keys, documentedComposeFixed...)
|
||||||
|
slices.Sort(keys)
|
||||||
|
return slices.Compact(keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfigKeysDocumented(t *testing.T) {
|
||||||
|
doc := documentedKeys()
|
||||||
|
for _, key := range loadConfigKeys {
|
||||||
|
if !slices.Contains(doc, key) {
|
||||||
|
t.Errorf("loadConfig reads %s but it is not listed in guide.md public, internal, or compose-fixed env docs", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildScriptKeysDocumented(t *testing.T) {
|
||||||
|
doc := documentedKeys()
|
||||||
|
for _, key := range buildScriptKeys {
|
||||||
|
if !slices.Contains(doc, key) {
|
||||||
|
t.Errorf("build scripts read %s but it is not listed in guide.md env documentation", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocumentedKeysAreRead(t *testing.T) {
|
||||||
|
read := append([]string{}, loadConfigKeys...)
|
||||||
|
read = append(read, buildScriptKeys...)
|
||||||
|
slices.Sort(read)
|
||||||
|
read = slices.Compact(read)
|
||||||
|
|
||||||
|
for _, key := range documentedKeys() {
|
||||||
|
if !slices.Contains(read, key) {
|
||||||
|
t.Errorf("guide.md documents %s but no code in loadConfig or build scripts reads it", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/app"
|
||||||
|
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||||
|
"github.com/mixeme/selfpost/internal/domain"
|
||||||
|
"github.com/mixeme/selfpost/internal/postfix"
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
"github.com/mixeme/selfpost/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mailStack is the panel's domain and application services plus the on-disk
|
||||||
|
// mail-path adapters they write through.
|
||||||
|
type mailStack struct {
|
||||||
|
Domains *domain.Service
|
||||||
|
Apps *app.Service
|
||||||
|
pf *postfix.Postfix
|
||||||
|
odk *domain.OpenDKIM
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMailStack(cfg config, st *store.Store) *mailStack {
|
||||||
|
pf := postfix.New(cfg.postfixDir)
|
||||||
|
odk := domain.NewOpenDKIM(cfg.opendkimDir)
|
||||||
|
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
|
||||||
|
domains := domain.NewService(st, odk, apps, cfg.dkimSelectorDef)
|
||||||
|
return &mailStack{Domains: domains, Apps: apps, pf: pf, odk: odk}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resync rebuilds OpenDKIM's tables and Postfix's sender map from SQLite and
|
||||||
|
// reloads both daemons — the same work as the Status page's Reload button.
|
||||||
|
func (m *mailStack) Resync() error {
|
||||||
|
if err := m.Domains.Resync(); err != nil {
|
||||||
|
return fmt.Errorf("opendkim resync: %w", err)
|
||||||
|
}
|
||||||
|
if err := m.Apps.Resync(); err != nil {
|
||||||
|
return fmt.Errorf("postfix resync: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mailStack) skipReloadForTest() {
|
||||||
|
m.pf.SetReloadHook(func() error { return nil })
|
||||||
|
m.odk.SetReloadHook(func() error { return nil })
|
||||||
|
}
|
||||||
|
|
||||||
|
// resyncAfterRestore runs one mail-path Resync on the first boot after a
|
||||||
|
// backup restore. testNoReload skips the supervisord reload step so restore
|
||||||
|
// tests can verify file regeneration without a running mail stack.
|
||||||
|
func resyncAfterRestore(cfg config, st *store.Store, testNoReload bool) error {
|
||||||
|
ms := newMailStack(cfg, st)
|
||||||
|
if testNoReload {
|
||||||
|
ms.skipReloadForTest()
|
||||||
|
}
|
||||||
|
return ms.Resync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPanel wires the panel's services over the shared database handle and
|
||||||
|
// builds the HTTP application from cfg. It is the composition of the panel as
|
||||||
|
// the environment describes it, with nothing bound to a port yet.
|
||||||
|
func newPanel(cfg config, st *store.Store) (*web.Server, error) {
|
||||||
|
ms := newMailStack(cfg, st)
|
||||||
|
return web.New(st, ms.Domains, ms.Apps, web.Config{
|
||||||
|
Hostname: cfg.hostname,
|
||||||
|
CookieSecure: cfg.cookieSecure,
|
||||||
|
SubmissionEnabled: cfg.submissionEnabled,
|
||||||
|
MailLogPath: cfg.mailLog,
|
||||||
|
DataDir: cfg.dataDir,
|
||||||
|
DBPath: cfg.dbPath,
|
||||||
|
DeployRoot: cfg.deployRoot,
|
||||||
|
Version: buildinfo.Version,
|
||||||
|
TrustedProxyCIDRs: cfg.trustedProxies,
|
||||||
|
TLSCertFile: cfg.tlsCertFile,
|
||||||
|
OpenDKIMSocket: cfg.opendkimSocket,
|
||||||
|
JournalSocket: cfg.journalSocket,
|
||||||
|
SessionIdleDays: cfg.sessionIdleDays,
|
||||||
|
DNSResolvers: cfg.dnsResolvers,
|
||||||
|
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
|
||||||
|
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
|
||||||
|
}, cfg.setupTokenPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveHTTP runs the control-panel HTTP server until ctx is cancelled, using
|
||||||
|
// the database handle shared by all roles: setup, login and the authenticated
|
||||||
|
// panel surface (security.md).
|
||||||
|
func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
|
||||||
|
srvApp, err := newPanel(cfg, st)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := srvApp.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: cfg.httpAddr,
|
||||||
|
Handler: srvApp.Handler(),
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shut the server down cleanly when the process is asked to stop.
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = srv.Shutdown(shutdownCtx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Printf("http panel listening on %s", cfg.httpAddr)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/milter"
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serveJournal opens the journal-milter Unix socket and runs the real milter
|
||||||
|
// (architecture.md § Mail path), recording accepted messages into the send
|
||||||
|
// log. Socket lifecycle (creation, stale cleanup, group permissions) lives
|
||||||
|
// here; the protocol handler lives in internal/milter.
|
||||||
|
func serveJournal(ctx context.Context, cfg config, st *store.Store) error {
|
||||||
|
socketPath := cfg.journalSocket
|
||||||
|
if err := os.MkdirAll(filepath.Dir(socketPath), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Clear a stale socket left behind by an unclean shutdown, otherwise the
|
||||||
|
// listen below fails with "address already in use".
|
||||||
|
if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ln, err := net.Listen("unix", socketPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Postfix (user `postfix`) connects to this socket as a milter and needs
|
||||||
|
// group access. The socket inherits group `selfpost` from the setgid parent
|
||||||
|
// dir entrypoint.sh prepares; make it group read/write so postfix can reach
|
||||||
|
// it (connecting to a Unix socket needs write permission on the node).
|
||||||
|
if err := os.Chmod(socketPath, 0o660); err != nil {
|
||||||
|
ln.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("journal-milter listening on %s", socketPath)
|
||||||
|
return milter.Serve(ctx, ln, st)
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
// Command panel is the SelfPost control panel. This single binary combines
|
||||||
|
// several roles (architecture.md § Image and processes) as a supervised
|
||||||
|
// process: the HTTP panel server, the journal-milter, the mail.log tailer and
|
||||||
|
// the rate-limit checks.
|
||||||
|
//
|
||||||
|
// Copyright (C) 2026 Mikhail Yenuchenko
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/backup"
|
||||||
|
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||||
|
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||||
|
"github.com/mixeme/selfpost/internal/logtail"
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
showVersion := flag.Bool("version", false, "print version and exit")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *showVersion {
|
||||||
|
fmt.Println(buildinfo.Version)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.SetFlags(log.LstdFlags | log.LUTC)
|
||||||
|
log.SetPrefix("panel: ")
|
||||||
|
|
||||||
|
if err := run(); err != nil {
|
||||||
|
log.Fatalf("fatal: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// config holds the runtime knobs the panel reads from the environment. Every
|
||||||
|
// value has a safe default so the binary also runs outside the container.
|
||||||
|
type config struct {
|
||||||
|
httpAddr string
|
||||||
|
journalSocket string
|
||||||
|
mailLog string
|
||||||
|
retentionDays int
|
||||||
|
|
||||||
|
dataDir string
|
||||||
|
dbPath string
|
||||||
|
manifestPath string
|
||||||
|
setupTokenPath string
|
||||||
|
hostname string
|
||||||
|
cookieSecure bool
|
||||||
|
submissionEnabled bool
|
||||||
|
trustedProxies []*net.IPNet
|
||||||
|
sessionIdleDays int
|
||||||
|
dnsResolvers []string
|
||||||
|
rateLimitMessagesPerIP int
|
||||||
|
rateLimitWindowSeconds int
|
||||||
|
|
||||||
|
// Read-only inputs to the panel's status page: the certificate Postfix
|
||||||
|
// serves and the two milter sockets it connects to. The defaults mirror
|
||||||
|
// build/postfix-config.sh, so the status page checks exactly what Postfix
|
||||||
|
// was configured with.
|
||||||
|
tlsCertFile string
|
||||||
|
opendkimSocket string
|
||||||
|
|
||||||
|
opendkimDir string
|
||||||
|
dkimSelectorDef string
|
||||||
|
|
||||||
|
saslDBPath string
|
||||||
|
saslRealm string
|
||||||
|
postfixDir string
|
||||||
|
deployRoot string
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() config {
|
||||||
|
dataDir := envDefault("SELFPOST_DATA_DIR", "/data")
|
||||||
|
return config{
|
||||||
|
httpAddr: envDefault("PANEL_HTTP_ADDR", ":8080"),
|
||||||
|
journalSocket: envDefault("JOURNAL_MILTER_SOCKET", "/run/selfpost/journal.sock"),
|
||||||
|
// Postfix's delivery log, under /data so the lines that resolve a
|
||||||
|
// "queued" send-log row outlive the container. The default must match
|
||||||
|
// maillog_file in build/postfix-config.sh.
|
||||||
|
mailLog: envDefault("MAIL_LOG", "/data/log/mail.log"),
|
||||||
|
// Send-log retention window (architecture.md § Persistence).
|
||||||
|
// Non-positive/invalid falls back to the 90-day default inside the
|
||||||
|
// log-tailer.
|
||||||
|
retentionDays: envInt("SEND_LOG_RETENTION_DAYS", 90),
|
||||||
|
|
||||||
|
dataDir: dataDir,
|
||||||
|
dbPath: envDefault("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db")),
|
||||||
|
manifestPath: filepath.Join(dataDir, backup.ManifestName),
|
||||||
|
setupTokenPath: envDefault("SELFPOST_SETUP_TOKEN_FILE", filepath.Join(dataDir, "setup-token")),
|
||||||
|
hostname: os.Getenv("SELFPOST_HOSTNAME"),
|
||||||
|
// Secure cookies by default (security.md); PANEL_COOKIE_SECURE=false is a
|
||||||
|
// development-only escape hatch for testing over plain HTTP.
|
||||||
|
cookieSecure: envDefault("PANEL_COOKIE_SECURE", "true") != "false",
|
||||||
|
// Whether this deployment also runs the 587 submission listener. The
|
||||||
|
// panel only displays it as a client connection setting; the comparison
|
||||||
|
// matches postfix-config.sh, which enables the listener on "true" alone.
|
||||||
|
submissionEnabled: os.Getenv("SUBMISSION_ENABLE") == "true",
|
||||||
|
// Reverse-proxy addresses allowed to supply X-Forwarded-For for
|
||||||
|
// rate-limiting. Empty by default: an untrusted peer's
|
||||||
|
// XFF header is trivially forgeable, so it's ignored unless the panel is
|
||||||
|
// told which proxy to trust.
|
||||||
|
trustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDR")),
|
||||||
|
// Sliding session idle timeout (security.md, plan B.1). Non-positive/invalid
|
||||||
|
// falls back to the 7-day default inside internal/web.
|
||||||
|
sessionIdleDays: envInt("PANEL_SESSION_IDLE_DAYS", 7),
|
||||||
|
// Recursive resolvers the deliverability checks query directly. Empty
|
||||||
|
// means dnscheck's public defaults; a closed network names its own here.
|
||||||
|
dnsResolvers: dnscheck.ParseResolvers(os.Getenv("SELFPOST_DNS_RESOLVERS")),
|
||||||
|
|
||||||
|
// Level-1 anvil defaults match build/postfix-config.sh / guide.md.
|
||||||
|
rateLimitMessagesPerIP: envInt("RATE_LIMIT_MESSAGES_PER_IP", 100),
|
||||||
|
rateLimitWindowSeconds: envInt("RATE_LIMIT_WINDOW_SECONDS", 3600),
|
||||||
|
|
||||||
|
tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
|
||||||
|
opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
|
||||||
|
|
||||||
|
// Per-domain DKIM state (architecture.md § OpenDKIM). The directory layout
|
||||||
|
// matches what entrypoint.sh prepares (setgid, shared `selfpost` group).
|
||||||
|
opendkimDir: envDefault("OPENDKIM_DIR", filepath.Join(dataDir, "opendkim")),
|
||||||
|
dkimSelectorDef: envDefault("DKIM_SELECTOR_DEFAULT", "selfpost"),
|
||||||
|
|
||||||
|
// Application SASL accounts and the Postfix sender map (architecture.md §
|
||||||
|
// Mail path), both under /data so they survive restarts. The SASL realm
|
||||||
|
// defaults to the server hostname so account identities line up with
|
||||||
|
// Postfix's SASL configuration; it falls back to localhost outside the
|
||||||
|
// container.
|
||||||
|
saslDBPath: envDefault("SASL_DB_PATH", filepath.Join(dataDir, "sasl", "sasldb2")),
|
||||||
|
saslRealm: saslRealm(),
|
||||||
|
postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")),
|
||||||
|
deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// saslRealm chooses the realm new SASL accounts live under. It mirrors the
|
||||||
|
// hostname Postfix's SASL layer uses so a client authenticating with a bare
|
||||||
|
// login resolves to the right account.
|
||||||
|
func saslRealm() string {
|
||||||
|
if r := os.Getenv("SASL_REALM"); r != "" {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
if h := os.Getenv("SELFPOST_HOSTNAME"); h != "" {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
return "localhost"
|
||||||
|
}
|
||||||
|
|
||||||
|
func envDefault(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
// envInt reads an integer environment variable, returning def if it is unset or
|
||||||
|
// not a valid integer.
|
||||||
|
func envInt(key string, def int) int {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
log.Printf("ignoring invalid %s=%q, using %d", key, v, def)
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTrustedProxies parses a comma-separated list of CIDRs (bare IPs are
|
||||||
|
// accepted and treated as /32 or /128). Invalid entries are logged and
|
||||||
|
// skipped rather than failing startup, matching envInt's tolerance of
|
||||||
|
// misconfiguration.
|
||||||
|
func parseTrustedProxies(raw string) []*net.IPNet {
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var nets []*net.IPNet
|
||||||
|
for _, part := range strings.Split(raw, ",") {
|
||||||
|
cidr := strings.TrimSpace(part)
|
||||||
|
if cidr == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(cidr, "/") {
|
||||||
|
if ip := net.ParseIP(cidr); ip != nil && ip.To4() != nil {
|
||||||
|
cidr += "/32"
|
||||||
|
} else {
|
||||||
|
cidr += "/128"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, n, err := net.ParseCIDR(cidr)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ignoring invalid TRUSTED_PROXY_CIDR entry %q: %v", part, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nets = append(nets, n)
|
||||||
|
}
|
||||||
|
return nets
|
||||||
|
}
|
||||||
|
|
||||||
|
// run starts the panel's three roles and blocks until a shutdown signal or the
|
||||||
|
// first fatal error from any role. A signal triggers a clean stop of all
|
||||||
|
// roles; a role error cancels the others and is returned so the process exits
|
||||||
|
// non-zero (letting supervisord/Docker see the failure — architecture.md §
|
||||||
|
// Image and processes).
|
||||||
|
func run() error {
|
||||||
|
cfg := loadConfig()
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
log.Printf("starting selfpost panel %s", buildinfo.Version)
|
||||||
|
|
||||||
|
// Restore version guard (architecture.md § Persistence): if a backup was
|
||||||
|
// extracted into /data, its manifest version must match this binary before we
|
||||||
|
// touch the database, so schema/format skew between versions cannot corrupt
|
||||||
|
// the restored state. A match consumes the manifest; its absence is the
|
||||||
|
// normal (non-restore) case.
|
||||||
|
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// One database handle shared by every role. The store serialises writes
|
||||||
|
// (MaxOpenConns(1)), so the HTTP panel, the journal-milter and the tailer
|
||||||
|
// can all use it without stepping on each other under WAL.
|
||||||
|
st, err := store.Open(cfg.dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
if restored {
|
||||||
|
log.Printf("restore manifest accepted; regenerating mail-path maps from SQLite")
|
||||||
|
if err := resyncAfterRestore(cfg, st, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errc := make(chan error, 3)
|
||||||
|
|
||||||
|
roles := []struct {
|
||||||
|
name string
|
||||||
|
fn func(context.Context) error
|
||||||
|
}{
|
||||||
|
{"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg, st) }},
|
||||||
|
{"journal-milter", func(ctx context.Context) error { return serveJournal(ctx, cfg, st) }},
|
||||||
|
{"log-tailer", func(ctx context.Context) error { return logtail.Run(ctx, cfg.mailLog, st, cfg.retentionDays) }},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range roles {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(name string, fn func(context.Context) error) {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := fn(ctx); err != nil {
|
||||||
|
errc <- fmt.Errorf("%s: %w", name, err)
|
||||||
|
}
|
||||||
|
}(r.name, r.fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Printf("shutdown signal received, stopping")
|
||||||
|
wg.Wait()
|
||||||
|
log.Printf("panel stopped cleanly")
|
||||||
|
return nil
|
||||||
|
case err := <-errc:
|
||||||
|
log.Printf("role failed: %v", err)
|
||||||
|
stop() // cancel ctx so the other roles wind down
|
||||||
|
wg.Wait()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/backup"
|
||||||
|
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||||
|
"github.com/mixeme/selfpost/internal/secretfile"
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// panel is expected to come up on it (architecture.md § Persistence). Nothing
|
||||||
|
// below stubs that story out — the archive is downloaded from a running panel
|
||||||
|
// through /backup, unpacked the way `tar -xzf` unpacks it, and a second panel
|
||||||
|
// is started on the result through the same startup sequence run() uses:
|
||||||
|
// CheckRestore, store.Open, newPanel, Start.
|
||||||
|
|
||||||
|
const (
|
||||||
|
restorePassword = "correct-horse-battery"
|
||||||
|
restoreDomain = "bs.example.ru"
|
||||||
|
restoreSubject = "Order confirmation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// restored is the outcome of a full backup-and-restore round trip.
|
||||||
|
type restored struct {
|
||||||
|
panel http.Handler // panel booted on the restored data directory
|
||||||
|
deployRoot string // the restored project directory
|
||||||
|
dataDir string // the restored /data
|
||||||
|
session *http.Cookie // a session opened before the backup was taken
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreFromOwnBackup runs the operator's path end to end: seed a panel that
|
||||||
|
// has been in use, sign in, download a backup from it, extract that archive
|
||||||
|
// into an empty directory and boot a second panel there. A non-empty password
|
||||||
|
// takes the encrypted download and decrypts it on the way in, which is what an
|
||||||
|
// operator does with a .spbk file.
|
||||||
|
func restoreFromOwnBackup(t *testing.T, password string) restored {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
live := seedPanelProject(t)
|
||||||
|
panel := bootPanel(t, live)
|
||||||
|
session := signIn(t, panel)
|
||||||
|
archive := downloadBackup(t, panel, session, password)
|
||||||
|
|
||||||
|
target := t.TempDir()
|
||||||
|
extract(t, archive, target)
|
||||||
|
|
||||||
|
return restored{
|
||||||
|
panel: bootPanel(t, target),
|
||||||
|
deployRoot: target,
|
||||||
|
dataDir: filepath.Join(target, "data"),
|
||||||
|
session: session,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The panel has to come up on the restored directory and show the state that
|
||||||
|
// was in the archive, without the operator touching anything else: the domain
|
||||||
|
// and its journal are in the database the archive carried, and the credentials
|
||||||
|
// that worked before the restore still work after it.
|
||||||
|
func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
|
||||||
|
r := restoreFromOwnBackup(t, "")
|
||||||
|
|
||||||
|
body := getPage(t, r.panel, "/deliveries", signIn(t, r.panel))
|
||||||
|
for _, want := range []string{restoreDomain, restoreSubject} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("the restored panel's send log does not show %q:\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The archive carries the daemon files, and the first boot after restore
|
||||||
|
// re-derives the maps from SQLite so they stay aligned with the database.
|
||||||
|
for path, want := range map[string]string{
|
||||||
|
filepath.Join("opendkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
|
||||||
|
filepath.Join("sasl", "sasldb2"): "SASLDB",
|
||||||
|
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
|
||||||
|
} {
|
||||||
|
got, err := os.ReadFile(filepath.Join(r.dataDir, path))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("the restored data directory has no %s: %v", path, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if string(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
|
||||||
|
// restored database has one. A restore that reopened it would publish a link
|
||||||
|
// that creates a second global administrator on a server holding live mail
|
||||||
|
// credentials (security.md).
|
||||||
|
func TestARestoreDoesNotReopenTheSetupLink(t *testing.T) {
|
||||||
|
r := restoreFromOwnBackup(t, "")
|
||||||
|
|
||||||
|
if _, err := os.Stat(filepath.Join(r.dataDir, "setup-token")); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("the restored panel wrote a setup token (stat err = %v)", err)
|
||||||
|
}
|
||||||
|
body := getPage(t, r.panel, "/login", nil)
|
||||||
|
if strings.Contains(body, "No administrator has been created yet") {
|
||||||
|
t.Errorf("the restored panel offers first-run setup:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sessions live in the database, so they travel in the archive: a cookie that
|
||||||
|
// was valid when the backup was taken is valid again on the restored panel.
|
||||||
|
// That is the documented consequence of restoring an older backup (guide §
|
||||||
|
// Backup and restore) — stated here so it cannot change by accident.
|
||||||
|
func TestARestoredPanelHonoursSessionsFromTheArchive(t *testing.T) {
|
||||||
|
r := restoreFromOwnBackup(t, "")
|
||||||
|
|
||||||
|
rec := request(t, r.panel, http.MethodGet, "/deliveries", nil, r.session)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("a session from before the backup = %d on the restored panel, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An encrypted download is the same archive inside an envelope, so it restores
|
||||||
|
// the same way once the password is supplied. The archive is never written to
|
||||||
|
// disk in the clear by the panel, so this is the only place the two paths can
|
||||||
|
// be shown to agree.
|
||||||
|
func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) {
|
||||||
|
r := restoreFromOwnBackup(t, "a-long-enough-password")
|
||||||
|
|
||||||
|
body := getPage(t, r.panel, "/deliveries", signIn(t, r.panel))
|
||||||
|
if !strings.Contains(body, restoreSubject) {
|
||||||
|
t.Errorf("the panel restored from an encrypted backup lost the send log:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A restore boot runs one Resync from SQLite. If the archive's Postfix map
|
||||||
|
// drifted from the database, that step puts it back before mail flows.
|
||||||
|
func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) {
|
||||||
|
deployRoot := seedPanelProject(t)
|
||||||
|
dataDir := filepath.Join(deployRoot, "data")
|
||||||
|
cfg := panelConfig(t, deployRoot)
|
||||||
|
|
||||||
|
mapPath := filepath.Join(dataDir, "postfix", "sender_login_maps")
|
||||||
|
if err := os.WriteFile(mapPath, []byte("stale map\n"), 0o640); err != nil {
|
||||||
|
t.Fatalf("write stale map: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, err := json.Marshal(backup.Manifest{
|
||||||
|
Format: backup.FormatFull,
|
||||||
|
Version: buildinfo.Version,
|
||||||
|
CreatedAt: "2026-08-14T00:00:00Z",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal manifest: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(cfg.manifestPath, manifest, 0o644); err != nil {
|
||||||
|
t.Fatalf("write manifest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckRestore: %v", err)
|
||||||
|
}
|
||||||
|
if !restored {
|
||||||
|
t.Fatal("CheckRestore did not report a restore")
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := store.Open(cfg.dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
if err := resyncAfterRestore(cfg, st, true); err != nil {
|
||||||
|
t.Fatalf("resync after restore: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := os.ReadFile(mapPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read sender map: %v", err)
|
||||||
|
}
|
||||||
|
want := "@" + restoreDomain + " shop\n"
|
||||||
|
if string(got) != want {
|
||||||
|
t.Errorf("sender map = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The version guard is what stops a restore from being silently corrupted by
|
||||||
|
// schema skew, and it runs before anything opens the database. The manifest
|
||||||
|
// stays put on a mismatch: the operator's next move is to start the image the
|
||||||
|
// backup names, and it has to be there when they do.
|
||||||
|
func TestPanelRefusesADataDirectoryRestoredFromAnotherVersion(t *testing.T) {
|
||||||
|
deployRoot := seedPanelProject(t)
|
||||||
|
dataDir := filepath.Join(deployRoot, "data")
|
||||||
|
var archive bytes.Buffer
|
||||||
|
if err := backup.Create(&archive, backup.Params{
|
||||||
|
DataDir: dataDir,
|
||||||
|
DBPath: filepath.Join(dataDir, "selfpost.db"),
|
||||||
|
Version: "9.9.9",
|
||||||
|
DeployRoot: deployRoot,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create backup: %v", err)
|
||||||
|
}
|
||||||
|
target := t.TempDir()
|
||||||
|
extract(t, archive.Bytes(), target)
|
||||||
|
|
||||||
|
cfg := panelConfig(t, target)
|
||||||
|
_, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("the panel booted on a data directory left by another version")
|
||||||
|
}
|
||||||
|
for _, want := range []string{"9.9.9", buildinfo.Version} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("the refusal does not name %q, so the operator cannot tell which image to run: %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(cfg.manifestPath); statErr != nil {
|
||||||
|
t.Errorf("the manifest was consumed by a refused restore: %v", statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedPanelProject builds an operator project tree: data/ with a panel that has
|
||||||
|
// been in use, plus docker-compose.yml, .env, and certs/ for full backups.
|
||||||
|
func seedPanelProject(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
deployRoot := t.TempDir()
|
||||||
|
dataDir := filepath.Join(deployRoot, "data")
|
||||||
|
if err := os.MkdirAll(dataDir, 0o750); err != nil {
|
||||||
|
t.Fatalf("mkdir data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(restorePassword), bcrypt.MinCost)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hash password: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.CreateGlobalUser("admin", string(hash)); err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
dom, err := st.AddDomain(restoreDomain, "selfpost")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add domain: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.AddApplication(dom.ID, "shop", store.AddressModeWildcard, nil); err != nil {
|
||||||
|
t.Fatalf("add application: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.InsertQueued(store.SendLogEntry{
|
||||||
|
QueueID: "4A1B2C3D", Domain: restoreDomain, AppLogin: "shop",
|
||||||
|
From: "noreply@" + restoreDomain, To: "customer@example.net", Subject: restoreSubject,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("insert send-log row: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.Close(); err != nil {
|
||||||
|
t.Fatalf("close store: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for path, content := range map[string]string{
|
||||||
|
filepath.Join("opendkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
|
||||||
|
filepath.Join("sasl", "sasldb2"): "SASLDB",
|
||||||
|
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
|
||||||
|
filepath.Join("log", "mail.log"): "postfix/smtp[1]: 4A1B2C3D: status=sent",
|
||||||
|
} {
|
||||||
|
full := filepath.Join(dataDir, path)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", full, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(full, []byte(content), 0o640); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", full, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// panelConfig resolves the panel's own configuration for a project directory, so
|
||||||
|
// the test finds the files where the running binary would look for them rather
|
||||||
|
// than where it put them. Cookies are marked insecure for the same reason the
|
||||||
|
// e2e stand does it: the test client speaks plain HTTP.
|
||||||
|
func panelConfig(t *testing.T, deployRoot string) config {
|
||||||
|
t.Helper()
|
||||||
|
dataDir := filepath.Join(deployRoot, "data")
|
||||||
|
t.Setenv("SELFPOST_DATA_DIR", dataDir)
|
||||||
|
t.Setenv("SELFPOST_DEPLOY_ROOT", deployRoot)
|
||||||
|
t.Setenv("PANEL_COOKIE_SECURE", "false")
|
||||||
|
t.Setenv("SELFPOST_HOSTNAME", "mail.example.ru")
|
||||||
|
// MAIL_LOG's default is an absolute path, not one derived from the data
|
||||||
|
// directory; without this the panel would read the host's /data.
|
||||||
|
t.Setenv("MAIL_LOG", filepath.Join(dataDir, "log", "mail.log"))
|
||||||
|
return loadConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
// bootPanel performs the startup sequence run() performs, in the same order,
|
||||||
|
// and returns the panel's HTTP handler.
|
||||||
|
func bootPanel(t *testing.T, deployRoot string) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
cfg := panelConfig(t, deployRoot)
|
||||||
|
|
||||||
|
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the panel refused to start on %s: %v", deployRoot, err)
|
||||||
|
}
|
||||||
|
if restored {
|
||||||
|
if _, err := os.Stat(cfg.manifestPath); err == nil {
|
||||||
|
t.Errorf("the restore manifest was not consumed, so the next start is gated by it too")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := store.Open(cfg.dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open the restored database: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = st.Close() })
|
||||||
|
|
||||||
|
if restored {
|
||||||
|
if err := resyncAfterRestore(cfg, st, true); err != nil {
|
||||||
|
t.Fatalf("resync after restore: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
panel, err := newPanel(cfg, st)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build the panel: %v", err)
|
||||||
|
}
|
||||||
|
if err := panel.Start(); err != nil {
|
||||||
|
t.Fatalf("start the panel: %v", err)
|
||||||
|
}
|
||||||
|
return panel.Handler()
|
||||||
|
}
|
||||||
|
|
||||||
|
// signIn signs in as the seeded administrator and returns the session cookie.
|
||||||
|
func signIn(t *testing.T, h http.Handler) *http.Cookie {
|
||||||
|
t.Helper()
|
||||||
|
form := url.Values{"username": {"admin"}, "password": {restorePassword}}
|
||||||
|
rec := request(t, h, http.MethodPost, "/login", strings.NewReader(form.Encode()), nil)
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("sign in = %d, want 303:\n%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
cookies := rec.Result().Cookies()
|
||||||
|
if len(cookies) == 0 {
|
||||||
|
t.Fatal("sign in issued no session cookie")
|
||||||
|
}
|
||||||
|
return cookies[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadBackup takes a backup through the panel's own /backup route, the way
|
||||||
|
// the operator does. An empty password downloads the plain archive; otherwise
|
||||||
|
// the response is a .spbk envelope, which is decrypted here.
|
||||||
|
func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
form := url.Values{}
|
||||||
|
if password != "" {
|
||||||
|
form.Set("encrypt", "1")
|
||||||
|
form.Set("password", password)
|
||||||
|
form.Set("password_confirm", password)
|
||||||
|
}
|
||||||
|
rec := request(t, h, http.MethodPost, "/backup", strings.NewReader(form.Encode()), session)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("download a backup = %d, want 200:\n%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
|
||||||
|
t.Errorf("Cache-Control = %q; an archive of every secret on the server must not be cached", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := rec.Body.Bytes()
|
||||||
|
if password == "" {
|
||||||
|
if secretfile.HasMagic(body) {
|
||||||
|
t.Fatal("an unencrypted download came back as an envelope")
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
if !secretfile.HasMagic(body) {
|
||||||
|
t.Fatal("the download is not an encrypted envelope, so the archive left the panel in the clear")
|
||||||
|
}
|
||||||
|
r, err := secretfile.NewReader(bytes.NewReader(body), password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open the encrypted backup: %v", err)
|
||||||
|
}
|
||||||
|
plain, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decrypt the backup: %v", err)
|
||||||
|
}
|
||||||
|
return plain
|
||||||
|
}
|
||||||
|
|
||||||
|
// extract unpacks a backup archive into dir, as `tar -xzf` does onto the project
|
||||||
|
// directory before the image is started.
|
||||||
|
func extract(t *testing.T, archive []byte, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
gz, err := gzip.NewReader(bytes.NewReader(archive))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the download is not a gzip stream: %v", err)
|
||||||
|
}
|
||||||
|
tr := tar.NewReader(gz)
|
||||||
|
for {
|
||||||
|
hdr, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read the archive: %v", err)
|
||||||
|
}
|
||||||
|
name := filepath.Clean(filepath.FromSlash(hdr.Name))
|
||||||
|
if strings.HasPrefix(name, "..") || filepath.IsAbs(name) {
|
||||||
|
t.Fatalf("the archive escapes the directory it is extracted into: %q", hdr.Name)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
|
||||||
|
switch hdr.Typeflag {
|
||||||
|
case tar.TypeDir:
|
||||||
|
if err := os.MkdirAll(path, hdr.FileInfo().Mode().Perm()); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", path, err)
|
||||||
|
}
|
||||||
|
case tar.TypeReg:
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, hdr.FileInfo().Mode().Perm())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(f, tr); err != nil {
|
||||||
|
f.Close()
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
t.Fatalf("close %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPage performs a GET and returns the body, failing on any non-200.
|
||||||
|
func getPage(t *testing.T, h http.Handler, target string, session *http.Cookie) string {
|
||||||
|
t.Helper()
|
||||||
|
rec := request(t, h, http.MethodGet, target, nil, session)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET %s = %d, want 200:\n%s", target, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
return rec.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// request drives the panel's real handler chain, including the origin check,
|
||||||
|
// with the headers a browser on the panel's own page would send.
|
||||||
|
func request(t *testing.T, h http.Handler, method, target string, body io.Reader, session *http.Cookie) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(method, "http://mail.example.ru"+target, body)
|
||||||
|
req.Host = "mail.example.ru"
|
||||||
|
if method == http.MethodPost {
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||||
|
}
|
||||||
|
if session != nil {
|
||||||
|
req.AddCookie(session)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
// Command selfpost-backup produces the full persistent-state archive from inside
|
||||||
|
// the container, invoked via `docker exec` for scripted/cron backups — the CLI
|
||||||
|
// equivalent of the panel's backup button (architecture.md § Persistence).
|
||||||
|
//
|
||||||
|
// By default the gzip-compressed tar is written to stdout, so the usual form is:
|
||||||
|
//
|
||||||
|
// docker exec <container> selfpost-backup > selfpost-backup.tar.gz
|
||||||
|
//
|
||||||
|
// Use -o to write to a file instead. The resulting archive is self-contained:
|
||||||
|
// data/, docker-compose.yml, .env, and certs/ when present — treat it as a
|
||||||
|
// secret (architecture.md § Persistence).
|
||||||
|
//
|
||||||
|
// Given a password (SELFPOST_BACKUP_PASSWORD or -password-file, never an
|
||||||
|
// argument, which would show up in the process list) the archive is written as
|
||||||
|
// an encrypted .spbk envelope instead. Turn one back into a plain .tar.gz with
|
||||||
|
// the same password:
|
||||||
|
//
|
||||||
|
// docker exec -i <container> selfpost-backup -decrypt < backup.spbk > backup.tar.gz
|
||||||
|
//
|
||||||
|
// Copyright (C) 2026 Mikhail Yenuchenko
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/backup"
|
||||||
|
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||||
|
"github.com/mixeme/selfpost/internal/secretfile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// passwordEnv names the environment variable holding the encryption password.
|
||||||
|
// A password must never be a command-line argument: the process list is
|
||||||
|
// readable by every process in the container.
|
||||||
|
const passwordEnv = "SELFPOST_BACKUP_PASSWORD"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
showVersion := flag.Bool("version", false, "print version and exit")
|
||||||
|
out := flag.String("o", "", "write the output to this file instead of stdout")
|
||||||
|
in := flag.String("i", "", "read the encrypted archive from this file instead of stdin (-decrypt only)")
|
||||||
|
decrypt := flag.Bool("decrypt", false, "decrypt an encrypted backup (.spbk) back to a plain .tar.gz")
|
||||||
|
pwFile := flag.String("password-file", "", "read the encryption password from this file (first line); "+passwordEnv+" is used when unset")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *showVersion {
|
||||||
|
fmt.Println(buildinfo.Version)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
password, err := readPassword(*pwFile)
|
||||||
|
if err == nil {
|
||||||
|
if *decrypt {
|
||||||
|
err = runDecrypt(*in, *out, password)
|
||||||
|
} else {
|
||||||
|
err = run(*out, password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "selfpost-backup: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run writes a backup, encrypting it when a password was supplied.
|
||||||
|
func run(outPath, password string) error {
|
||||||
|
dataDir := envDefault("SELFPOST_DATA_DIR", "/data")
|
||||||
|
dbPath := envDefault("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db"))
|
||||||
|
|
||||||
|
w, closeOut, err := openOutput(outPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer closeOut()
|
||||||
|
|
||||||
|
sink := w
|
||||||
|
var env *secretfile.Writer
|
||||||
|
if password != "" {
|
||||||
|
env, err = secretfile.NewWriter(w, secretfile.TypeFullBackup, password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sink = env
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := backup.Create(sink, backup.Params{
|
||||||
|
DataDir: dataDir,
|
||||||
|
DBPath: dbPath,
|
||||||
|
Version: buildinfo.Version,
|
||||||
|
DeployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
|
||||||
|
OnWarn: func(msg string) {
|
||||||
|
fmt.Fprintf(os.Stderr, "selfpost-backup: %s\n", msg)
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if env != nil {
|
||||||
|
if err := env.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if outPath != "" {
|
||||||
|
kind := "plain"
|
||||||
|
if password != "" {
|
||||||
|
kind = "encrypted"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "selfpost-backup: wrote %s (%s, SelfPost %s)\n", outPath, kind, buildinfo.Version)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runDecrypt turns a .spbk envelope back into the plain gzip tar, so an
|
||||||
|
// encrypted backup can be extracted with ordinary tools during a restore.
|
||||||
|
func runDecrypt(inPath, outPath, password string) error {
|
||||||
|
if password == "" {
|
||||||
|
return fmt.Errorf("-decrypt needs the password (set %s or use -password-file)", passwordEnv)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := io.Reader(os.Stdin)
|
||||||
|
if inPath != "" {
|
||||||
|
f, err := os.Open(inPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
r = f
|
||||||
|
}
|
||||||
|
|
||||||
|
env, err := secretfile.NewReader(r, password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if env.Type() != secretfile.TypeFullBackup {
|
||||||
|
return fmt.Errorf("that file is a %s, not a full backup", env.Type())
|
||||||
|
}
|
||||||
|
|
||||||
|
w, closeOut, err := openOutput(outPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer closeOut()
|
||||||
|
|
||||||
|
if _, err := io.Copy(w, env); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if outPath != "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "selfpost-backup: wrote %s (decrypted)\n", outPath)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openOutput returns stdout, or a freshly created owner-only file: both plain
|
||||||
|
// and encrypted backups are secret enough to keep off other users' eyes.
|
||||||
|
func openOutput(outPath string) (io.Writer, func(), error) {
|
||||||
|
if outPath == "" {
|
||||||
|
return os.Stdout, func() {}, nil
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return f, func() { _ = f.Close() }, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPassword takes the password from the given file (first line) or, when no
|
||||||
|
// file is named, from the environment. An empty result means "no encryption".
|
||||||
|
func readPassword(pwFile string) (string, error) {
|
||||||
|
if pwFile == "" {
|
||||||
|
return os.Getenv(passwordEnv), nil
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(pwFile)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read password file: %w", err)
|
||||||
|
}
|
||||||
|
// A password file is usually written with a trailing newline; take the first
|
||||||
|
// line and strip the line ending, but keep any other whitespace, which may
|
||||||
|
// well be part of the password.
|
||||||
|
line, _, _ := strings.Cut(string(data), "\n")
|
||||||
|
line = strings.TrimSuffix(line, "\r")
|
||||||
|
if line == "" {
|
||||||
|
return "", fmt.Errorf("password file %s is empty", pwFile)
|
||||||
|
}
|
||||||
|
return line, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func envDefault(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/backup"
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedProject builds the minimum operator project tree a backup can be taken from.
|
||||||
|
func seedProject(t *testing.T) (deployRoot, dataDir string) {
|
||||||
|
t.Helper()
|
||||||
|
deployRoot = t.TempDir()
|
||||||
|
dataDir = filepath.Join(deployRoot, "data")
|
||||||
|
if err := os.MkdirAll(dataDir, 0o750); err != nil {
|
||||||
|
t.Fatalf("mkdir data: %v", err)
|
||||||
|
}
|
||||||
|
st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.AddDomain("example.com", "selfpost"); err != nil {
|
||||||
|
t.Fatalf("add domain: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.Close(); err != nil {
|
||||||
|
t.Fatalf("close store: %v", err)
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(deployRoot, backup.ComposeFileName), "services:\n selfpost:\n image: test\n")
|
||||||
|
writeFile(t, filepath.Join(deployRoot, backup.EnvFileName), "SELFPOST_HOSTNAME=mail.example.com\n")
|
||||||
|
writeFile(t, filepath.Join(deployRoot, backup.CertsDirName, "fullchain.pem"), "CERT")
|
||||||
|
writeFile(t, filepath.Join(deployRoot, backup.CertsDirName, "privkey.pem"), "KEY")
|
||||||
|
t.Setenv("SELFPOST_DATA_DIR", dataDir)
|
||||||
|
t.Setenv("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db"))
|
||||||
|
t.Setenv("SELFPOST_DEPLOY_ROOT", deployRoot)
|
||||||
|
return deployRoot, dataDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An encrypted backup is only worth having if the container it came from can
|
||||||
|
// hand it back as an ordinary archive during a restore, so the two halves of
|
||||||
|
// the CLI are tested as the one round trip an operator actually performs.
|
||||||
|
func TestEncryptedBackupRoundTrip(t *testing.T) {
|
||||||
|
seedProject(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
encrypted := filepath.Join(dir, "backup.spbk")
|
||||||
|
plain := filepath.Join(dir, "backup.tar.gz")
|
||||||
|
const password = "a long enough password"
|
||||||
|
|
||||||
|
if err := run(encrypted, password); err != nil {
|
||||||
|
t.Fatalf("create encrypted backup: %v", err)
|
||||||
|
}
|
||||||
|
head, err := os.ReadFile(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read backup: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(string(head), "SELFPOST") {
|
||||||
|
t.Fatalf("encrypted backup does not start with the envelope magic")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runDecrypt(encrypted, plain, "the wrong password"); err == nil {
|
||||||
|
t.Fatal("decryption with the wrong password succeeded")
|
||||||
|
}
|
||||||
|
if err := runDecrypt(encrypted, plain, password); err != nil {
|
||||||
|
t.Fatalf("decrypt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// What comes out must be the same gzip tar the plain path produces.
|
||||||
|
f, err := os.Open(plain)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open decrypted archive: %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
gz, err := gzip.NewReader(f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("gzip: %v", err)
|
||||||
|
}
|
||||||
|
names := map[string]bool{}
|
||||||
|
tr := tar.NewReader(gz)
|
||||||
|
for {
|
||||||
|
hdr, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("tar: %v", err)
|
||||||
|
}
|
||||||
|
names[hdr.Name] = true
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
backup.DataArchivePrefix + backup.ManifestName,
|
||||||
|
backup.DataArchivePrefix + "selfpost.db",
|
||||||
|
backup.ComposeFileName,
|
||||||
|
backup.EnvFileName,
|
||||||
|
} {
|
||||||
|
if !names[want] {
|
||||||
|
t.Errorf("decrypted archive has no %s (entries: %v)", want, names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without a password the CLI keeps producing the plain archive that existing
|
||||||
|
// backup scripts consume.
|
||||||
|
func TestUnencryptedBackupStaysPlain(t *testing.T) {
|
||||||
|
seedProject(t)
|
||||||
|
out := filepath.Join(t.TempDir(), "backup.tar.gz")
|
||||||
|
if err := run(out, ""); err != nil {
|
||||||
|
t.Fatalf("create backup: %v", err)
|
||||||
|
}
|
||||||
|
f, err := os.Open(out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open archive: %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if _, err := gzip.NewReader(f); err != nil {
|
||||||
|
t.Fatalf("plain backup is not a gzip archive: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypting needs a password, and it must come from a file or the environment
|
||||||
|
// — never an argument, which the process list would expose.
|
||||||
|
func TestReadPassword(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
pwFile := filepath.Join(dir, "pw")
|
||||||
|
if err := os.WriteFile(pwFile, []byte("from the file\nignored second line\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write password file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv(passwordEnv, "from the environment")
|
||||||
|
got, err := readPassword("")
|
||||||
|
if err != nil || got != "from the environment" {
|
||||||
|
t.Errorf("readPassword(\"\") = %q, %v", got, err)
|
||||||
|
}
|
||||||
|
got, err = readPassword(pwFile)
|
||||||
|
if err != nil || got != "from the file" {
|
||||||
|
t.Errorf("readPassword(file) = %q, %v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(pwFile, nil, 0o600); err != nil {
|
||||||
|
t.Fatalf("truncate password file: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := readPassword(pwFile); err == nil {
|
||||||
|
t.Error("an empty password file was accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Unsetenv(passwordEnv)
|
||||||
|
if err := runDecrypt("", "", ""); err == nil {
|
||||||
|
t.Error("-decrypt without a password was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Copy to .env next to docker-compose.yml and fill in.
|
||||||
|
# Full reference: docs/guide.md "Environment variables".
|
||||||
|
|
||||||
|
# Required — the container refuses to start without it. Hostname used both for
|
||||||
|
# the panel's TLS vhost and for Postfix's SASL realm (SASL_REALM defaults to
|
||||||
|
# this) and myhostname; must also match your PTR record. Must match the
|
||||||
|
# certificate your reverse proxy obtains. Bare FQDN only — no scheme or port.
|
||||||
|
SELFPOST_HOSTNAME=mail.example.com
|
||||||
|
|
||||||
|
# Set to true to also enable RFC 6409 submission (587/STARTTLS) alongside the
|
||||||
|
# primary 465/smtps listener.
|
||||||
|
SUBMISSION_ENABLE=false
|
||||||
|
|
||||||
|
# Level-1 backstop rate limit (anvil) — see docs/guide.md "Rate limiting".
|
||||||
|
RATE_LIMIT_MESSAGES_PER_IP=100
|
||||||
|
RATE_LIMIT_WINDOW_SECONDS=3600
|
||||||
|
|
||||||
|
# Days of send-log history kept before the background sweep deletes rows.
|
||||||
|
SEND_LOG_RETENTION_DAYS=90
|
||||||
|
|
||||||
|
# Sliding idle timeout for the panel login session, in days. No absolute cap:
|
||||||
|
# an admin who keeps coming back stays signed in indefinitely. Polling by the
|
||||||
|
# monitoring screens does not count as activity, only navigation/actions do.
|
||||||
|
PANEL_SESSION_IDLE_DAYS=7
|
||||||
|
|
||||||
|
# Comma-separated resolvers the panel's deliverability checks (PTR, SPF, DKIM,
|
||||||
|
# DMARC) query directly. They deliberately skip this host's own resolver: with
|
||||||
|
# systemd-resolved the reverse lookup of the server's IP answers with the local
|
||||||
|
# hostname instead of the PTR record published in public DNS, which made the
|
||||||
|
# panel report a correct PTR as wrong. Leave unset for the public defaults
|
||||||
|
# (1.1.1.1, 8.8.8.8, 9.9.9.9); set it if outbound port 53 is closed or you run
|
||||||
|
# your own recursor, e.g. SELFPOST_DNS_RESOLVERS=10.0.0.2:53
|
||||||
|
# SELFPOST_DNS_RESOLVERS=
|
||||||
|
|
||||||
|
# Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to
|
||||||
|
# supply X-Forwarded-For for login/setup rate-limiting. Leave unset unless
|
||||||
|
# you know the exact address of your reverse proxy — trusting the wrong
|
||||||
|
# source lets a client spoof its rate-limit key. Behind the default Apache
|
||||||
|
# host-network setup this is typically the Docker bridge gateway, e.g.
|
||||||
|
# TRUSTED_PROXY_CIDR=172.18.0.1
|
||||||
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Example host Apache vhost for the SelfPost panel (spec 10.3, default
|
||||||
|
# scenario). Install on the host running Apache — NOT inside the SelfPost
|
||||||
|
# container. Requires mod_ssl and mod_proxy/mod_proxy_http enabled
|
||||||
|
# (`a2enmod ssl proxy proxy_http`).
|
||||||
|
#
|
||||||
|
# Certificates: obtain with the Apache certbot plugin against THIS vhost
|
||||||
|
# (`certbot --apache -d mail.example.com`). Certbot edits this file in place
|
||||||
|
# to add the SSLCertificateFile/SSLCertificateKeyFile directives and a
|
||||||
|
# :80 -> :443 redirect vhost, and renews in the background via its own timer.
|
||||||
|
# The resulting PEM files land at
|
||||||
|
# /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host
|
||||||
|
# — bind-mount that directory read-only into the SelfPost container (see
|
||||||
|
# ../docker-compose.yml, the `./certs:/etc/postfix/tls:ro` volume) so Postfix
|
||||||
|
# can present the very same certificate on 465/587. One certificate, two
|
||||||
|
# consumers (spec 10 p.4) — no separate cert just for the panel.
|
||||||
|
|
||||||
|
<VirtualHost *:443>
|
||||||
|
ServerName mail.example.com
|
||||||
|
|
||||||
|
# Filled in by `certbot --apache`; shown here for clarity.
|
||||||
|
# SSLEngine on
|
||||||
|
# SSLCertificateFile /etc/letsencrypt/live/mail.example.com/fullchain.pem
|
||||||
|
# SSLCertificateKeyFile /etc/letsencrypt/live/mail.example.com/privkey.pem
|
||||||
|
|
||||||
|
ProxyPreserveHost On
|
||||||
|
ProxyPass "/" "http://127.0.0.1:8080/"
|
||||||
|
ProxyPassReverse "/" "http://127.0.0.1:8080/"
|
||||||
|
|
||||||
|
# The panel is plain HTTP behind the proxy; it never terminates TLS
|
||||||
|
# itself (spec 10 p.2). Cookies are still marked Secure by the panel
|
||||||
|
# (spec 7.6.6) because the browser only ever sees this HTTPS vhost.
|
||||||
|
</VirtualHost>
|
||||||
|
|
||||||
|
<VirtualHost *:80>
|
||||||
|
ServerName mail.example.com
|
||||||
|
# certbot's http-01 challenge needs this on plain :80; everything else
|
||||||
|
# redirects to HTTPS once certbot adds the RewriteRule/Redirect block.
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/
|
||||||
|
RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [R=301,L]
|
||||||
|
</VirtualHost>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
mail.example.com {
|
||||||
|
reverse_proxy selfpost:8080
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
|
||||||
|
# Alternative reverse-proxy: Caddy (spec 10.3) — the simplest option, fully
|
||||||
|
# automatic ACME with no separate certbot container. Caddy writes certificates
|
||||||
|
# as plain PEM under its own data directory, which this fragment bind-mounts
|
||||||
|
# to the host so SelfPost can read the same files.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
# <data>/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>/<hostname>.crt
|
||||||
|
# This has been stable across recent Caddy releases but is an internal
|
||||||
|
# storage detail, not a documented public API — VERIFY the exact path against
|
||||||
|
# the Caddy version you deploy (`docker compose exec caddy find /data/caddy/certificates -name '*.crt'`)
|
||||||
|
# before wiring it into the volume mount below.
|
||||||
|
#
|
||||||
|
# Merge with the base file — run this from the deploy/ directory (Compose
|
||||||
|
# resolves every relative path in both files against the directory of the
|
||||||
|
# FIRST -f file, i.e. deploy/, which is why paths below are ./caddy/...):
|
||||||
|
#
|
||||||
|
# docker compose -f docker-compose.yml -f caddy/docker-compose.caddy.yml up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
selfpost:
|
||||||
|
ports: !override
|
||||||
|
- "465:465"
|
||||||
|
- "587:587"
|
||||||
|
environment:
|
||||||
|
# Caddy names certificate files after the hostname, not
|
||||||
|
# fullchain.pem/privkey.pem like certbot — override the base file's
|
||||||
|
# paths to match.
|
||||||
|
TLS_CERT_FILE: /etc/postfix/tls/<hostname>.crt
|
||||||
|
TLS_KEY_FILE: /etc/postfix/tls/<hostname>.key
|
||||||
|
volumes: !override
|
||||||
|
- ./data:/data
|
||||||
|
- .:/selfpost-deploy:ro
|
||||||
|
# Replace <hostname> with your actual mail/panel hostname, matching
|
||||||
|
# the Caddyfile below and SELFPOST_HOSTNAME.
|
||||||
|
- ./caddy/caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>:/etc/postfix/tls:ro
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
image: caddy:2
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- selfpost
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- ./caddy/caddy-data:/data
|
||||||
|
- ./caddy/caddy-config:/config
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
|
||||||
|
# SelfPost — default deployment, reverse-proxy = Apache (spec 10, 10.5).
|
||||||
|
#
|
||||||
|
# This file only runs SelfPost itself. Apache is assumed to already be
|
||||||
|
# installed on the HOST (the target audience for this project typically runs
|
||||||
|
# Apache there already — spec 10.5) and reverse-proxies HTTPS for the panel;
|
||||||
|
# it is not containerised here. See ../apache/selfpost-vhost.conf for a ready
|
||||||
|
# vhost fragment, and docs/guide.md "Reverse proxy" for the
|
||||||
|
# certbot steps that produce the PEM files this compose file mounts.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# 1. Copy this file (and .env.example as .env) next to your own ./data and
|
||||||
|
# ./certs directories, or adjust the paths below.
|
||||||
|
# 2. Fill in .env (at minimum SELFPOST_HOSTNAME). Put PEM files in ./certs —
|
||||||
|
# TLS paths are fixed in this file to match that bind mount, not .env.
|
||||||
|
# 3. docker compose up -d
|
||||||
|
#
|
||||||
|
# The image tag below is FIXED on purpose (spec 10 p.10, 7.5.A): backup
|
||||||
|
# restore compares the manifest version against the running binary's version,
|
||||||
|
# so ":latest" would make that check meaningless. Bump the tag deliberately
|
||||||
|
# when you want to upgrade.
|
||||||
|
|
||||||
|
services:
|
||||||
|
selfpost:
|
||||||
|
image: ghcr.io/mixeme/selfpost:1.3.0
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
||||||
|
# Path Postfix reads inside the container — matches the certs bind mount
|
||||||
|
# below. Point these at your reverse-proxy's PEM output (spec 10 p.2).
|
||||||
|
TLS_CERT_FILE: /etc/postfix/tls/fullchain.pem
|
||||||
|
TLS_KEY_FILE: /etc/postfix/tls/privkey.pem
|
||||||
|
# Set to true to also publish RFC 6409 submission (587/STARTTLS)
|
||||||
|
# alongside the primary 465/smtps listener (spec 5).
|
||||||
|
SUBMISSION_ENABLE: "${SUBMISSION_ENABLE:-false}"
|
||||||
|
# Level-1 backstop rate limit (anvil, spec 5.5, 7.4); per-domain/app
|
||||||
|
# limits (level 2) are configured later from the panel itself.
|
||||||
|
RATE_LIMIT_MESSAGES_PER_IP: "${RATE_LIMIT_MESSAGES_PER_IP:-100}"
|
||||||
|
RATE_LIMIT_WINDOW_SECONDS: "${RATE_LIMIT_WINDOW_SECONDS:-3600}"
|
||||||
|
# How long the send log keeps rows before the background sweep deletes
|
||||||
|
# them (spec 7.3, 9) — the main driver of /data growth over time.
|
||||||
|
SEND_LOG_RETENTION_DAYS: "${SEND_LOG_RETENTION_DAYS:-90}"
|
||||||
|
# Sliding idle timeout for the panel login session, in days (spec 7.6.6).
|
||||||
|
PANEL_SESSION_IDLE_DAYS: "${PANEL_SESSION_IDLE_DAYS:-7}"
|
||||||
|
# Resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly, so they
|
||||||
|
# report what the internet sees rather than what this host's own stub
|
||||||
|
# resolver synthesises. Empty uses public defaults.
|
||||||
|
SELFPOST_DNS_RESOLVERS: "${SELFPOST_DNS_RESOLVERS:-}"
|
||||||
|
# Reverse-proxy CIDRs allowed to supply X-Forwarded-For for login/setup
|
||||||
|
# rate-limiting. See docs/guide.md "Environment variables" — wrong value lets a
|
||||||
|
# client spoof its rate-limit key.
|
||||||
|
TRUSTED_PROXY_CIDR: "${TRUSTED_PROXY_CIDR:-}"
|
||||||
|
volumes:
|
||||||
|
# All persistent state lives under /data (spec 9): SQLite DB, DKIM keys,
|
||||||
|
# sasldb2, sender map, setup token. Back this up (panel button or the
|
||||||
|
# selfpost-backup CLI) before you touch it directly.
|
||||||
|
# Postfix's mail.log is here too (./data/log, rotated daily, 14 files
|
||||||
|
# kept) so the delivery lines that resolve a queued send-log row survive
|
||||||
|
# a container recreate; it is diagnostics and is left out of backups.
|
||||||
|
- ./data:/data
|
||||||
|
# Read-only project root for full backups: docker-compose.yml, .env, and
|
||||||
|
# certs/ are archived alongside /data (architecture.md § Persistence).
|
||||||
|
- .:/selfpost-deploy:ro
|
||||||
|
# Read-only: SelfPost only ever reads certificates, never manages them
|
||||||
|
# (spec 10 p.2). Point this at wherever your reverse-proxy/certbot
|
||||||
|
# writes PEM files, e.g. /etc/letsencrypt on the host.
|
||||||
|
- ./certs:/etc/postfix/tls:ro
|
||||||
|
ports:
|
||||||
|
# 465 (smtps, primary) and optionally 587 (submission/STARTTLS) are
|
||||||
|
# published directly — mail traffic bypasses Apache entirely, it only
|
||||||
|
# ever proxies the panel's HTTP(S) (spec 10 p.2-3). The panel itself
|
||||||
|
# (8080) is intentionally NOT published here: Apache reaches it over
|
||||||
|
# the host network at 127.0.0.1:8080 (see the vhost fragment), so the
|
||||||
|
# panel is never directly reachable from the internet without TLS.
|
||||||
|
- "465:465"
|
||||||
|
# 587 is mapped even when SUBMISSION_ENABLE=false; Postfix listens only
|
||||||
|
# when the variable is true — see docs/guide.md "Published ports".
|
||||||
|
- "587:587"
|
||||||
|
- "127.0.0.1:8080:8080"
|
||||||
|
# Hardening (spec 10 p.6). SelfPost's entrypoint still needs to run as
|
||||||
|
# root very briefly to fix /data ownership and normalise permissions
|
||||||
|
# under the shared `selfpost` group (see build/entrypoint.sh) before
|
||||||
|
# supervisord drops the panel to an unprivileged user — so this cannot be
|
||||||
|
# `user: panel` or a fully read-only rootfs without breaking that startup
|
||||||
|
# self-healing. What IS applied: no privilege escalation past what the
|
||||||
|
# image already grants, and every Linux capability dropped except the
|
||||||
|
# small set the root startup phase and Postfix/OpenDKIM genuinely need:
|
||||||
|
# - NET_BIND_SERVICE — bind 465/587 (and 25 outbound) below 1024;
|
||||||
|
# - CHOWN — entrypoint re-owns /data (bind mount) to `panel`;
|
||||||
|
# - FOWNER — entrypoint then chmods those now panel-owned /data
|
||||||
|
# dirs/files while still root (owner-check bypass);
|
||||||
|
# - FSETID — set the setgid bit (2750) on the shared /data dirs
|
||||||
|
# when the process gid differs from the dir's group;
|
||||||
|
# - SETUID/SETGID — supervisord drops the panel to the unprivileged
|
||||||
|
# `panel` user; Postfix switches to its own users;
|
||||||
|
# - DAC_OVERRIDE — cross-user file access within the `selfpost` group.
|
||||||
|
# - KILL — supervisord (root) sends SIGUSR1 to opendkim (a
|
||||||
|
# different uid) on domain add/remove to reload its
|
||||||
|
# KeyTable/SigningTable; without it os.kill() fails
|
||||||
|
# with EPERM even though the caller is root, because
|
||||||
|
# the kernel's signal permission check for
|
||||||
|
# cross-uid kill() still consults CAP_KILL.
|
||||||
|
# FOWNER/FSETID are required by build/entrypoint.sh's permission
|
||||||
|
# self-healing; without them chmod fails with EPERM and the container
|
||||||
|
# crash-loops on start.
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
cap_add:
|
||||||
|
- NET_BIND_SERVICE
|
||||||
|
- CHOWN
|
||||||
|
- FOWNER
|
||||||
|
- FSETID
|
||||||
|
- SETUID
|
||||||
|
- SETGID
|
||||||
|
- DAC_OVERRIDE
|
||||||
|
- KILL
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
|
||||||
|
# Alternative reverse-proxy: nginx (spec 10.3). PEM files land on the host
|
||||||
|
# exactly like the Apache+certbot scenario — nginx and certbot are close
|
||||||
|
# cousins here, both writing/reading plain PEM under /etc/letsencrypt.
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
# takes over 80/443 and proxies to the panel over the compose network
|
||||||
|
# instead). Merge it with the base file — run this from the deploy/ directory
|
||||||
|
# (Compose resolves every relative path in both files against the directory of
|
||||||
|
# the FIRST -f file, i.e. deploy/, which is why paths below are ./nginx/...):
|
||||||
|
#
|
||||||
|
# docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d
|
||||||
|
#
|
||||||
|
# First-run certificate issuance (webroot method, before nginx has a cert to
|
||||||
|
# serve — run once):
|
||||||
|
# docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml \
|
||||||
|
# run --rm certbot certonly --webroot -w /var/www/certbot \
|
||||||
|
# -d mail.example.com --email you@example.com --agree-tos --no-eff-email
|
||||||
|
|
||||||
|
services:
|
||||||
|
selfpost:
|
||||||
|
ports: !override
|
||||||
|
- "465:465"
|
||||||
|
- "587:587"
|
||||||
|
# No host publish for 8080 here: nginx reaches it over the compose
|
||||||
|
# network at selfpost:8080 instead (see nginx.conf.example).
|
||||||
|
volumes: !override
|
||||||
|
- ./data:/data
|
||||||
|
- .:/selfpost-deploy:ro
|
||||||
|
# Same host directory certbot below writes into — plain bind mount,
|
||||||
|
# no named volume, so the PEM files are as directly inspectable as in
|
||||||
|
# the Apache scenario (spec 10.3).
|
||||||
|
- ./nginx/certbot-etc/live/mail.example.com:/etc/postfix/tls:ro
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:1.27
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- selfpost
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/nginx.conf.example:/etc/nginx/conf.d/selfpost.conf:ro
|
||||||
|
- ./nginx/certbot-etc:/etc/letsencrypt:ro
|
||||||
|
- ./nginx/certbot-www:/var/www/certbot:ro
|
||||||
|
|
||||||
|
certbot:
|
||||||
|
image: certbot/certbot:latest
|
||||||
|
volumes:
|
||||||
|
- ./nginx/certbot-etc:/etc/letsencrypt
|
||||||
|
- ./nginx/certbot-www:/var/www/certbot
|
||||||
|
# Renewal twice a day is certbot's own recommended cadence; it no-ops
|
||||||
|
# until a certificate is within its renewal window.
|
||||||
|
entrypoint: sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done'
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# nginx vhost for the SelfPost panel (spec 10.3). Proxies HTTPS to the panel
|
||||||
|
# over the compose network; mail (465/587) is published directly by the
|
||||||
|
# selfpost container and never touches nginx.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name mail.example.com;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name mail.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/mail.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/mail.example.com/privkey.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://selfpost:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
|
||||||
|
# Alternative reverse-proxy: Traefik (spec 10.3). Traefik does NOT write plain
|
||||||
|
# PEM files — everything (cert + key) lives bundled inside acme.json. SelfPost
|
||||||
|
# needs separate PEM files for Postfix, so an extraction step is required; see
|
||||||
|
# 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
|
||||||
|
# resolves every relative path in both files against the directory of the
|
||||||
|
# FIRST -f file, i.e. deploy/, which is why paths below are ./traefik/...):
|
||||||
|
#
|
||||||
|
# docker compose -f docker-compose.yml -f traefik/docker-compose.traefik.yml up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
selfpost:
|
||||||
|
ports: !override
|
||||||
|
- "465:465"
|
||||||
|
- "587:587"
|
||||||
|
volumes: !override
|
||||||
|
- ./data:/data
|
||||||
|
- .:/selfpost-deploy:ro
|
||||||
|
# Populated by extract-cert.sh from traefik's acme.json — see below.
|
||||||
|
- ./traefik/extracted-certs:/etc/postfix/tls:ro
|
||||||
|
labels:
|
||||||
|
# Traefik only proxies the panel; it never sees the mail ports.
|
||||||
|
# These live on the selfpost service (not traefik's) because the
|
||||||
|
# docker provider reads routing labels off the container being routed
|
||||||
|
# to, not off traefik itself.
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.selfpost.rule=Host(`mail.example.com`)
|
||||||
|
- traefik.http.routers.selfpost.entrypoints=websecure
|
||||||
|
- traefik.http.routers.selfpost.tls.certresolver=le
|
||||||
|
- traefik.http.services.selfpost.loadbalancer.server.port=8080
|
||||||
|
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.1
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- selfpost
|
||||||
|
command:
|
||||||
|
- --providers.docker=true
|
||||||
|
- --providers.docker.exposedbydefault=false
|
||||||
|
- --entrypoints.web.address=:80
|
||||||
|
- --entrypoints.websecure.address=:443
|
||||||
|
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||||
|
- --certificatesresolvers.le.acme.email=you@example.com
|
||||||
|
- --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
|
||||||
|
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- ./traefik/letsencrypt:/letsencrypt
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json
|
||||||
|
# (spec 10.3: "Traefik keeps certificates in acme.json, so a PEM extraction
|
||||||
|
# step is required"). Run this on the host, after Traefik has issued or
|
||||||
|
# renewed the certificate, and again on a schedule (cron/systemd timer) since
|
||||||
|
# acme.json is not itself watched by SelfPost/Postfix.
|
||||||
|
#
|
||||||
|
# Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ACME_JSON="${1:?path to acme.json}"
|
||||||
|
DOMAIN="${2:?domain name, e.g. mail.example.com}"
|
||||||
|
OUT_DIR="${3:?output directory, e.g. ./extracted-certs}"
|
||||||
|
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
|
||||||
|
jq -r --arg domain "$DOMAIN" '
|
||||||
|
.le.Certificates[]
|
||||||
|
| select(.domain.main == $domain)
|
||||||
|
| .certificate' "$ACME_JSON" | base64 -d > "$OUT_DIR/fullchain.pem"
|
||||||
|
|
||||||
|
jq -r --arg domain "$DOMAIN" '
|
||||||
|
.le.Certificates[]
|
||||||
|
| select(.domain.main == $domain)
|
||||||
|
| .key' "$ACME_JSON" | base64 -d > "$OUT_DIR/privkey.pem"
|
||||||
|
|
||||||
|
chmod 0640 "$OUT_DIR/fullchain.pem" "$OUT_DIR/privkey.pem"
|
||||||
|
echo "extracted $DOMAIN to $OUT_DIR/{fullchain,privkey}.pem"
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
# SelfPost — architecture (as-built)
|
||||||
|
|
||||||
|
**Source of truth:** the code tree, not historical specs. Synchronise this file
|
||||||
|
when env keys, routes, or mail-path behaviour change. Verification method:
|
||||||
|
[development.md](development.md) § «Verifying docs against code».
|
||||||
|
|
||||||
|
User install/operations: [README.md](../README.md), [guide.md](guide.md). Product boundaries:
|
||||||
|
[product.md](product.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Image and processes
|
||||||
|
|
||||||
|
Single Debian slim image. `entrypoint.sh` (root) fixes `/data` ownership and
|
||||||
|
milter socket directories, **requires** `SELFPOST_HOSTNAME` (FQDN with at least
|
||||||
|
one dot; no scheme, port, or spaces — invalid or empty value → `exit 1` before
|
||||||
|
Postfix config or supervisord), then execs `supervisord` as PID 1.
|
||||||
|
|
||||||
|
The hostname is not a tunable default: it must match PTR/rDNS, TLS CN/SAN, and
|
||||||
|
SASL realm together. Soft fallbacks (`localhost` in the panel vs container ID in
|
||||||
|
Postfix) split realms and break SMTP AUTH with a silent `535` in clients while
|
||||||
|
the panel looks healthy. Shipped `docker-compose.yml` already requires the
|
||||||
|
variable; the entrypoint gate catches `docker run`, custom compose, and k8s.
|
||||||
|
|
||||||
|
Managed programs ([build/supervisord.conf](../build/supervisord.conf)):
|
||||||
|
|
||||||
|
| Program | User | Priority | Role |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `opendkim` | root → `opendkim` (`UserID` in opendkim.conf) | 100 | DKIM signing milter |
|
||||||
|
| `panel` | panel | 200 | HTTP UI + journal-milter + log-tailer goroutine |
|
||||||
|
| `postfix` | root (wrapper) | 300 | MTA — started only after both milter sockets exist |
|
||||||
|
| `postfix-reload` | root | — | On-demand `postfix reload` (autostart off) |
|
||||||
|
| `cert-reload` | root | 400 | Daily `postfix reload` for renewed TLS certs |
|
||||||
|
| `logrotate` | root | 400 | Periodic `mail.log` rotation |
|
||||||
|
|
||||||
|
Start order: OpenDKIM → panel (opens journal-milter socket) → Postfix wrapper
|
||||||
|
polls unix sockets (timeout `MILTER_WAIT_TIMEOUT`, default 30s) then
|
||||||
|
`postfix start-fg`.
|
||||||
|
|
||||||
|
`crashexit` event listener exits the container on any managed process FATAL so
|
||||||
|
Docker `restart: unless-stopped` recreates a broken instance.
|
||||||
|
|
||||||
|
**Liveness:** `GET /healthz` (unauthenticated) returns 200 when opendkim,
|
||||||
|
panel, and postfix are RUNNING; Docker `HEALTHCHECK` uses the same probe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mail path
|
||||||
|
|
||||||
|
```
|
||||||
|
Client ──TLS+SASL──► Postfix (465 smtps, optional 587 submission)
|
||||||
|
│
|
||||||
|
├─► OpenDKIM milter (sign, tempfail on failure)
|
||||||
|
├─► journal-milter (send log + L2 rate limits, fail-open)
|
||||||
|
└─► outbound MX delivery (port 25 client)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Postfix ([build/postfix-config.sh](../build/postfix-config.sh))
|
||||||
|
|
||||||
|
- **465/smtps** — implicit TLS, SASL required; primary listener.
|
||||||
|
- **587/submission** — only when `SUBMISSION_ENABLE=true`; STARTTLS with
|
||||||
|
`smtpd_tls_security_level=encrypt`.
|
||||||
|
- **No open relay** — `permit_sasl_authenticated`, `reject_unauth_destination`;
|
||||||
|
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
|
||||||
|
- **Level-1 rate limit** — `smtpd_client_message_rate_limit` /
|
||||||
|
`anvil_rate_time_unit` from `RATE_LIMIT_*` env vars; independent of milter.
|
||||||
|
- **Chroot disabled** for all services (DNS/TLS inside container).
|
||||||
|
- **TLS certs** — read-only mount at `TLS_CERT_FILE` / `TLS_KEY_FILE`; daily
|
||||||
|
reload via `cert-reload`.
|
||||||
|
|
||||||
|
### OpenDKIM
|
||||||
|
|
||||||
|
Per-domain keys under `/data/opendkim/keys`; `KeyTable` / `SigningTable` maintained
|
||||||
|
by the panel. Socket `/run/opendkim/opendkim.sock`.
|
||||||
|
|
||||||
|
### Panel binary ([cmd/panel](../cmd/panel))
|
||||||
|
|
||||||
|
One process, three roles:
|
||||||
|
|
||||||
|
1. **HTTP server** — `:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse
|
||||||
|
proxy only.
|
||||||
|
2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
|
||||||
|
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open**
|
||||||
|
(`default_action=accept`) so milter failure does not stop mail. Domain
|
||||||
|
ceilings apply to every client IP; an application ceiling with trusted IPs
|
||||||
|
raises the limit for those IPs only and skips the domain check (guide § Rate
|
||||||
|
limiting). The level-2 count is the stored send-log rows plus the messages
|
||||||
|
this process has admitted but not yet written (`internal/milter/inflight.go`),
|
||||||
|
so concurrent sessions cannot each spend the same last slot; a reservation
|
||||||
|
is released at end-of-message, on ABORT, or after a 10-minute TTL.
|
||||||
|
3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by
|
||||||
|
queue-id. Send-log `queued → sent` transitions depend on this goroutine alone
|
||||||
|
(`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)).
|
||||||
|
|
||||||
|
Milter chain in Postfix: OpenDKIM (tempfail) then journal (accept on failure).
|
||||||
|
|
||||||
|
### Log tailer and `mail.log` rotation
|
||||||
|
|
||||||
|
`mail.log` lives at `/data/log/mail.log` — inside the persistent bind mount, so
|
||||||
|
the delivery lines that resolve a `queued` send-log row are not lost when the
|
||||||
|
container is recreated. `postlogd` writes it as user `postfix`; the panel reads
|
||||||
|
it through the shared `selfpost` group (directory `2750 postfix:selfpost`, file
|
||||||
|
`0640`, both normalised on every start by
|
||||||
|
[build/entrypoint.sh](../build/entrypoint.sh)). The path is one default in two
|
||||||
|
places, `maillog_file` in [build/postfix-config.sh](../build/postfix-config.sh)
|
||||||
|
and `MAIL_LOG` in [cmd/panel/main.go](../cmd/panel/main.go). Backups exclude
|
||||||
|
`log/`: it is diagnostic output, not state to restore.
|
||||||
|
|
||||||
|
Rotation uses rename + `postfix reload`
|
||||||
|
([build/logrotate-mail.conf](../build/logrotate-mail.conf)), not `copytruncate` —
|
||||||
|
the latter can drop `status=sent` lines and leave send-log rows stuck at
|
||||||
|
`queued`. After rename, logrotate runs `create 0640 postfix selfpost` (postlogd
|
||||||
|
recreates the file lazily on first write as mode `0600`, which the unprivileged
|
||||||
|
panel user cannot read). `follow()` drains the old inode once more before
|
||||||
|
switching descriptors; the panel treats a missing log file as an empty tail, not
|
||||||
|
an error.
|
||||||
|
|
||||||
|
**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
|
||||||
|
on start resumes from it, parsing the tail written while the panel was down. If
|
||||||
|
the fingerprint no longer matches (rotated or recreated in the meantime) it reads
|
||||||
|
the current file from the start; re-parsing lines is harmless because
|
||||||
|
`UpdateStatus` writes the same status onto the same row. Only a first-ever start,
|
||||||
|
with nothing stored, begins at end-of-file, so installing the panel does not
|
||||||
|
replay a pre-existing log.
|
||||||
|
|
||||||
|
**Queue reconcile** is the backstop for what the log cannot explain at all: a
|
||||||
|
row still `queued` more than two minutes after it was accepted, whose queue id
|
||||||
|
`postqueue -p` no longer lists, is marked `bounced` (swept every five minutes,
|
||||||
|
[internal/logtail](../internal/logtail/logtail.go),
|
||||||
|
[postfix.QueueIDs](../internal/postfix/queue.go)). Postfix having dropped the
|
||||||
|
message means nothing more will ever be reported about it, so the row can only
|
||||||
|
be closed on an assumption, and it is closed as a failure because a delivery the
|
||||||
|
panel cannot evidence must not be shown as one. Three things keep the sweep from
|
||||||
|
guessing where it need not: it starts only after the tailer has read to
|
||||||
|
end-of-file once (on a restart the log itself holds the answer), the two-minute
|
||||||
|
grace covers messages merely in flight, and a `postqueue` that cannot be read
|
||||||
|
leaves every row untouched rather than closing them all. Now that the log
|
||||||
|
survives the container, reaching this path means the lines are gone for good —
|
||||||
|
rotated past fourteen files while the panel was down, or deleted.
|
||||||
|
|
||||||
|
**Two one-shot reads** sit beside the follow loop and are unrelated to it, both
|
||||||
|
serving panel pages on request: `TailLines` (the last *n* lines, for
|
||||||
|
`/system-log`) and `QueueLines` (the lines carrying one queue-id, for
|
||||||
|
`/deliveries/{id}`). `QueueLines` scans a bounded tail of the current file —
|
||||||
|
finding a message's lines means reading rather than seeking — and matches the id
|
||||||
|
anchored on the character before it, since queue ids are hexadecimal runs and a
|
||||||
|
shorter one is regularly the tail of a longer one. Send-log rows outlive the log
|
||||||
|
(retention 90 days, rotation 14 files), so an empty result is the expected end
|
||||||
|
state for an older message and the page reports it as such, not as a failure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Panel HTTP surface
|
||||||
|
|
||||||
|
Canonical routes: [internal/web/web.go](../internal/web/web.go). Authenticated
|
||||||
|
unless noted. Routes marked **global** return **404** for domain administrators
|
||||||
|
(`requireGlobal()` in
|
||||||
|
[internal/web/handlers/authz.go](../internal/web/handlers/authz.go)). The table
|
||||||
|
below is a summary — HTMX fragment endpoints
|
||||||
|
(`/status/fragment`, `/deliveries/rows`, `/mail-queue/body`,
|
||||||
|
`/system-log/body`, …) and every POST variant live in `web.go`.
|
||||||
|
|
||||||
|
| Route | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `/healthz` | Liveness (no auth) |
|
||||||
|
| `/license` | Embedded `LICENSE` text (no auth) |
|
||||||
|
| `/setup/*` | One-time admin bootstrap |
|
||||||
|
| `/login`, `/logout` | Session auth |
|
||||||
|
| `/account` | 308 redirect to `/settings` (pre-1.2.3 route, kept as a compat shim) |
|
||||||
|
| `/status`, `/status/*` | **Global.** Process, cert, socket, PTR checks; machine CPU/memory/network |
|
||||||
|
| `/domains` | Domain list; `POST /domains` (add domain) is **global** |
|
||||||
|
| `/domains/{id}`, `/domains/{id}/*` | Assigned-domain detail for domain-admins; delete domain is **global** |
|
||||||
|
| `/domains/import` | **Global.** Domain import (`POST`; form on the Backup page) |
|
||||||
|
| `/deliveries`, `/deliveries/{id}` | Send log with filters; scoped to assigned domains for domain-admins |
|
||||||
|
| `/mail-queue`, `/mail-queue/*` | **Global.** Postfix queue view |
|
||||||
|
| `/system-log`, `/system-log/*` | **Global.** `mail.log` tail |
|
||||||
|
| `/reload` | **Global.** `POST` — reload OpenDKIM + Postfix maps |
|
||||||
|
| `/backup`, `/backup/*` | **Global.** Full backup download (page also hosts the import form) |
|
||||||
|
| `/settings` | Username/password for any user; DMARC report default is **global** only |
|
||||||
|
| `/users`, `/users/*` | **Global.** Panel user CRUD |
|
||||||
|
|
||||||
|
HTMX polling refreshes monitoring fragments (5 s while the operator is active on
|
||||||
|
the page, 30 s when the tab is visible but idle, none when hidden — scheduled in
|
||||||
|
`panel.js` via `data-poll`, not `hx-trigger="every …"`); polling does not extend
|
||||||
|
session idle timeout (only non-`HX-Request` GET and mutating requests count as
|
||||||
|
activity).
|
||||||
|
|
||||||
|
### Sessions
|
||||||
|
|
||||||
|
Stored in SQLite (`sessions` table, migration `0002`): cookie holds a random
|
||||||
|
token; the database stores **SHA-256 of the token**, not the token itself — a
|
||||||
|
stolen DB or backup archive does not alone grant login, but a browser that still
|
||||||
|
holds the cookie works after process restart, redeploy, or full backup restore.
|
||||||
|
|
||||||
|
- **Idle timeout** — sliding window, `PANEL_SESSION_IDLE_DAYS` (default 7); no
|
||||||
|
absolute cap (regular use keeps the session alive indefinitely).
|
||||||
|
- **Renewal** — DB `last_seen` and cookie `Max-Age` update at most once per hour
|
||||||
|
(`renewThreshold` in [internal/web/auth/session.go](../internal/web/auth/session.go)).
|
||||||
|
- **Password change on `/settings`** — changing your own password deletes
|
||||||
|
every other session for that user; the current session stays active
|
||||||
|
([internal/store/sessions.go](../internal/store/sessions.go),
|
||||||
|
[handlers_settings.go](../internal/web/handlers/handlers_settings.go)).
|
||||||
|
A global administrator resetting another user's password on `/users` updates
|
||||||
|
the hash but does not delete that user's existing sessions.
|
||||||
|
|
||||||
|
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
|
||||||
|
the cookie and the restored row's `expires_at` has not passed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code layers
|
||||||
|
|
||||||
|
Multi-store writes that must land in more than one place (SQLite row,
|
||||||
|
`sasldb2` entry, Postfix map, OpenDKIM table) go through a service, which is
|
||||||
|
also where the rollback of a partial failure lives. Handlers may call
|
||||||
|
`store` directly for single-table reads and simple writes (sessions, admin,
|
||||||
|
send-log queries); the first-run setup-token file is read and written in
|
||||||
|
`web` itself. The adapters below the services are the only code that knows
|
||||||
|
about Postfix, OpenDKIM, DNS or the log file, which is what makes them
|
||||||
|
substitutable in tests — `milter.Store`, `app.SenderMaps` and
|
||||||
|
`logtail.StatusStore` are the seams the unit tests replace with fakes.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph cmd ["cmd — composition root"]
|
||||||
|
panel["panel: HTTP + journal-milter + log-tailer"]
|
||||||
|
backupcli["selfpost-backup CLI"]
|
||||||
|
end
|
||||||
|
subgraph web ["internal/web — HTTP surface"]
|
||||||
|
webRoot["web.go — router, security"]
|
||||||
|
viewPkg["web/view — templates, static"]
|
||||||
|
authPkg["web/auth — session, login, setup"]
|
||||||
|
handlersPkg["web/handlers — authenticated pages"]
|
||||||
|
webRoot --> viewPkg
|
||||||
|
webRoot --> authPkg
|
||||||
|
webRoot --> handlersPkg
|
||||||
|
handlersPkg --> authPkg
|
||||||
|
handlersPkg --> viewPkg
|
||||||
|
end
|
||||||
|
subgraph services ["Services — multi-store operations + rollback"]
|
||||||
|
domainSvc["internal/domain"]
|
||||||
|
appSvc["internal/app"]
|
||||||
|
end
|
||||||
|
subgraph persistence ["Persistence"]
|
||||||
|
store["internal/store — SQLite, embedded migrations"]
|
||||||
|
end
|
||||||
|
subgraph adapters ["Adapters — the only infrastructure-aware code"]
|
||||||
|
postfix["internal/postfix"]
|
||||||
|
milterPkg["internal/milter"]
|
||||||
|
logtail["internal/logtail"]
|
||||||
|
dnscheck["internal/dnscheck"]
|
||||||
|
backupPkg["internal/backup"]
|
||||||
|
health["internal/health"]
|
||||||
|
secretfile["internal/secretfile"]
|
||||||
|
end
|
||||||
|
panel --> web
|
||||||
|
panel --> milterPkg
|
||||||
|
panel --> logtail
|
||||||
|
backupcli --> backupPkg
|
||||||
|
backupcli --> secretfile
|
||||||
|
web --> store
|
||||||
|
web --> domainSvc
|
||||||
|
web --> appSvc
|
||||||
|
web --> backupPkg
|
||||||
|
web --> dnscheck
|
||||||
|
web --> health
|
||||||
|
web --> secretfile
|
||||||
|
domainSvc --> store
|
||||||
|
appSvc --> store
|
||||||
|
milterPkg --> store
|
||||||
|
logtail --> store
|
||||||
|
domainSvc --> postfix
|
||||||
|
appSvc --> postfix
|
||||||
|
```
|
||||||
|
|
||||||
|
The three roles inside the `panel` process (HTTP server, journal-milter,
|
||||||
|
log-tailer goroutine) share one binary and one SQLite handle on purpose — see
|
||||||
|
[Panel binary](#panel-binary-cmdpanel) for why, and *Persistence* below for the
|
||||||
|
single-connection trade-off that follows from it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Persistence (`/data` bind mount)
|
||||||
|
|
||||||
|
| Path | Contents |
|
||||||
|
|---|---|
|
||||||
|
| `selfpost.db` | SQLite: domains, apps, admin, sessions, send log, L2 limits, log-tailer offset |
|
||||||
|
| `setup-token` | First-run setup token file |
|
||||||
|
| `opendkim/` | DKIM keys + tables |
|
||||||
|
| `sasl/sasldb2` | Application SASL credentials |
|
||||||
|
| `postfix/sender_login_maps` | Login → From binding |
|
||||||
|
| `postfix/queue/` | Postfix transit mail (deferred/active); survives container recreate |
|
||||||
|
| `log/mail.log` | Postfix delivery log + rotated copies (excluded from backups) |
|
||||||
|
| `manifest.json` | Backup version stamp (consumed on restore) |
|
||||||
|
|
||||||
|
Not in `/data`: TLS certificates for the panel (reverse-proxy mount) — though
|
||||||
|
full backups also archive the operator's `./certs` PEM files when present.
|
||||||
|
|
||||||
|
**Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90);
|
||||||
|
`mail.log` via logrotate (14 rotated files, check every 6h, rename +
|
||||||
|
`postfix reload` in `postrotate` — see § Log tailer above).
|
||||||
|
|
||||||
|
**Restore:** panel button or `selfpost-backup` CLI — self-contained archive:
|
||||||
|
`data/` (SQLite snapshot + tree minus `log/`, the setup token and any `tls/`
|
||||||
|
under `/data`), `docker-compose.yml`, `.env`, and `certs/` when present;
|
||||||
|
version check on restore. Requires the project directory mounted read-only at
|
||||||
|
`SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` in the default compose file). On the
|
||||||
|
first successful boot after restore, the panel runs one **Resync** — OpenDKIM's
|
||||||
|
tables and Postfix's sender map are re-derived from SQLite and both daemons are
|
||||||
|
reloaded, so drift between the extracted archive and the database is healed
|
||||||
|
before mail flows (same step as `POST /reload` on demand). Stopped-container
|
||||||
|
`tar` of `./data` alone remains possible for state-only copies (see guide).
|
||||||
|
|
||||||
|
**Optional encryption** of the two secret-bearing downloads
|
||||||
|
([internal/secretfile](../internal/secretfile/secretfile.go)): password →
|
||||||
|
scrypt → AES-256-GCM over 64 KiB chunks, each authenticated with the header,
|
||||||
|
its counter and an end-of-stream flag (so truncation and reordering fail to
|
||||||
|
open). Full backup `.tar.gz` → `.spbk` (SelfPost backup), domain export
|
||||||
|
`.json` → `.spde` (SelfPost domain export); the plain forms remain the
|
||||||
|
default. Domain import detects the envelope by magic bytes; an encrypted full
|
||||||
|
backup is converted back with `selfpost-backup -decrypt` before restore.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security (summary)
|
||||||
|
|
||||||
|
Mandatory checklist: [security.md](security.md). Accepted trade-offs (CSRF
|
||||||
|
origin check, no CSRF tokens) are documented there separately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Public env vars: [guide § Environment variables](guide.md#environment-variables).
|
||||||
|
Regression test: [cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go).
|
||||||
|
|
||||||
|
**Internal env vars.** The following are read by the panel or startup scripts
|
||||||
|
but are not part of the operator interface — not meant to be changed in a
|
||||||
|
normal deployment; documented here so an accidental override reads as
|
||||||
|
unsupported rather than as a missing doc:
|
||||||
|
|
||||||
|
- **Panel paths and tuning:** `SELFPOST_DATA_DIR` (`/data`), `SELFPOST_DB_PATH`
|
||||||
|
(`/data/selfpost.db`), `SELFPOST_SETUP_TOKEN_FILE`
|
||||||
|
(`/data/setup-token`), `PANEL_HTTP_ADDR` (`:8080`),
|
||||||
|
`JOURNAL_MILTER_SOCKET` (`/run/selfpost/journal.sock`), `MAIL_LOG`
|
||||||
|
(`/data/log/mail.log` — read by the panel and written by Postfix, so a change
|
||||||
|
here has to be matched in `build/postfix-config.sh`),
|
||||||
|
`PANEL_COOKIE_SECURE` (`true`), `OPENDKIM_SOCKET`
|
||||||
|
(`/run/opendkim/opendkim.sock`), `OPENDKIM_DIR` (`/data/opendkim`),
|
||||||
|
`DKIM_SELECTOR_DEFAULT` (`selfpost`), `SASL_DB_PATH`
|
||||||
|
(`/data/sasl/sasldb2`), `SASL_REALM` (defaults to `SELFPOST_HOSTNAME`),
|
||||||
|
`POSTFIX_DIR` (`/data/postfix`), `POSTFIX_SENDER_LOGIN_MAPS`
|
||||||
|
(`/data/postfix/sender_login_maps` — read by Postfix config only; the panel
|
||||||
|
always writes `<POSTFIX_DIR>/sender_login_maps`, so overriding this env alone
|
||||||
|
desyncs the map Postfix reads from the file the panel maintains),
|
||||||
|
`POSTFIX_QUEUE_DIR` (`/data/postfix/queue` — set in `build/postfix-config.sh`),
|
||||||
|
`SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` — operator project directory for
|
||||||
|
full backups; mount `.:/selfpost-deploy:ro` in compose).
|
||||||
|
- **Milter and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`),
|
||||||
|
`MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`),
|
||||||
|
`MILTER_WAIT_TIMEOUT` (`30` seconds).
|
||||||
|
- **Background maintenance:** `TLS_RELOAD_INTERVAL_SECONDS` (`86400` — daily
|
||||||
|
`postfix reload` to pick up renewed certificates),
|
||||||
|
`LOGROTATE_INTERVAL_SECONDS` (`21600` — check `mail.log` rotation every six
|
||||||
|
hours; logrotate keeps 14 rotated files on a daily schedule, and each
|
||||||
|
rotation triggers `postfix reload`).
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 854 KiB |
@@ -0,0 +1,44 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>SelfPost — panel mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var map = {
|
||||||
|
login: "login.html",
|
||||||
|
setup: "setup.html",
|
||||||
|
status: "status.html",
|
||||||
|
domains: "domains.html",
|
||||||
|
domain: "domain.html",
|
||||||
|
"domain-delete": "domain-delete.html",
|
||||||
|
deliveries: "deliveries.html",
|
||||||
|
delivery: "delivery.html",
|
||||||
|
"mail-queue": "mail-queue.html",
|
||||||
|
"system-log": "system-log.html",
|
||||||
|
backup: "backup.html",
|
||||||
|
users: "users.html",
|
||||||
|
"user-form": "user-form.html",
|
||||||
|
"user-delete": "user-delete.html",
|
||||||
|
settings: "settings.html",
|
||||||
|
inbound: "inbound.html",
|
||||||
|
"inbound-domain": "inbound-domain.html",
|
||||||
|
"inbound-backup": "inbound-backup.html",
|
||||||
|
"inbound-delete": "inbound-delete.html",
|
||||||
|
dmarc: "dmarc.html",
|
||||||
|
"dmarc-domain": "dmarc-domain.html",
|
||||||
|
"dmarc-report": "dmarc-report.html",
|
||||||
|
"dmarc-report-fail": "dmarc-report-fail.html",
|
||||||
|
help: "help.html"
|
||||||
|
};
|
||||||
|
var h = location.hash.replace(/^#/, "");
|
||||||
|
location.replace((map[h] || "status.html") + location.search);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>Макеты собраны отдельными страницами. Откройте <a href="status.html">Status</a> или <a href="index.html">оглавление</a>.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Backup & migration — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-backup" data-page="backup" data-nav="backup" data-title="Backup" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<h1>Backup & migration</h1>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Full backup</h2>
|
||||||
|
<p class="muted">Download a self-contained backup — <code>data/</code> (database, DKIM keys, credentials, Postfix queue), <code>docker-compose.yml</code>, <code>.env</code>, and <code>certs/</code>. Extract into an empty project directory on a new machine, adjust hostname if needed, and start the <strong>same SelfPost version</strong> before first boot. The reverse-proxy vhost is not included.</p>
|
||||||
|
<p class="muted"><strong>The backup file is a secret.</strong> Encrypting it is the simplest way to store it: the download is then a <code>.spbk</code> that only the password opens.</p>
|
||||||
|
<div class="encrypt">
|
||||||
|
<label class="check"><input type="checkbox" checked> <span>Encrypt with a password</span></label>
|
||||||
|
<div class="encrypt-fields">
|
||||||
|
<label>Password</label><input type="password" placeholder="at least 8 characters">
|
||||||
|
<label>Repeat password</label><input type="password">
|
||||||
|
<p class="muted">Keep this password: without it the file cannot be opened.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions-row"><button type="button">Download full backup</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Import a domain</h2>
|
||||||
|
<p class="muted">Move a single domain here from another SelfPost instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS record needs no change. The export file is a secret, like a full backup.</p>
|
||||||
|
<label>Domain export file</label>
|
||||||
|
<input type="file">
|
||||||
|
<label>Password</label>
|
||||||
|
<input type="password" autocomplete="off">
|
||||||
|
<p class="muted">Needed for a <code>.spde</code> file. Leave empty for plain <code>.json</code>.</p>
|
||||||
|
<div class="actions-row"><button type="button">Import domain</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Deliveries — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-deliveries" data-page="deliveries" data-nav="deliveries" data-title="Deliveries">
|
||||||
|
<main class="stack">
|
||||||
|
<h1>Deliveries</h1>
|
||||||
|
<form class="toolbar fill" action="#" onsubmit="return false">
|
||||||
|
<div class="field"><label>Domain</label>
|
||||||
|
<select><option>All domains</option><option>example.com</option><option>alerts.example.com</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Application</label>
|
||||||
|
<select><option>All applications</option><option>newsletter</option><option>billing</option></select>
|
||||||
|
</div>
|
||||||
|
<button type="button">Filter</button>
|
||||||
|
</form>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<table class="desk-only">
|
||||||
|
<thead><tr><th>Time</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="time muted">2026-08-15 20:14:02</td>
|
||||||
|
<td>billing@example.com</td>
|
||||||
|
<td>ada@example.net</td>
|
||||||
|
<td class="subject"><span>Invoice #4412</span></td>
|
||||||
|
<td class="status"><span class="st st-warn">deferred</span></td>
|
||||||
|
<td class="actions"><a href="delivery.html">Details</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="time muted">2026-08-15 20:11:40</td>
|
||||||
|
<td>news@example.com</td>
|
||||||
|
<td>list-bounces@example.net</td>
|
||||||
|
<td class="subject"><span>August digest</span></td>
|
||||||
|
<td class="status"><span class="st st-ok">delivered</span></td>
|
||||||
|
<td class="actions"><a href="delivery.html">Details</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="time muted">2026-08-15 19:02:11</td>
|
||||||
|
<td>alerts@alerts.example.com</td>
|
||||||
|
<td>noreply@blocked.example</td>
|
||||||
|
<td class="subject"><span>Disk 92% on web-3</span></td>
|
||||||
|
<td class="status"><span class="st st-error">bounced</span></td>
|
||||||
|
<td class="actions"><a href="delivery.html">Details</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="time muted">2026-08-15 18:44:09</td>
|
||||||
|
<td>news@example.com</td>
|
||||||
|
<td>sam@example.org</td>
|
||||||
|
<td class="subject"><span>August digest</span></td>
|
||||||
|
<td class="status"><span class="st st-ok">delivered</span></td>
|
||||||
|
<td class="actions"><a href="delivery.html">Details</a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul class="phone-list phone-only">
|
||||||
|
<li>
|
||||||
|
<a href="delivery.html">
|
||||||
|
<span class="st st-warn">deferred</span><span class="when">20:14</span>
|
||||||
|
<span class="meta">billing@example.com → ada@example.net</span>
|
||||||
|
<span class="subj">Invoice #4412</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="delivery.html">
|
||||||
|
<span class="st st-ok">delivered</span><span class="when">20:11</span>
|
||||||
|
<span class="meta">news@example.com → list-bounces@example.net</span>
|
||||||
|
<span class="subj">August digest</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="delivery.html">
|
||||||
|
<span class="st st-error">bounced</span><span class="when">19:02</span>
|
||||||
|
<span class="meta">alerts@alerts.example.com → noreply@blocked.example</span>
|
||||||
|
<span class="subj">Disk 92% on web-3</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">Page 1 of 4 · <a href="deliveries.html">Older →</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Invoice #4412 — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-delivery" data-page="delivery" data-nav="deliveries" data-title="Message">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1 class="subject">Invoice #4412</h1>
|
||||||
|
<p class="route">
|
||||||
|
<span class="addr">billing@example.com</span>
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<span class="addr">ada@example.net</span>
|
||||||
|
<span class="st st-warn">deferred</span>
|
||||||
|
</p>
|
||||||
|
<a class="back" href="deliveries.html">← Back to deliveries</a>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Message</h2>
|
||||||
|
<div class="facts">
|
||||||
|
<div class="fact"><span class="fact-label">Domain</span><span class="fact-value">example.com</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Application</span><span class="fact-value">billing</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Accepted</span><span class="fact-value">2026-08-15 20:14:02 UTC</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Status reported</span><span class="fact-value">2026-08-15 20:14:08 UTC</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Queue id</span><span class="fact-value mono">4C3A1E2F1A</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Journal id</span><span class="fact-value mono">1842</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>History</h2>
|
||||||
|
<ol class="timeline">
|
||||||
|
<li class="event lvl-ok">
|
||||||
|
<p class="event-time">2026-08-15 20:14:02 UTC</p>
|
||||||
|
<p class="event-title"><span class="st st-ok">accepted</span> Received by the relay</p>
|
||||||
|
<p class="event-detail muted">SASL login billing, queued as 4C3A1E2F1A.</p>
|
||||||
|
</li>
|
||||||
|
<li class="event lvl-warn">
|
||||||
|
<p class="event-time">2026-08-15 20:14:08 UTC</p>
|
||||||
|
<p class="event-title"><span class="st st-warn">deferred</span> Receiving MX asked to try later</p>
|
||||||
|
<p class="event-detail muted">Postfix retries: first after 5 minutes, then with increasing gaps up to 1 hour 7 minutes, for up to 5 days. There is no fixed attempt count — a deferred message stays in the queue until it is delivered or that lifetime runs out.</p>
|
||||||
|
</li>
|
||||||
|
<li class="event pending">
|
||||||
|
<p class="event-time">not yet</p>
|
||||||
|
<p class="event-title"><span class="st st-unknown">delivery</span> Waiting on the next retry</p>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Delivery log</h2>
|
||||||
|
<table class="log desk-only">
|
||||||
|
<thead><tr><th>Time</th><th>Message</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td class="time muted">20:14:02</td><td class="log-text">postfix/smtpd[221]: 4C3A1E2F1A: client=203.0.113.40, sasl_username=billing</td></tr>
|
||||||
|
<tr><td class="time muted">20:14:02</td><td class="log-text">postfix/cleanup[224]: 4C3A1E2F1A: message-id=<4412@example.com></td></tr>
|
||||||
|
<tr><td class="time muted">20:14:08</td><td class="log-text">postfix/smtp[230]: 4C3A1E2F1A: to=<ada@example.net>, relay=mx.example.net[198.51.100.20]:25, delay=6, status=deferred (450 4.2.1 mailbox busy)</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="phone-only">
|
||||||
|
<p class="muted" style="margin:0.6rem 0 0.2rem">20:14:08</p>
|
||||||
|
<span class="code">status=deferred (450 4.2.1 mailbox busy)</span>
|
||||||
|
<p class="muted" style="margin:0.6rem 0 0.2rem">20:14:02</p>
|
||||||
|
<span class="code">client=203.0.113.40, sasl_username=billing</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>example.com — DMARC — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-dmarc-domain" data-page="dmarc-domain" data-nav="dmarc" data-title="example.com DMARC">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1>example.com <span class="tag future">candidate</span></h1>
|
||||||
|
<a class="back g-only" href="dmarc.html">← All DMARC reports</a>
|
||||||
|
<a class="back" href="domain.html">← Back to example.com</a>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Last 7 days <span class="st st-ok">pass</span></h2>
|
||||||
|
<p>98% aligned. Tightening <code>p=</code> looks reasonable.</p>
|
||||||
|
<p class="muted"><a href="domain.html">Domain DNS and rua=</a></p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Third-party senders</h2>
|
||||||
|
<p>google.com / 66.102.0.0/20 failed SPF and DKIM 6 times. Everyone else is this relay.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Reports</h2>
|
||||||
|
<table class="desk-only">
|
||||||
|
<thead><tr><th>Received</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="time muted">2026-08-15 06:12</td>
|
||||||
|
<td class="muted">google.com</td>
|
||||||
|
<td>14 Aug</td>
|
||||||
|
<td>412</td>
|
||||||
|
<td>6</td>
|
||||||
|
<td class="actions"><a href="dmarc-report.html">View</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="time muted">2026-08-14 06:08</td>
|
||||||
|
<td class="muted">google.com</td>
|
||||||
|
<td>13 Aug</td>
|
||||||
|
<td>390</td>
|
||||||
|
<td>2</td>
|
||||||
|
<td class="actions"><a href="dmarc-report.html">View</a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul class="phone-list phone-only">
|
||||||
|
<li>
|
||||||
|
<a href="dmarc-report.html">
|
||||||
|
<span class="st st-ok">412 pass</span><span class="when">14 Aug</span>
|
||||||
|
<span class="meta">google.com · 6 fail</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="dmarc-report.html">
|
||||||
|
<span class="st st-ok">390 pass</span><span class="when">13 Aug</span>
|
||||||
|
<span class="meta">google.com · 2 fail</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Sources · last 7 days</h2>
|
||||||
|
<table class="desk-only">
|
||||||
|
<thead><tr><th>Source</th><th>Pass</th><th>Fail</th><th>Disposition</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td class="muted">203.0.113.10 (this relay)</td><td>802</td><td>0</td><td><span class="st st-ok">none</span></td></tr>
|
||||||
|
<tr><td class="muted">google.com / 66.102.0.0/20</td><td>0</td><td>8</td><td><span class="st st-warn">none</span></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul class="phone-list phone-only">
|
||||||
|
<li><a href="dmarc-report.html"><span>this relay</span><span class="st st-ok">802 pass</span></a></li>
|
||||||
|
<li><a href="dmarc-report.html"><span>google.com / 66.102.0.0/20</span><span class="st st-warn">8 fail</span></a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>google.com report — alerts.example.com — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-dmarc-report-fail" data-page="dmarc-report-fail" data-nav="dmarc" data-title="DMARC report">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1 class="subject">google.com · 14 Aug</h1>
|
||||||
|
<p class="route">
|
||||||
|
<span class="addr">alerts.example.com</span>
|
||||||
|
<span class="st st-ok">88 pass</span>
|
||||||
|
<span class="st st-error">19 fail</span>
|
||||||
|
</p>
|
||||||
|
<a class="back" href="dmarc.html">← All DMARC reports</a>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Report</h2>
|
||||||
|
<div class="facts">
|
||||||
|
<div class="fact"><span class="fact-label">Reporter</span><span class="fact-value">google.com</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Report id</span><span class="fact-value mono">3178944098765432109</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Window</span><span class="fact-value">2026-08-14 00:00 – 2026-08-15 00:00 UTC</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Received</span><span class="fact-value">2026-08-15 04:40 UTC</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Contact</span><span class="fact-value">noreply-dmarc-support@google.com</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card attn">
|
||||||
|
<h2>Published policy</h2>
|
||||||
|
<div class="facts">
|
||||||
|
<div class="fact"><span class="fact-label">Domain</span><span class="fact-value">alerts.example.com</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">p / sp / pct</span><span class="fact-value mono">none / none / 100</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">adkim / aspf</span><span class="fact-value mono">r / r</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">rua</span><span class="fact-value">dmarc@mail.example.org</span></div>
|
||||||
|
</div>
|
||||||
|
<p class="muted">A third-party source is not in SPF or DKIM. Do not tighten <code>p=</code> until that sender is gone or aligned.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Records</h2>
|
||||||
|
<table class="desk-only">
|
||||||
|
<thead><tr><th>Source</th><th>Count</th><th>Disposition</th><th>SPF</th><th>DKIM</th><th>Header from</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="muted">203.0.113.10 (this relay)</td>
|
||||||
|
<td>88</td>
|
||||||
|
<td><span class="st st-ok">none</span></td>
|
||||||
|
<td><span class="st st-ok">pass</span></td>
|
||||||
|
<td><span class="st st-ok">pass</span></td>
|
||||||
|
<td>alerts.example.com</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="muted">198.51.100.80 (unknown)</td>
|
||||||
|
<td>19</td>
|
||||||
|
<td><span class="st st-error">none</span></td>
|
||||||
|
<td><span class="st st-error">fail</span></td>
|
||||||
|
<td><span class="st st-error">fail</span></td>
|
||||||
|
<td>alerts.example.com</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul class="phone-list phone-only">
|
||||||
|
<li>
|
||||||
|
<div class="item">
|
||||||
|
<span>this relay · 88</span><span class="st st-ok">pass</span>
|
||||||
|
<span class="meta">203.0.113.10 · SPF pass · DKIM pass</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div class="item">
|
||||||
|
<span>unknown · 19</span><span class="st st-error">fail</span>
|
||||||
|
<span class="meta">198.51.100.80 · SPF fail · DKIM fail</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">Parsed from the aggregate XML. Forensic (<code>ruf=</code>) samples are not stored.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>google.com report — example.com — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-dmarc-report" data-page="dmarc-report" data-nav="dmarc" data-title="DMARC report">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1 class="subject">google.com · 14 Aug</h1>
|
||||||
|
<p class="route">
|
||||||
|
<span class="addr">example.com</span>
|
||||||
|
<span class="st st-ok">412 pass</span>
|
||||||
|
<span class="st st-warn">6 fail</span>
|
||||||
|
</p>
|
||||||
|
<a class="back" href="dmarc-domain.html">← Back to example.com</a>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Report</h2>
|
||||||
|
<div class="facts">
|
||||||
|
<div class="fact"><span class="fact-label">Reporter</span><span class="fact-value">google.com</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Report id</span><span class="fact-value mono">3178944012345678901</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Window</span><span class="fact-value">2026-08-14 00:00 – 2026-08-15 00:00 UTC</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Received</span><span class="fact-value">2026-08-15 06:12 UTC</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Contact</span><span class="fact-value">noreply-dmarc-support@google.com</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Published policy</h2>
|
||||||
|
<div class="facts">
|
||||||
|
<div class="fact"><span class="fact-label">Domain</span><span class="fact-value">example.com</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">p / sp / pct</span><span class="fact-value mono">none / none / 100</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">adkim / aspf</span><span class="fact-value mono">r / r</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">rua</span><span class="fact-value">dmarc@mail.example.org</span></div>
|
||||||
|
</div>
|
||||||
|
<p class="muted"><code>p=none</code> does not affect delivery. Failures here are forwarding through Google, not this relay.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Records</h2>
|
||||||
|
<table class="desk-only">
|
||||||
|
<thead><tr><th>Source</th><th>Count</th><th>Disposition</th><th>SPF</th><th>DKIM</th><th>Header from</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="muted">203.0.113.10 (this relay)</td>
|
||||||
|
<td>412</td>
|
||||||
|
<td><span class="st st-ok">none</span></td>
|
||||||
|
<td><span class="st st-ok">pass</span></td>
|
||||||
|
<td><span class="st st-ok">pass</span></td>
|
||||||
|
<td>example.com</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="muted">66.102.1.44 (google.com)</td>
|
||||||
|
<td>6</td>
|
||||||
|
<td><span class="st st-warn">none</span></td>
|
||||||
|
<td><span class="st st-error">fail</span></td>
|
||||||
|
<td><span class="st st-error">fail</span></td>
|
||||||
|
<td>example.com</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul class="phone-list phone-only">
|
||||||
|
<li>
|
||||||
|
<div class="item">
|
||||||
|
<span>this relay · 412</span><span class="st st-ok">pass</span>
|
||||||
|
<span class="meta">203.0.113.10 · SPF pass · DKIM pass</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div class="item">
|
||||||
|
<span>google.com · 6</span><span class="st st-error">fail</span>
|
||||||
|
<span class="meta">66.102.1.44 · SPF fail · DKIM fail</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">Parsed from the aggregate XML. Forensic (<code>ruf=</code>) samples are not stored.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>DMARC reports — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-dmarc" data-page="dmarc" data-nav="dmarc" data-title="DMARC" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<div class="page-head">
|
||||||
|
<h1>DMARC reports <span class="tag future">candidate</span></h1>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Aggregate reports SelfPost accepted for <code>rua=</code>. Forensic (<code>ruf=</code>) is out of scope. Open a report for the parsed XML — not a dashboard.</p>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Ingest <span class="st st-ok">ok</span></h2>
|
||||||
|
<p>Last report 6 hours ago. Port 25 accepts only the configured report address.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>This week</h2>
|
||||||
|
<p>14 kept, 0 parse failures. Older summaries are pruned.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Recent reports</h2>
|
||||||
|
<table class="desk-only">
|
||||||
|
<thead><tr><th>Received</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="time muted">2026-08-15 06:12</td>
|
||||||
|
<td><a href="dmarc-domain.html">example.com</a></td>
|
||||||
|
<td class="muted">google.com</td>
|
||||||
|
<td>14 Aug</td>
|
||||||
|
<td>412</td>
|
||||||
|
<td>6</td>
|
||||||
|
<td class="actions"><a href="dmarc-report.html">View</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="time muted">2026-08-15 04:40</td>
|
||||||
|
<td>alerts.example.com</td>
|
||||||
|
<td class="muted">google.com</td>
|
||||||
|
<td>14 Aug</td>
|
||||||
|
<td>88</td>
|
||||||
|
<td>19</td>
|
||||||
|
<td class="actions"><a href="dmarc-report-fail.html">View</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="time muted">2026-08-14 06:08</td>
|
||||||
|
<td><a href="dmarc-domain.html">example.com</a></td>
|
||||||
|
<td class="muted">google.com</td>
|
||||||
|
<td>13 Aug</td>
|
||||||
|
<td>390</td>
|
||||||
|
<td>2</td>
|
||||||
|
<td class="actions"><a href="dmarc-report.html">View</a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul class="phone-list phone-only">
|
||||||
|
<li>
|
||||||
|
<a href="dmarc-report.html">
|
||||||
|
<span class="st st-ok">412 pass</span><span class="when">06:12</span>
|
||||||
|
<span class="meta">example.com · google.com · 14 Aug</span>
|
||||||
|
<span class="subj">6 fail</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="dmarc-report-fail.html">
|
||||||
|
<span class="st st-error">19 fail</span><span class="when">04:40</span>
|
||||||
|
<span class="meta">alerts.example.com · google.com · 14 Aug</span>
|
||||||
|
<span class="subj">88 pass</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="dmarc-report.html">
|
||||||
|
<span class="st st-ok">390 pass</span><span class="when">14 Aug</span>
|
||||||
|
<span class="meta">example.com · google.com · 13 Aug</span>
|
||||||
|
<span class="subj">2 fail</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">Each row is one aggregate XML SelfPost parsed. Domain names open the roll-up for that sending domain.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Delete example.com — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-domain-delete" data-page="domain-delete" data-nav="domains" data-title="Delete domain">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1>Delete example.com</h1>
|
||||||
|
<a class="back" href="domain.html">← Back to example.com</a>
|
||||||
|
</div>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Confirm deletion</h2>
|
||||||
|
<p>You are about to delete <strong>example.com</strong>. This will:</p>
|
||||||
|
<ul>
|
||||||
|
<li>permanently delete its DKIM signing key;</li>
|
||||||
|
<li>delete <strong>all 2 bound applications</strong>, including their SASL credentials;</li>
|
||||||
|
<li>reload OpenDKIM so the domain is no longer signed.</li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">This cannot be undone.</p>
|
||||||
|
<div class="actions-row"><button type="button" class="danger">Delete example.com</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>example.com — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-domain" data-page="domain" data-nav="domains" data-title="example.com">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1>example.com</h1>
|
||||||
|
<a class="back" href="domains.html">← All domains</a>
|
||||||
|
</div>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="card credential" id="new-credential">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>New application password</h2>
|
||||||
|
<label class="help-link" for="help-password" title="Why this is shown once">?</label>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Shown <strong>once only</strong> and not stored. Copy it now.</p>
|
||||||
|
<label>Login</label>
|
||||||
|
<div class="code-row"><span class="code">newsletter</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
<label>Password</label>
|
||||||
|
<div class="code-row"><span class="code">xK.9fQ2m-pL7wR</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card" id="dns-status">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>DNS status <span class="st st-ok">ok</span></h2>
|
||||||
|
<label class="help-link" for="help-dns" title="What these checks mean">?</label>
|
||||||
|
</div>
|
||||||
|
<div class="stack">
|
||||||
|
<div class="pair">
|
||||||
|
<div>
|
||||||
|
<label>DKIM <span class="st st-ok">ok</span></label>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<span class="code">mail._domainkey.example.com</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>SPF <span class="st st-ok">ok</span></label>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<span class="code">example.com</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div>
|
||||||
|
<label>DMARC <span class="st st-ok">ok</span></label>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<span class="code">_dmarc.example.com</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="muted">p=none; rua points at SelfPost ingest.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Report authorization</label>
|
||||||
|
<p class="muted">Not required (rua= is on a domain SelfPost accepts).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions-row"><button type="button">Re-check</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card" id="dkim-spf">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>DKIM and SPF records</h2>
|
||||||
|
<label class="help-link" for="help-records" title="What to publish">?</label>
|
||||||
|
</div>
|
||||||
|
<p class="check-col-title">DKIM</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<div class="code-row"><span class="code">mail._domainkey.example.com</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>Value</label>
|
||||||
|
<div class="code-row"><span class="code">v=DKIM1; k=rsa; p=MIIBIjANBgkqh…</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
<p class="check-col-title">SPF</p>
|
||||||
|
<div class="code-row"><span class="code">v=spf1 ip4:203.0.113.10 -all</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="dmarc-record">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>DMARC record</h2>
|
||||||
|
<label class="help-link" for="help-dmarc" title="What this record does">?</label>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<div class="code-row"><span class="code">_dmarc.example.com</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>Value <span class="muted">(from Settings)</span></label>
|
||||||
|
<div class="code-row"><span class="code">v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
<p class="muted"><a href="dmarc-domain.html">Open DMARC reports</a> for this domain.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card" id="connection">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Connection settings</h2>
|
||||||
|
<label class="help-link" for="help-connection" title="How to connect">?</label>
|
||||||
|
</div>
|
||||||
|
<label>Server</label>
|
||||||
|
<div class="code-row"><span class="code">mail.example.org</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
<label>Port and encryption</label>
|
||||||
|
<span class="code">465 — SSL/TLS (implicit)
|
||||||
|
587 — STARTTLS (submission)</span>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="add-application">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Add an application</h2>
|
||||||
|
<label class="help-link" for="help-apps" title="What an application is">?</label>
|
||||||
|
</div>
|
||||||
|
<form action="#" onsubmit="return false">
|
||||||
|
<label>Login</label>
|
||||||
|
<input placeholder="prod-server" autocomplete="off">
|
||||||
|
<label>Address mode</label>
|
||||||
|
<select><option>Any address of the domain</option><option>Specific addresses (list)</option></select>
|
||||||
|
<div class="actions-row"><button type="button">Create application</button></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card" id="applications">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Applications</h2>
|
||||||
|
<label class="help-link" for="help-apps" title="Address mode and rate limits">?</label>
|
||||||
|
</div>
|
||||||
|
<ul class="apps">
|
||||||
|
<li class="app-item">
|
||||||
|
<p class="app-login">newsletter</p>
|
||||||
|
<p class="app-addr muted">Any address of the domain — *@example.com</p>
|
||||||
|
<div class="actions">
|
||||||
|
<input class="panel-toggle t-edit" id="edit-1" type="checkbox" checked>
|
||||||
|
<label class="toggle for-edit" for="edit-1">Edit <span class="st st-ok">limit</span></label>
|
||||||
|
<button type="button">New password</button>
|
||||||
|
<button type="button" class="danger">Delete</button>
|
||||||
|
<div class="panel panel-edit">
|
||||||
|
<div class="pair">
|
||||||
|
<div>
|
||||||
|
<p class="check-col-title">Address mode</p>
|
||||||
|
<select><option selected>Any address of the domain</option><option>Specific addresses (list)</option></select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="check-col-title">Trusted-IP override <span class="st st-ok">active</span></p>
|
||||||
|
<textarea rows="1">203.0.113.40</textarea>
|
||||||
|
<div class="field-row equal">
|
||||||
|
<div class="field">
|
||||||
|
<label>Message limit</label>
|
||||||
|
<input value="80">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Window (seconds)</label>
|
||||||
|
<input value="60">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="app-item">
|
||||||
|
<p class="app-login">billing</p>
|
||||||
|
<p class="app-addr muted">Fixed list — invoices@example.com</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button">Edit</button>
|
||||||
|
<button type="button">New password</button>
|
||||||
|
<button type="button" class="danger">Delete</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card" id="domain-settings-dmarc">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>DMARC reports</h2>
|
||||||
|
<label class="help-link" for="help-domain-settings" title="Reports and rate limits">?</label>
|
||||||
|
</div>
|
||||||
|
<label>Aggregate reports (rua=)</label>
|
||||||
|
<select><option selected>Same as Settings (dmarc@mail.example.org)</option><option>No aggregate reports</option><option>Custom address</option></select>
|
||||||
|
<div class="actions-row"><button type="button">Save DMARC report settings</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="domain-settings-limit">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Level-2 rate limit <span class="st st-ok">active</span></h2>
|
||||||
|
</div>
|
||||||
|
<div class="field-row equal">
|
||||||
|
<div class="field">
|
||||||
|
<label>Message limit</label>
|
||||||
|
<input value="40">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Window (seconds)</label>
|
||||||
|
<input value="60">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions-row"><button type="button">Save limit</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card" id="export-domain">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Export domain</h2>
|
||||||
|
<label class="help-link" for="help-export" title="What the file contains">?</label>
|
||||||
|
</div>
|
||||||
|
<p class="muted"><strong>Secret file</strong> — transfer securely, or encrypt as <code>.spde</code>.</p>
|
||||||
|
<div class="encrypt">
|
||||||
|
<label class="check"><input type="checkbox"> <span>Encrypt with a password</span></label>
|
||||||
|
<div class="encrypt-fields">
|
||||||
|
<label>Password</label><input type="password">
|
||||||
|
<label>Repeat password</label><input type="password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions-row"><button type="button">Export domain</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="card g-only">
|
||||||
|
<h2>Danger zone</h2>
|
||||||
|
<p class="muted">Deletes the DKIM key and every application on this domain.</p>
|
||||||
|
<div class="actions-row"><a class="danger" href="domain-delete.html">Delete domain</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Domains — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-domains" data-page="domains" data-nav="domains" data-title="Domains">
|
||||||
|
<main class="stack">
|
||||||
|
<div class="page-head"><h1>Domains</h1></div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Domains</h2>
|
||||||
|
<form class="measure g-only" action="#" onsubmit="return false;">
|
||||||
|
<label for="dname">Add a sending domain</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="dname" placeholder="example.com" autocomplete="off">
|
||||||
|
<button type="button">Add domain</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<table class="desk-only">
|
||||||
|
<thead><tr><th>Domain</th><th>DNS</th><th>Selector</th><th>Apps</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><a href="domain.html">example.com</a></td>
|
||||||
|
<td><a class="st st-ok" href="domain.html">ok</a></td>
|
||||||
|
<td class="muted">mail</td>
|
||||||
|
<td>2</td>
|
||||||
|
<td class="actions g-only"><a class="danger" href="domain-delete.html">Delete</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="domain.html">alerts.example.com</a></td>
|
||||||
|
<td><a class="st st-warn" href="domain.html">warn</a></td>
|
||||||
|
<td class="muted">mail</td>
|
||||||
|
<td>1</td>
|
||||||
|
<td class="actions g-only"><a class="danger" href="domain-delete.html">Delete</a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul class="phone-list phone-only">
|
||||||
|
<li><a href="domain.html"><span>example.com</span><span class="st st-ok">ok</span><span class="meta">2 apps · selector mail</span></a></li>
|
||||||
|
<li><a href="domain.html"><span>alerts.example.com</span><span class="st st-warn">warn</span><span class="meta">1 app · selector mail</span></a></li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">The DNS badge is the worst of DKIM, SPF and DMARC. Open a domain for details.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Help — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-help" data-page="help" data-nav="help" data-title="Help">
|
||||||
|
<main class="stack">
|
||||||
|
<div class="page-head">
|
||||||
|
<h1>Help <span class="tag future">candidate</span></h1>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Short operator notes inside the panel — not a second copy of the full guide. Seeded from the Status explanations that do not belong on the cards (what a kernel counter is, why PTR is set at the host, what Reload does not touch). The cards themselves keep their readings, Detail columns, and the Configuration control.</p>
|
||||||
|
<div class="measure stack">
|
||||||
|
<div class="card">
|
||||||
|
<h2>On this panel</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="help.html">Machine</a> — kernel counters and the rate window</li>
|
||||||
|
<li><a href="help.html">TLS certificate</a> — port 465, reverse-proxy mount</li>
|
||||||
|
<li><a href="help.html">Hostname / reverse DNS</a> — forward-confirmed PTR at the hosting provider</li>
|
||||||
|
<li><a href="help.html">Mail queue retries</a> — time-based, no attempt budget</li>
|
||||||
|
<li><a href="help.html">Inbound</a> — not mailboxes; listed recipients or any address at the domain</li>
|
||||||
|
<li>Domain page — DNS, records, connection, applications, export (drawer from each card’s «?»)</li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">The same texts open in the drawer from Status’s «?» — so a card can stay a reading, not a paragraph, without throwing the reading away.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Machine</h2>
|
||||||
|
<p>CPU and memory are the container’s own readings, not the host’s spare capacity. Network is a short window, not a daily total. High CPU with an empty queue usually means something else on the box — not SelfPost “being slow to send”.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>TLS certificate</h2>
|
||||||
|
<p>Port 465 presents the certificate the reverse proxy (or the image) mounted. The panel does not issue certificates. A warn here is “expires soon”; an error is “missing or unreadable”, and clients will refuse submission.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Hostname / reverse DNS</h2>
|
||||||
|
<p>Forward-confirmed reverse DNS: the A/AAAA for <code>SELFPOST_HOSTNAME</code> must reverse to that same name. PTR is set at the hosting provider, not in this panel. Receiving networks use this pair as a cheap reputation check.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>backup.example.net — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-inbound-backup" data-page="inbound-backup" data-nav="inbound" data-title="backup.example.net" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1>backup.example.net</h1>
|
||||||
|
<a class="back" href="inbound.html">← All inbound domains</a>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>DNS status <span class="st st-error">error</span></h2>
|
||||||
|
<p class="muted">Cached a few minutes — use <em>Re-check</em> after publishing. Unlike outbound, inbound needs an MX pointing at this server.</p>
|
||||||
|
<label>MX <span class="st st-error">error</span></label>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<span class="code">backup.example.net</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">MX</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>Value</label>
|
||||||
|
<span class="code">10 mail.primary.example.net.</span>
|
||||||
|
<p class="error">No MX points at mail.example.org (this server). Publish the record below, or wait for DNS to propagate and Re-check.</p>
|
||||||
|
<div class="actions-row"><button type="button">Re-check</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Upstream</h2>
|
||||||
|
<p class="muted">Where accepted mail is handed off. Not a mailbox.</p>
|
||||||
|
<label>Host</label><input value="192.0.2.20">
|
||||||
|
<label>Port</label><input value="25">
|
||||||
|
<label>TLS to upstream</label>
|
||||||
|
<select><option>Opportunistic</option><option>Required</option><option selected>Off</option></select>
|
||||||
|
<div class="actions-row"><button type="button">Save upstream</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>MX record to publish</h2>
|
||||||
|
<p class="muted">Add this MX so the internet delivers here. Keep any existing primary MX if this is backup-MX.</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<div class="code-row"><span class="code">backup.example.net</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">MX</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>Value</label>
|
||||||
|
<div class="code-row"><span class="code">20 mail.example.org.</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card rcpt-mode">
|
||||||
|
<h2>Valid recipients</h2>
|
||||||
|
<p class="muted">Who this domain accepts on port 25. Same idea as an application’s address mode: a list, or any address at the domain.</p>
|
||||||
|
<label>Who to accept</label>
|
||||||
|
<select>
|
||||||
|
<option value="list">Listed addresses only</option>
|
||||||
|
<option value="any" selected>Any recipient at this domain</option>
|
||||||
|
</select>
|
||||||
|
<div class="rcpt-list">
|
||||||
|
<label>Addresses (one per line or comma-separated)</label>
|
||||||
|
<textarea rows="6">postmaster@backup.example.net
|
||||||
|
abuse@backup.example.net</textarea>
|
||||||
|
<p class="muted">Unknown recipients are rejected at RCPT so this relay does not generate backscatter.</p>
|
||||||
|
</div>
|
||||||
|
<p class="rcpt-any muted">Every address at this domain is accepted and forwarded. Prefer a list unless the upstream rejects unknowns — otherwise this relay may generate backscatter.</p>
|
||||||
|
<div class="actions-row"><button type="button">Save recipients</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Danger zone</h2>
|
||||||
|
<p class="muted">Stops accepting mail for this domain. Does not touch outbound sending domains.</p>
|
||||||
|
<div class="actions-row"><a class="danger" href="inbound-delete.html">Delete inbound domain</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Delete lists.example.com — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-inbound-delete" data-page="inbound-delete" data-nav="inbound" data-title="Delete inbound" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1>Delete lists.example.com</h1>
|
||||||
|
<a class="back" href="inbound-domain.html">← Back to lists.example.com</a>
|
||||||
|
</div>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Confirm deletion</h2>
|
||||||
|
<p>You are about to stop accepting inbound mail for <strong>lists.example.com</strong>. This will:</p>
|
||||||
|
<ul>
|
||||||
|
<li>remove it from <code>relay_domains</code> and the recipient map;</li>
|
||||||
|
<li>stop forwarding to <strong>10.0.0.8:25</strong>;</li>
|
||||||
|
<li>leave outbound sending domains untouched.</li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">This cannot be undone from a backup of inbound maps alone unless you restore one. Remove the MX if you do not plan to re-add the domain.</p>
|
||||||
|
<div class="actions-row"><button type="button" class="danger">Delete lists.example.com</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>lists.example.com — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-inbound-domain" data-page="inbound-domain" data-nav="inbound" data-title="lists.example.com" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1>lists.example.com</h1>
|
||||||
|
<a class="back" href="inbound.html">← All inbound domains</a>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>DNS status <span class="st st-ok">ok</span></h2>
|
||||||
|
<p class="muted">Cached a few minutes — use <em>Re-check</em> after publishing. Unlike outbound, inbound needs an MX pointing at this server.</p>
|
||||||
|
<label>MX <span class="st st-ok">ok</span></label>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<span class="code">lists.example.com</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">MX</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>Value</label>
|
||||||
|
<span class="code">10 mail.example.org.
|
||||||
|
20 mail.primary.example.net.</span>
|
||||||
|
<p class="muted">An MX points at mail.example.org (this server). Other MX values are the domain’s own primaries — they are not an error.</p>
|
||||||
|
<div class="actions-row"><button type="button">Re-check</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Upstream</h2>
|
||||||
|
<p class="muted">Where accepted mail is handed off. Not a mailbox.</p>
|
||||||
|
<label>Host</label><input value="10.0.0.8">
|
||||||
|
<label>Port</label><input value="25">
|
||||||
|
<label>TLS to upstream</label>
|
||||||
|
<select><option>Opportunistic</option><option selected>Required</option><option>Off</option></select>
|
||||||
|
<div class="actions-row"><button type="button">Save upstream</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>MX record to publish</h2>
|
||||||
|
<p class="muted">Add this MX so the internet delivers here. Keep any existing primary MX if this is backup-MX.</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<div class="code-row"><span class="code">lists.example.com</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">MX</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>Value</label>
|
||||||
|
<div class="code-row"><span class="code">10 mail.example.org.</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card rcpt-mode">
|
||||||
|
<h2>Valid recipients</h2>
|
||||||
|
<p class="muted">Who this domain accepts on port 25. Same idea as an application’s address mode: a list, or any address at the domain.</p>
|
||||||
|
<label>Who to accept</label>
|
||||||
|
<select>
|
||||||
|
<option value="list" selected>Listed addresses only</option>
|
||||||
|
<option value="any">Any recipient at this domain</option>
|
||||||
|
</select>
|
||||||
|
<div class="rcpt-list">
|
||||||
|
<label>Addresses (one per line or comma-separated)</label>
|
||||||
|
<textarea rows="6">staff@lists.example.com
|
||||||
|
postmaster@lists.example.com
|
||||||
|
abuse@lists.example.com</textarea>
|
||||||
|
<p class="muted">Unknown recipients are rejected at RCPT so this relay does not generate backscatter.</p>
|
||||||
|
</div>
|
||||||
|
<p class="rcpt-any muted">Every address at this domain is accepted and forwarded. Prefer a list unless the upstream rejects unknowns — otherwise this relay may generate backscatter.</p>
|
||||||
|
<div class="actions-row"><button type="button">Save recipients</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Danger zone</h2>
|
||||||
|
<p class="muted">Stops accepting mail for this domain. Does not touch outbound sending domains.</p>
|
||||||
|
<div class="actions-row"><a class="danger" href="inbound-delete.html">Delete inbound domain</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Inbound — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-inbound" data-page="inbound" data-nav="inbound" data-title="Inbound" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<div class="page-head">
|
||||||
|
<h1>Inbound <span class="tag">1.x</span></h1>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Backup-MX / forwarder. Accepts on port 25 only for listed domains. Recipients are either an allow-list or any address at that domain. Off by default in Compose.</p>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Forwarding</h2>
|
||||||
|
<form class="measure" action="#" onsubmit="return false">
|
||||||
|
<label for="iname">Add inbound domain</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="iname" placeholder="lists.example.com">
|
||||||
|
<button type="button">Add domain</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<table class="desk-only">
|
||||||
|
<thead><tr><th>Domain</th><th>DNS</th><th>Upstream</th><th>Recipients</th><th>TLS</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><a href="inbound-domain.html">lists.example.com</a></td>
|
||||||
|
<td><a class="st st-ok" href="inbound-domain.html">ok</a></td>
|
||||||
|
<td class="muted">10.0.0.8:25</td>
|
||||||
|
<td>12 listed</td>
|
||||||
|
<td><span class="st st-ok">required</span></td>
|
||||||
|
<td class="actions"><a class="danger" href="inbound-delete.html">Delete</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="inbound-backup.html">backup.example.net</a></td>
|
||||||
|
<td><a class="st st-error" href="inbound-backup.html">error</a></td>
|
||||||
|
<td class="muted">192.0.2.20:25</td>
|
||||||
|
<td>any</td>
|
||||||
|
<td><span class="st st-unknown">off</span></td>
|
||||||
|
<td class="actions"><a class="danger" href="inbound-delete.html">Delete</a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul class="phone-list phone-only">
|
||||||
|
<li><a href="inbound-domain.html"><span>lists.example.com</span><span class="st st-ok">ok</span><span class="meta">12 listed · 10.0.0.8:25</span></a></li>
|
||||||
|
<li><a href="inbound-backup.html"><span>backup.example.net</span><span class="st st-error">error</span><span class="meta">any recipient · 192.0.2.20:25</span></a></li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">The <em>DNS</em> badge is the MX check: at least one MX must point at this server. Results are cached for a few minutes; open a domain for the lookup and a <em>Re-check</em> button.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>SelfPost — макеты панели</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
</head>
|
||||||
|
<body class="doc">
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<p class="eyebrow">макеты · не панель</p>
|
||||||
|
<h1>Self<b>Post</b> — полное обновление интерфейса</h1>
|
||||||
|
<p>Проект системы — как собирать экраны, чтобы не чинить пустые колонки, высоту полей и ряды кнопок по одному. Каждый экран — отдельная страница, не простыня с якорями. Знак и палитра из листа утверждения не меняются. HTML в <code>docs/assets/panel-ui/</code>, не вёрстка <code>internal/web</code>.</p>
|
||||||
|
<div class="cta-row">
|
||||||
|
<a class="cta" href="status.html">Открыть Status</a>
|
||||||
|
<a class="cta quiet" href="system.html">Система интерфейса</a>
|
||||||
|
<a class="cta quiet" href="status.html?view=phone">Аварийный телефон</a>
|
||||||
|
<a class="cta quiet" href="status.html?theme=dark">Status, тёмная тема</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Система, не рескин</h2>
|
||||||
|
<p>Первый click-through повторил сетку живой панели, поэтому те же сбои чинились по одному. <a href="system.html">Грамматика</a> задаёт регионы (<code>measure</code> / <code>pair</code> / <code>fill</code>), карточку со слотом справки, <code>field-row</code> (Host и Type одной высоты) и <code>actions-row</code>. Макеты собраны только из этого: один экран — один HTML-файл.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Ширина окна</h2>
|
||||||
|
<p>Сейчас оболочка центрируется, колонка контента упирается в <code>64rem</code>, формы ещё уже — <code>48rem</code>. На широком мониторе поля пустые. Растянуть поля на 100% окна нельзя — они становятся нечитаемыми. Две независимые карточки рядом — можно.</p>
|
||||||
|
<h3>Гибрид</h3>
|
||||||
|
<p>Навигация прижата влево. Ops-страницы (Status, Deliveries, очередь, лог, Backup, Settings, DMARC, inbound) занимают остаток до <strong>90rem (~1440px)</strong>. Backup — полный бэкап ‖ импорт. Settings — учётные данные ‖ DMARC (у domain-admin остаётся одна узкая карточка). Таблица Users — <code>fill</code>. Одиночные формы (подтверждения, форма пользователя) остаются <strong>42rem и выровнены влево</strong>. Login и setup — по-прежнему узкий центрированный блок.</p>
|
||||||
|
<div class="compare">
|
||||||
|
<div>
|
||||||
|
<div class="row">сейчас, окно ~1600px</div>
|
||||||
|
<div class="bar">
|
||||||
|
<i class="navc">nav</i>
|
||||||
|
<i class="col" style="width:42%">64rem</i>
|
||||||
|
<i class="empty">пусто</i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="row">макет, ops-страница</div>
|
||||||
|
<div class="bar">
|
||||||
|
<i class="navc">nav</i>
|
||||||
|
<i class="fill">контент до 90rem</i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="row">макет, форма</div>
|
||||||
|
<div class="bar">
|
||||||
|
<i class="navc">nav</i>
|
||||||
|
<i class="col" style="width:28%">42rem</i>
|
||||||
|
<i class="empty">поле не растягивает инпуты</i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="note">На телефоне это не «узкий десктоп». Таблицы из шести колонок складываются в список. Навигация — выезжающая колонка на CSS-checkbox, без обязательного JavaScript (как панели Edit у приложений уже сейчас).</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Информационная архитектура</h2>
|
||||||
|
<div class="ia">
|
||||||
|
<div>
|
||||||
|
<h3>Global administrator</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Status</li>
|
||||||
|
<li>Domains → domain → delete</li>
|
||||||
|
<li>Deliveries → message</li>
|
||||||
|
<li>Mail queue</li>
|
||||||
|
<li>System log</li>
|
||||||
|
<li>Inbound <span class="tag">1.x</span> — скрыт, пока <code>INBOUND_RELAY_ENABLE</code> выключен</li>
|
||||||
|
<li>DMARC reports <span class="tag future">candidate</span></li>
|
||||||
|
<li>Backup, Users, Settings</li>
|
||||||
|
<li>Help <span class="tag future">candidate</span> — пункт навбара и drawer с карточек Status и страницы домена</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Domain-admin</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Domains (только назначенные) → domain → DMARC reports этого домена</li>
|
||||||
|
<li>Deliveries → message, включая пояснение retry</li>
|
||||||
|
<li>Settings — только учётные данные панели</li>
|
||||||
|
<li>Help drawer</li>
|
||||||
|
<li>Нет Status, очереди, system log, Backup, Users, inbound, глобального DMARC hub</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="muted" style="margin-top:1rem">В прототипе роль, inbound-флаг, ширина и тема переключаются в шапке. <code>contributing</code> и <code>schema-squash</code> экранов не имеют.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Знак и новые иконки</h2>
|
||||||
|
<p>Утверждённый stamp не заменяется. Для узкой шапки телефона предлагается уже существующий SP-значок (фавикон), а не компактный wordmark: на 390px высота важнее, чем прочитать «SelfPost» второй раз.</p>
|
||||||
|
<div class="mark-row">
|
||||||
|
<figure>
|
||||||
|
<img src="../selfpost-stamp.svg" width="220" height="100" alt="Полный знак">
|
||||||
|
<figcaption>эталон · login / setup</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure>
|
||||||
|
<img src="../selfpost-stamp-compact.svg" width="160" height="73" alt="Компактный знак">
|
||||||
|
<figcaption>колонка навбара</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure>
|
||||||
|
<img src="../selfpost-icon.svg" width="64" height="64" alt="SP">
|
||||||
|
<figcaption>телефон · шапка drawer</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
<h3>Иконки новых пунктов</h3>
|
||||||
|
<p class="muted">Тот же штрих 1.5, что у Status / Domains. Не пиктограммы-эмодзи и не заливка.</p>
|
||||||
|
<div class="icons-row">
|
||||||
|
<div class="icon-card">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 9.5h11"/><path d="M8 2.75v6.2"/><path d="M5.4 6.4 8 9.05 10.6 6.4"/><path d="M3.2 12.6h9.6"/></svg>
|
||||||
|
<span>Inbound</span>
|
||||||
|
</div>
|
||||||
|
<div class="icon-card">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 1.85 2.75 3.7v4.2c0 3.15 2.15 5.2 5.25 6.25 3.1-1.05 5.25-3.1 5.25-6.25V3.7Z"/><path d="M5.4 8.05 7.15 9.8 10.7 6.2"/></svg>
|
||||||
|
<span>DMARC</span>
|
||||||
|
</div>
|
||||||
|
<div class="icon-card">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M8 7.2V11.4"/><path d="M8 5.05v.01"/></svg>
|
||||||
|
<span>Help</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Экраны</h2>
|
||||||
|
<h3>Система</h3>
|
||||||
|
<div class="screen-index">
|
||||||
|
<a href="system.html">Грамматика и живые образцы</a>
|
||||||
|
</div>
|
||||||
|
<h3>Вход</h3>
|
||||||
|
<div class="screen-index">
|
||||||
|
<a href="login.html">Sign in</a>
|
||||||
|
<a href="setup.html">Create administrator</a>
|
||||||
|
</div>
|
||||||
|
<h3>Текущая панель</h3>
|
||||||
|
<div class="screen-index">
|
||||||
|
<a href="status.html">Status</a>
|
||||||
|
<a href="domains.html">Domains</a>
|
||||||
|
<a href="domain.html">Domain detail</a>
|
||||||
|
<a href="domain-delete.html">Delete domain</a>
|
||||||
|
<a href="deliveries.html">Deliveries</a>
|
||||||
|
<a href="delivery.html">Delivery (deferred + retries)</a>
|
||||||
|
<a href="mail-queue.html">Mail queue + retry policy</a>
|
||||||
|
<a href="system-log.html">System log</a>
|
||||||
|
<a href="backup.html">Backup</a>
|
||||||
|
<a href="users.html">Users</a>
|
||||||
|
<a href="user-form.html">Edit user</a>
|
||||||
|
<a href="user-delete.html">Delete user</a>
|
||||||
|
<a href="settings.html">Settings</a>
|
||||||
|
</div>
|
||||||
|
<h3>Roadmap</h3>
|
||||||
|
<div class="screen-index">
|
||||||
|
<a href="mail-queue.html">queue-retries — карточка на Mail queue</a>
|
||||||
|
<a href="delivery.html">queue-retries — history deferred</a>
|
||||||
|
<a href="inbound.html">Inbound list (колонка DNS = MX)</a>
|
||||||
|
<a href="inbound-domain.html">Inbound domain — DNS ok</a>
|
||||||
|
<a href="inbound-backup.html">Inbound domain — DNS error</a>
|
||||||
|
<a href="inbound-delete.html">Delete inbound domain</a>
|
||||||
|
<a href="status.html?inbound=0">Inbound выключен (пункта нет)</a>
|
||||||
|
<a href="dmarc.html">DMARC reports hub <span class="tag future">candidate</span></a>
|
||||||
|
<a href="dmarc-domain.html">DMARC — domain roll-up</a>
|
||||||
|
<a href="dmarc-report.html">DMARC report — aligned</a>
|
||||||
|
<a href="dmarc-report-fail.html">DMARC report — third-party fail</a>
|
||||||
|
<a href="help.html">Help <span class="tag future">candidate</span></a>
|
||||||
|
</div>
|
||||||
|
<h3>Аварийный телефон</h3>
|
||||||
|
<div class="screen-index">
|
||||||
|
<a href="status.html?view=phone">Status 390px — Overall, then queue/cert, then the rest</a>
|
||||||
|
<a href="mail-queue.html?view=phone">Mail queue 390px</a>
|
||||||
|
<a href="deliveries.html?view=phone">Deliveries 390px — список, не таблица</a>
|
||||||
|
<a href="delivery.html?view=phone">Delivery 390px</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style="border-bottom:none">
|
||||||
|
<h2>Как смотреть</h2>
|
||||||
|
<p>Откройте любой экран из списка — это отдельная страница, не якорь в одном файле. Шапка прототипа — не часть панели: роль, Desktop/Phone, inbound, светлая/тёмная. Копирайт экранов английский, как в продукте. Drawer Help открывается с «?» на Status или с карточек домена; на карточках остаются показания и колонка Detail.</p>
|
||||||
|
<p class="muted">После утверждения макетов вёрстка <code>panel.css</code> и шаблонов — отдельная задача. CSP и progressive enhancement в этом HTML не воспроизводятся один в один: здесь допустимы вещи, которые в панели останутся в файле стилей.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Sign in — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-login" data-page="login" data-nav="login" data-title="Sign in" data-auth="1">
|
||||||
|
<main class="auth">
|
||||||
|
<img class="mark" src="../selfpost-stamp.svg" width="330" height="150" alt="SelfPost">
|
||||||
|
<h1>Sign in</h1>
|
||||||
|
<div class="card">
|
||||||
|
<form action="status.html">
|
||||||
|
<label for="u">Username</label>
|
||||||
|
<input id="u" name="username" autocomplete="username">
|
||||||
|
<label for="p">Password</label>
|
||||||
|
<input id="p" name="password" type="password" autocomplete="current-password">
|
||||||
|
<div class="actions-row"><button type="submit">Sign in</button></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<p class="version" style="text-align:center">© Mixeme · <a href="#">License (AGPL-3.0)</a></p>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Mail queue — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-mail-queue" data-page="mail-queue" data-nav="queue" data-title="Mail queue" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<h1>Mail queue</h1>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>How delivery retries work</h2>
|
||||||
|
<p class="muted">This Postfix’s policy, read once at panel start. There is no maximum attempt count — only time.</p>
|
||||||
|
<div class="facts retry-facts">
|
||||||
|
<div class="fact"><span class="fact-label">First retry</span><span class="fact-value">5 minutes</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Later retries</span><span class="fact-value">doubling, cap 1 h 7 min</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Kept in queue</span><span class="fact-value">5 days</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Then</span><span class="fact-value">bounced</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Pending messages</h2>
|
||||||
|
<table class="desk-only">
|
||||||
|
<thead><tr><th>Queue id</th><th>Age</th><th>From</th><th>To</th><th>Size</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td class="mono">4C3A1E2F1A</td><td class="time muted">18 min</td><td>billing@example.com</td><td>ada@example.net</td><td>12 KiB</td></tr>
|
||||||
|
<tr><td class="mono">4C3A1E3010</td><td class="time muted">11 min</td><td>news@example.com</td><td>pat@slow.example</td><td>48 KiB</td></tr>
|
||||||
|
<tr><td class="mono">4C3A1E3102</td><td class="time muted">4 min</td><td>billing@example.com</td><td>ada@example.net</td><td>9 KiB</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul class="phone-list phone-only">
|
||||||
|
<li><a href="delivery.html"><span>4C3A1E2F1A</span><span class="when">18 min</span><span class="meta">billing@example.com → ada@example.net</span></a></li>
|
||||||
|
<li><a href="delivery.html"><span>4C3A1E3010</span><span class="when">11 min</span><span class="meta">news@example.com → pat@slow.example</span></a></li>
|
||||||
|
<li><a href="delivery.html"><span>4C3A1E3102</span><span class="when">4 min</span><span class="meta">billing@example.com → ada@example.net</span></a></li>
|
||||||
|
</ul>
|
||||||
|
<label>postqueue -p</label>
|
||||||
|
<span class="code">-Queue ID- --Size-- ----Arrival Time---- -Sender/Recipient-------
|
||||||
|
4C3A1E2F1A* 12288 Sat Aug 15 20:14:02 billing@example.com
|
||||||
|
ada@example.net
|
||||||
|
4C3A1E3010 49152 Sat Aug 15 20:21:18 news@example.com
|
||||||
|
pat@slow.example
|
||||||
|
-- 3 Kbytes in 3 Requests.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,715 @@
|
|||||||
|
/* SelfPost panel UI mockups. Tokens match internal/web/view/static/panel.css.
|
||||||
|
This file is a design artifact, not the panel stylesheet. */
|
||||||
|
|
||||||
|
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@200;300;400;500;600;700&display=swap");
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--font-sans: "IBM Plex Sans", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
--font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
--bg: #f4f2ed; --fg: #12161c;
|
||||||
|
--muted: #6b7684;
|
||||||
|
--card-bg: #fff;
|
||||||
|
--border: #dedcd7;
|
||||||
|
--control-border: #cbc8c1;
|
||||||
|
--input-bg: #fff;
|
||||||
|
--code-bg: #efede9;
|
||||||
|
--surface-bg: #eae7e0; --surface-bg-hover: #e0dcd3; --surface-open-bg: #d8d3c8;
|
||||||
|
--nav-active-bg: #ede4de;
|
||||||
|
--accent-fill: #7a3b2e; --accent-fill-hover: #632f25; --accent-text: #7a3b2e;
|
||||||
|
--on-accent: #fff;
|
||||||
|
--flash-bg: #edf6ef; --flash-border: #c2e0cc; --flash-fg: #2c6b43;
|
||||||
|
--credential-bg: #fcf6e4; --credential-border: #e0c874;
|
||||||
|
--danger-fill: #b42318; --danger-fill-hover: #912018;
|
||||||
|
--danger-bg: #fbedea; --danger-border: #efccc4; --danger-fg: #b42318; --danger-bg-hover: #f7dfda;
|
||||||
|
--st-ok-bg: #edf6ef; --st-ok-fg: #2c6b43; --st-ok-border: #c2e0cc;
|
||||||
|
--st-warn-bg: #fbf2e2; --st-warn-fg: #8a5510; --st-warn-border: #ebd5a6;
|
||||||
|
--st-error-bg: #fbedea; --st-error-fg: #b42318; --st-error-border: #efccc4;
|
||||||
|
--st-unknown-bg: #efede9; --st-unknown-fg: #6b7684; --st-unknown-border: #dedcd7;
|
||||||
|
--nav-w: 14rem;
|
||||||
|
--ops-max: 90rem;
|
||||||
|
--form-max: 42rem;
|
||||||
|
--auth-max: 24rem;
|
||||||
|
--gallery-h: 3.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-tokens {
|
||||||
|
--bg: #16181b; --fg: #e9e6e0;
|
||||||
|
--muted: #9aa1a9;
|
||||||
|
--card-bg: #1d2024;
|
||||||
|
--border: #2c2f34;
|
||||||
|
--control-border: #3a3e44;
|
||||||
|
--input-bg: #14161a;
|
||||||
|
--code-bg: #14161a;
|
||||||
|
--surface-bg: #23262b; --surface-bg-hover: #2c3036; --surface-open-bg: #343941;
|
||||||
|
--nav-active-bg: #2a1f1b;
|
||||||
|
--accent-fill: #8e4535; --accent-fill-hover: #a0503e; --accent-text: #ce7b66;
|
||||||
|
--flash-bg: #132318; --flash-border: #22452f; --flash-fg: #7fcb9b;
|
||||||
|
--credential-bg: #26210d; --credential-border: #5e5013;
|
||||||
|
--danger-fill: #9b2c22; --danger-fill-hover: #b0342a;
|
||||||
|
--danger-bg: #2a1412; --danger-border: #5e2721; --danger-fg: #eb9b92; --danger-bg-hover: #381a17;
|
||||||
|
--st-ok-bg: #132318; --st-ok-fg: #7fcb9b; --st-ok-border: #22452f;
|
||||||
|
--st-warn-bg: #2a2109; --st-warn-fg: #e5be72; --st-warn-border: #5e4b12;
|
||||||
|
--st-error-bg: #2a1412; --st-error-fg: #eb9b92; --st-error-border: #5e2721;
|
||||||
|
--st-unknown-bg: #23262b; --st-unknown-fg: #9aa1a9; --st-unknown-border: #2c2f34;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
html:not(.force-light) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html { scrollbar-gutter: stable; }
|
||||||
|
body {
|
||||||
|
font: 400 15px/1.5 var(--font-sans);
|
||||||
|
margin: 0; background: var(--bg); color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||||||
|
|
||||||
|
/* —— Gallery chrome (index + prototype toolbar) —— */
|
||||||
|
.gallery {
|
||||||
|
position: sticky; top: 0; z-index: 40;
|
||||||
|
display: flex; flex-wrap: wrap; align-items: center; gap: 0.55rem 1rem;
|
||||||
|
min-height: var(--gallery-h);
|
||||||
|
padding: 0.45rem 1rem;
|
||||||
|
background: var(--surface-bg);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.gallery a { color: var(--accent-text); }
|
||||||
|
.gallery .brand-mini {
|
||||||
|
font-family: var(--font-mono); font-size: 0.7rem; font-weight: 600;
|
||||||
|
letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent-text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.gallery .seg {
|
||||||
|
display: flex; flex-wrap: wrap; align-items: center; gap: 0.2rem 0.35rem;
|
||||||
|
}
|
||||||
|
.gallery .seg > span { color: var(--muted); margin-right: 0.15rem; }
|
||||||
|
.gallery .seg label {
|
||||||
|
margin: 0; font-weight: 500; cursor: pointer;
|
||||||
|
padding: 0.2rem 0.55rem; border: 1px solid var(--control-border);
|
||||||
|
border-radius: 5px; background: var(--card-bg); color: var(--fg);
|
||||||
|
}
|
||||||
|
#role-global:checked ~ .gallery label[for="role-global"],
|
||||||
|
#role-domain:checked ~ .gallery label[for="role-domain"],
|
||||||
|
#vp-desktop:checked ~ .gallery label[for="vp-desktop"],
|
||||||
|
#vp-phone:checked ~ .gallery label[for="vp-phone"],
|
||||||
|
#theme-light:checked ~ .gallery label[for="theme-light"],
|
||||||
|
#theme-dark:checked ~ .gallery label[for="theme-dark"] {
|
||||||
|
background: var(--nav-active-bg); color: var(--accent-text);
|
||||||
|
border-color: var(--accent-fill); font-weight: 600;
|
||||||
|
}
|
||||||
|
.gallery .check-lab {
|
||||||
|
display: flex; align-items: center; gap: 0.35rem; margin: 0; font-weight: 500; cursor: pointer;
|
||||||
|
}
|
||||||
|
.gallery .check-lab input { width: auto; margin: 0; }
|
||||||
|
.tag {
|
||||||
|
display: inline-block; font-family: var(--font-mono); font-size: 0.68rem;
|
||||||
|
font-weight: 500; letter-spacing: 0.06em; text-transform: uppercase;
|
||||||
|
padding: 0.12rem 0.4rem; border-radius: 4px;
|
||||||
|
background: var(--surface-bg); color: var(--muted); border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.tag.future { color: var(--st-warn-fg); border-color: var(--st-warn-border); background: var(--st-warn-bg); }
|
||||||
|
|
||||||
|
body:has(#theme-dark:checked) { background: #16181b; color: #e9e6e0; }
|
||||||
|
|
||||||
|
#theme-dark:checked ~ .gallery,
|
||||||
|
#theme-dark:checked ~ .app,
|
||||||
|
#theme-dark:checked ~ .help-drawer {
|
||||||
|
--bg: #16181b; --fg: #e9e6e0;
|
||||||
|
--muted: #9aa1a9;
|
||||||
|
--card-bg: #1d2024;
|
||||||
|
--border: #2c2f34;
|
||||||
|
--control-border: #3a3e44;
|
||||||
|
--input-bg: #14161a;
|
||||||
|
--code-bg: #14161a;
|
||||||
|
--surface-bg: #23262b; --surface-bg-hover: #2c3036; --surface-open-bg: #343941;
|
||||||
|
--nav-active-bg: #2a1f1b;
|
||||||
|
--accent-fill: #8e4535; --accent-fill-hover: #a0503e; --accent-text: #ce7b66;
|
||||||
|
--flash-bg: #132318; --flash-border: #22452f; --flash-fg: #7fcb9b;
|
||||||
|
--credential-bg: #26210d; --credential-border: #5e5013;
|
||||||
|
--danger-fill: #9b2c22; --danger-fill-hover: #b0342a;
|
||||||
|
--danger-bg: #2a1412; --danger-border: #5e2721; --danger-fg: #eb9b92; --danger-bg-hover: #381a17;
|
||||||
|
--st-ok-bg: #132318; --st-ok-fg: #7fcb9b; --st-ok-border: #22452f;
|
||||||
|
--st-warn-bg: #2a2109; --st-warn-fg: #e5be72; --st-warn-border: #5e4b12;
|
||||||
|
--st-error-bg: #2a1412; --st-error-fg: #eb9b92; --st-error-border: #5e2721;
|
||||||
|
--st-unknown-bg: #23262b; --st-unknown-fg: #9aa1a9; --st-unknown-border: #2c2f34;
|
||||||
|
background: var(--bg); color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— Prototype shell: nav docked left, content uses remaining width —— */
|
||||||
|
.app {
|
||||||
|
display: flex; align-items: flex-start; min-height: calc(100vh - var(--gallery-h));
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.stage { flex: 1 1 auto; min-width: 0; }
|
||||||
|
.nav {
|
||||||
|
position: sticky; top: var(--gallery-h); align-self: stretch;
|
||||||
|
flex: none; width: var(--nav-w);
|
||||||
|
display: flex; flex-direction: column; gap: 0.75rem;
|
||||||
|
padding: 1.1rem 0.85rem 1.4rem;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
max-height: calc(100vh - var(--gallery-h)); overflow-y: auto;
|
||||||
|
}
|
||||||
|
.nav .links, .nav .session { display: flex; flex-direction: column; gap: 0.1rem; }
|
||||||
|
.nav .session { margin-top: auto; padding-top: 0.75rem; border-top: 1px solid var(--border); }
|
||||||
|
.nav .session .muted { padding: 0 0.6rem; font-size: 0.85rem; overflow-wrap: anywhere; }
|
||||||
|
.nav .session-user { display: flex; align-items: center; gap: 0.5rem; }
|
||||||
|
.nav .brand { padding: 0; display: block; }
|
||||||
|
.nav .brand img { display: block; width: 100%; height: auto; }
|
||||||
|
.nav a, .nav [aria-current], .nav .current {
|
||||||
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
padding: 0.35rem 0.6rem; border-radius: 5px; text-decoration: none;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.nav a:hover { background: var(--surface-bg); }
|
||||||
|
.nav [aria-current], .nav .current {
|
||||||
|
font-weight: 600; color: var(--accent-text); background: var(--nav-active-bg);
|
||||||
|
box-shadow: inset 2px 0 0 var(--accent-fill);
|
||||||
|
}
|
||||||
|
body.page-status .n-status,
|
||||||
|
body.page-domains .n-domains,
|
||||||
|
body.page-domain .n-domains,
|
||||||
|
body.page-domain-delete .n-domains,
|
||||||
|
body.page-deliveries .n-deliveries,
|
||||||
|
body.page-delivery .n-deliveries,
|
||||||
|
body.page-mail-queue .n-queue,
|
||||||
|
body.page-system-log .n-log,
|
||||||
|
body.page-inbound .n-inbound,
|
||||||
|
body.page-inbound-domain .n-inbound,
|
||||||
|
body.page-inbound-backup .n-inbound,
|
||||||
|
body.page-inbound-delete .n-inbound,
|
||||||
|
body.page-dmarc .n-dmarc,
|
||||||
|
body.page-dmarc-domain .n-dmarc,
|
||||||
|
body.page-dmarc-report .n-dmarc,
|
||||||
|
body.page-dmarc-report-fail .n-dmarc,
|
||||||
|
body.page-backup .n-backup,
|
||||||
|
body.page-users .n-users,
|
||||||
|
body.page-user-form .n-users,
|
||||||
|
body.page-user-delete .n-users,
|
||||||
|
body.page-help .n-help,
|
||||||
|
body.page-settings .n-settings {
|
||||||
|
font-weight: 600; color: var(--accent-text); background: var(--nav-active-bg);
|
||||||
|
box-shadow: inset 2px 0 0 var(--accent-fill);
|
||||||
|
}
|
||||||
|
.nav .icon { width: 1rem; height: 1rem; flex: none; }
|
||||||
|
.nav button, .nav .btn-ghost {
|
||||||
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
margin: 0; padding: 0.35rem 0.6rem; font: inherit; font-size: 0.95rem; font-weight: 400;
|
||||||
|
color: var(--danger-fg); background: var(--danger-bg); border: 1px solid var(--danger-border);
|
||||||
|
border-radius: 5px; cursor: pointer; text-decoration: none; width: 100%;
|
||||||
|
}
|
||||||
|
.nav button:hover, .nav .btn-ghost:hover { background: var(--danger-bg-hover); }
|
||||||
|
|
||||||
|
.phone-bar { display: none; }
|
||||||
|
.phone-bar .icon { width: 1.1rem; height: 1.1rem; }
|
||||||
|
.mono { font-family: var(--font-mono); }
|
||||||
|
.sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
|
||||||
|
|
||||||
|
#feat-inbound:checked ~ .gallery label[for="feat-inbound"] {
|
||||||
|
background: var(--nav-active-bg); color: var(--accent-text);
|
||||||
|
border-color: var(--accent-fill);
|
||||||
|
}
|
||||||
|
.gallery label[for="feat-inbound"] {
|
||||||
|
margin: 0; font-weight: 500; cursor: pointer;
|
||||||
|
padding: 0.2rem 0.55rem; border: 1px solid var(--control-border);
|
||||||
|
border-radius: 5px; background: var(--card-bg); color: var(--fg);
|
||||||
|
}
|
||||||
|
#role-domain:checked ~ .gallery .g-only { display: none; }
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1 1 auto; min-width: 0; width: 100%;
|
||||||
|
padding: 1.5rem 1.5rem 2.5rem;
|
||||||
|
}
|
||||||
|
main.auth {
|
||||||
|
max-width: none; display: flex; flex-direction: column; align-items: center;
|
||||||
|
padding-top: 3rem;
|
||||||
|
}
|
||||||
|
main.auth > * { width: 100%; max-width: var(--auth-max); margin-left: auto; margin-right: auto; }
|
||||||
|
main.stack > .page-head,
|
||||||
|
main.stack > h1 { margin-bottom: 0; }
|
||||||
|
main.stack > .back { margin: 0; }
|
||||||
|
main.stack > .route { margin-top: 0; margin-bottom: 0; }
|
||||||
|
main.stack > p.muted { margin-top: 0; margin-bottom: 0; }
|
||||||
|
main.stack > .toolbar { margin-bottom: 0; max-width: var(--ops-max); }
|
||||||
|
|
||||||
|
body.page-login .nav,
|
||||||
|
body.page-setup .nav,
|
||||||
|
body.page-login .phone-bar,
|
||||||
|
body.page-setup .phone-bar { display: none !important; }
|
||||||
|
body.page-login .app,
|
||||||
|
body.page-setup .app { display: block; }
|
||||||
|
|
||||||
|
#role-domain:checked ~ .app .g-only { display: none !important; }
|
||||||
|
#role-domain:checked ~ .app .pair:has(> .g-only) {
|
||||||
|
display: block;
|
||||||
|
max-width: var(--form-max);
|
||||||
|
}
|
||||||
|
#feat-inbound:not(:checked) ~ .app .in-only { display: none !important; }
|
||||||
|
#feat-inbound:checked ~ .app .in-off { display: none !important; }
|
||||||
|
|
||||||
|
h1 { font-size: 1.5rem; font-weight: 300; letter-spacing: -0.01em; margin: 0 0 1rem; }
|
||||||
|
h1.subject { overflow-wrap: anywhere; }
|
||||||
|
h2 { font-size: 1.05rem; font-weight: 600; margin: 0 0 0.4rem; }
|
||||||
|
.mark { display: block; width: 100%; max-width: 24rem; height: auto; margin-bottom: 1.4rem; }
|
||||||
|
.card {
|
||||||
|
background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
padding: 1.25rem 1.4rem; margin: 0;
|
||||||
|
}
|
||||||
|
.card.narrow { max-width: var(--auth-max); }
|
||||||
|
.card + .card { margin-top: 1rem; }
|
||||||
|
.card.credential { border-color: var(--credential-border); background: var(--credential-bg); }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.error { color: var(--danger-fg); margin: 0.6rem 0 0; font-weight: 600; }
|
||||||
|
a { color: var(--accent-text); }
|
||||||
|
.back { display: block; margin: -0.4rem 0 1rem; }
|
||||||
|
.flash {
|
||||||
|
background: var(--flash-bg); border: 1px solid var(--flash-border); color: var(--flash-fg);
|
||||||
|
padding: 0.7rem 1rem; border-radius: 6px; margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.flash.error { background: var(--danger-bg); border-color: var(--danger-border); color: var(--danger-fg); }
|
||||||
|
.version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: var(--muted); }
|
||||||
|
.version a { color: inherit; }
|
||||||
|
|
||||||
|
label { display: block; font-weight: 600; margin: 0.9rem 0 0.3rem; }
|
||||||
|
label.check {
|
||||||
|
display: flex; align-items: center; gap: 0.5rem; margin: 0.45rem 0 0; font-weight: 600; cursor: pointer;
|
||||||
|
}
|
||||||
|
label.check input { width: auto; margin: 0; padding: 0; border: 0; background: none; }
|
||||||
|
input, select, textarea {
|
||||||
|
width: 100%; padding: 0.55rem 0.7rem; font-family: inherit; font-size: 1rem;
|
||||||
|
border: 1px solid var(--control-border); border-radius: 5px; background: var(--input-bg); color: inherit;
|
||||||
|
}
|
||||||
|
textarea { resize: vertical; }
|
||||||
|
fieldset {
|
||||||
|
margin: 0.9rem 0 0; padding: 0.55rem 0.85rem 0.85rem;
|
||||||
|
border: 1px solid var(--control-border); border-radius: 5px;
|
||||||
|
}
|
||||||
|
fieldset legend { padding: 0 0.25rem; font-weight: 600; }
|
||||||
|
fieldset > .muted { margin: 0.15rem 0 0.35rem; font-size: 0.85rem; font-weight: 400; }
|
||||||
|
|
||||||
|
button, a.btn, a.danger {
|
||||||
|
display: inline-block; margin-top: 1.1rem; padding: 0.6rem 1.1rem;
|
||||||
|
font: inherit; font-size: 1rem; font-weight: 600; text-decoration: none;
|
||||||
|
color: var(--on-accent); background: var(--accent-fill); border: 0; border-radius: 5px; cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover, a.btn:hover { background: var(--accent-fill-hover); }
|
||||||
|
button.danger, a.danger { background: var(--danger-fill); color: var(--on-accent); }
|
||||||
|
button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); }
|
||||||
|
form.inline { display: inline; margin: 0; }
|
||||||
|
.form-actions {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center;
|
||||||
|
margin-top: 1.1rem;
|
||||||
|
}
|
||||||
|
.form-actions > button, .form-actions > a.btn, .form-actions > a.danger { margin-top: 0; }
|
||||||
|
|
||||||
|
.st {
|
||||||
|
display: inline-block; padding: 0.14rem 0.45rem 0.28rem; border-radius: 4px;
|
||||||
|
font-family: var(--font-mono); font-size: 0.78rem; font-weight: 500; line-height: 1;
|
||||||
|
letter-spacing: 0.02em; vertical-align: middle; border: 1px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.st-ok { background: var(--st-ok-bg); color: var(--st-ok-fg); border-color: var(--st-ok-border); }
|
||||||
|
.st-warn { background: var(--st-warn-bg); color: var(--st-warn-fg); border-color: var(--st-warn-border); }
|
||||||
|
.st-error { background: var(--st-error-bg); color: var(--st-error-fg); border-color: var(--st-error-border); }
|
||||||
|
.st-unknown { background: var(--st-unknown-bg); color: var(--st-unknown-fg); border-color: var(--st-unknown-border); }
|
||||||
|
a.st, a.st:hover { color: inherit; text-decoration: none; }
|
||||||
|
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td {
|
||||||
|
text-align: left; padding: 0.5rem 0.45rem; border-bottom: 1px solid var(--border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
font-family: var(--font-mono); font-size: 0.75rem; font-weight: 500;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted);
|
||||||
|
}
|
||||||
|
td.actions { text-align: right; }
|
||||||
|
.metric { white-space: nowrap; }
|
||||||
|
.card:has(table) { overflow-x: auto; }
|
||||||
|
td.subject span {
|
||||||
|
display: block; max-width: 22rem;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
display: block; white-space: pre; overflow-x: auto; overflow-wrap: normal;
|
||||||
|
word-break: normal; font-family: var(--font-mono);
|
||||||
|
font-size: 0.85rem; background: var(--code-bg); border: 1px solid var(--border);
|
||||||
|
border-radius: 5px; padding: 0.7rem 0.8rem; margin: 0.3rem 0 0;
|
||||||
|
}
|
||||||
|
.code-row { display: flex; align-items: flex-start; gap: 0.5rem; }
|
||||||
|
.code-row .code { flex: 1; min-width: 0; margin-top: 0; padding-top: 0.45rem; padding-bottom: 0.45rem; }
|
||||||
|
.input-row { display: flex; align-items: stretch; gap: 0.5rem; }
|
||||||
|
.input-row input { flex: 1; min-width: 0; }
|
||||||
|
.input-row button { margin-top: 0; flex: none; white-space: nowrap; }
|
||||||
|
|
||||||
|
.split {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
|
||||||
|
gap: 1rem; margin-top: 1rem;
|
||||||
|
}
|
||||||
|
h1 + .split, h1 + form > .split { margin-top: 0; }
|
||||||
|
.split > .card { min-width: 0; }
|
||||||
|
.split > .card + .card { margin-top: 0; }
|
||||||
|
.split + .card, .card + .split, form:has(.split) + .card { margin-top: 1rem; }
|
||||||
|
#settings form { width: 100%; max-width: none; }
|
||||||
|
#settings .split,
|
||||||
|
#backup > .split {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-cols {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
|
||||||
|
gap: 1rem 1.2rem; margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.check-col { min-width: 0; }
|
||||||
|
.check-col-title { margin: 0.85rem 0 0.35rem; font-size: 1.05rem; font-weight: 600; }
|
||||||
|
.field-pair {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
|
||||||
|
gap: 0 1rem; margin-top: 0.45rem;
|
||||||
|
}
|
||||||
|
.field-pair > div { min-width: 0; }
|
||||||
|
.field-pair label { margin-top: 0.45rem; }
|
||||||
|
/* Host / name beside Type — size the type column to that token rather than
|
||||||
|
giving it half the row. Vertical padding matches .code-row .code so Type is
|
||||||
|
the same height as Host when Copy sits beside it. */
|
||||||
|
.field-pair.host-type {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
.field-pair.host-type .code {
|
||||||
|
padding-top: 0.45rem; padding-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
.field-type { width: max-content; }
|
||||||
|
.field-type .code {
|
||||||
|
width: fit-content; min-width: 2.75rem; text-align: center; box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0.5rem; margin-top: 1rem; }
|
||||||
|
.fact { min-width: 0; padding: 0.5rem 0.7rem; border-radius: 6px; background: var(--surface-bg); }
|
||||||
|
.fact-label {
|
||||||
|
display: block; font-family: var(--font-mono); font-size: 0.7rem; font-weight: 500;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted);
|
||||||
|
}
|
||||||
|
.fact-value { display: block; margin-top: 0.1rem; white-space: nowrap; overflow-x: auto; }
|
||||||
|
.fact-value.mono { font-family: var(--font-mono); font-size: 0.85rem; }
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
list-style: none; display: flex; flex-direction: column; gap: 1.1rem;
|
||||||
|
margin: 1rem 0 0; padding: 0.2rem 0 0.2rem 1.4rem; border-left: 2px solid var(--border);
|
||||||
|
}
|
||||||
|
.event { position: relative; min-width: 0; }
|
||||||
|
.event::before {
|
||||||
|
content: ""; position: absolute; left: -1.85rem; top: 0.3rem;
|
||||||
|
width: 0.65rem; height: 0.65rem; border-radius: 50%;
|
||||||
|
background: var(--card-bg); border: 2px solid var(--control-border);
|
||||||
|
}
|
||||||
|
.event.lvl-ok::before { border-color: var(--st-ok-fg); background: var(--st-ok-bg); }
|
||||||
|
.event.lvl-warn::before { border-color: var(--st-warn-fg); background: var(--st-warn-bg); }
|
||||||
|
.event.lvl-error::before { border-color: var(--st-error-fg); background: var(--st-error-bg); }
|
||||||
|
.event.pending { opacity: 0.7; }
|
||||||
|
.event.pending::before { border-style: dashed; }
|
||||||
|
.event-time { margin: 0; font-family: var(--font-mono); font-size: 0.75rem; color: var(--muted); }
|
||||||
|
.event-title { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin: 0.15rem 0 0; font-weight: 600; }
|
||||||
|
.event-detail { margin: 0.2rem 0 0; font-size: 0.9rem; }
|
||||||
|
|
||||||
|
.route { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-top: -0.5rem; margin-bottom: 1rem; }
|
||||||
|
.route .addr { font-family: var(--font-mono); font-size: 0.9rem; white-space: nowrap; overflow-x: auto; max-width: 100%; }
|
||||||
|
.route .arrow { color: var(--muted); }
|
||||||
|
|
||||||
|
table.log { margin-top: 1rem; }
|
||||||
|
table.log th:first-child, table.log td.time { width: 1%; }
|
||||||
|
table.log td.log-text {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.8rem; white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.copy, .actions button, .actions > label.toggle, .actions a.danger {
|
||||||
|
margin: 0; padding: 0.45rem 0.7rem; font-size: 0.8rem; font-weight: 600;
|
||||||
|
border-radius: 5px; white-space: nowrap;
|
||||||
|
background: var(--surface-bg); color: var(--accent-text); border: 1px solid var(--control-border);
|
||||||
|
}
|
||||||
|
button.copy:hover, .actions button:hover, .actions > label.toggle:hover { background: var(--surface-bg-hover); }
|
||||||
|
.actions button.danger, .actions a.danger { color: var(--danger-fg); background: var(--danger-bg); border-color: var(--danger-border); }
|
||||||
|
.actions { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; }
|
||||||
|
|
||||||
|
.apps { list-style: none; margin: 1rem 0 0; padding: 0; }
|
||||||
|
.app-item { padding: 0.9rem 0; border-top: 1px solid var(--border); }
|
||||||
|
.app-item:last-child { padding-bottom: 0; }
|
||||||
|
.app-login { margin: 0; font-family: var(--font-mono); font-weight: 600; }
|
||||||
|
.app-addr { margin: 0.15rem 0 0; white-space: nowrap; overflow-x: auto; }
|
||||||
|
.app-item .actions { margin-top: 0.7rem; }
|
||||||
|
.app-item .actions > .panel-toggle {
|
||||||
|
position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none;
|
||||||
|
}
|
||||||
|
.app-item .actions > .panel { display: none; flex: 1 0 100%; }
|
||||||
|
.app-item .actions > .t-edit:checked ~ .panel-edit { display: block; }
|
||||||
|
.app-item .actions > .t-edit:checked ~ .for-edit { background: var(--surface-open-bg); }
|
||||||
|
.app-item .actions > .panel button { margin-top: 0.9rem; }
|
||||||
|
|
||||||
|
.encrypt { margin-top: 1.2rem; }
|
||||||
|
.encrypt-fields {
|
||||||
|
display: none; margin-top: 0.8rem; margin-left: 1.6rem; padding-left: 0.9rem;
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
|
}
|
||||||
|
.encrypt:has(input[type="checkbox"]:checked) .encrypt-fields { display: block; }
|
||||||
|
.encrypt-fields label { margin-top: 0.7rem; }
|
||||||
|
|
||||||
|
.rcpt-list, .rcpt-any { display: none; }
|
||||||
|
.rcpt-mode:has(option[value="list"]:checked) .rcpt-list { display: block; }
|
||||||
|
.rcpt-mode:has(option[value="any"]:checked) .rcpt-any { display: block; }
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 0.6rem 1rem; align-items: end;
|
||||||
|
padding: 0.85rem 1rem; margin-bottom: 1rem;
|
||||||
|
background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
}
|
||||||
|
.toolbar .field { flex: 1 1 10rem; min-width: 8rem; }
|
||||||
|
.toolbar label { margin-top: 0; font-size: 0.8rem; }
|
||||||
|
.toolbar button { margin-top: 0; }
|
||||||
|
|
||||||
|
/* Status: hero + dense grid */
|
||||||
|
.status-hero {
|
||||||
|
display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.75rem 1.2rem;
|
||||||
|
padding: 1rem 1.25rem; margin-bottom: 1rem;
|
||||||
|
background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
}
|
||||||
|
.status-hero.attn,
|
||||||
|
.card.attn { border-color: var(--st-warn-border); }
|
||||||
|
.status-hero h1 { margin: 0; }
|
||||||
|
.status-hero .lead { margin: 0; color: var(--fg); }
|
||||||
|
.status-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(17.5rem, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.status-grid .card { margin: 0; }
|
||||||
|
.status-grid .card.attn { border-color: var(--st-warn-border); }
|
||||||
|
.metric-row {
|
||||||
|
display: flex; justify-content: space-between; gap: 0.75rem; align-items: baseline;
|
||||||
|
padding: 0.35rem 0; border-bottom: 1px solid var(--border); font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.metric-row:last-child { border-bottom: 0; }
|
||||||
|
.metric-row .k { font-weight: 600; }
|
||||||
|
.metric-row .v { font-family: var(--font-mono); font-size: 0.85rem; }
|
||||||
|
meter { width: 5rem; height: 0.7rem; vertical-align: middle; margin-right: 0.4rem; }
|
||||||
|
.card-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.card-head h2 { margin: 0; }
|
||||||
|
.help-link {
|
||||||
|
flex: none; width: 1.35rem; height: 1.35rem; display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: 50%; border: 1px solid var(--control-border); color: var(--muted);
|
||||||
|
text-decoration: none; font-family: var(--font-mono); font-size: 0.75rem; font-weight: 600;
|
||||||
|
background: var(--surface-bg); cursor: pointer; margin: 0;
|
||||||
|
}
|
||||||
|
.help-link:hover { color: var(--accent-text); border-color: var(--accent-fill); }
|
||||||
|
|
||||||
|
.page-head {
|
||||||
|
display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 0.5rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.page-head h1 { margin: 0; }
|
||||||
|
|
||||||
|
/* Compact retry policy */
|
||||||
|
.retry-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: 0.5rem; margin-top: 0.7rem; }
|
||||||
|
|
||||||
|
.phone-list { display: none; list-style: none; margin: 0; padding: 0; }
|
||||||
|
.phone-list li { border-bottom: 1px solid var(--border); }
|
||||||
|
.phone-list a,
|
||||||
|
.phone-list .item {
|
||||||
|
display: grid; grid-template-columns: 1fr auto; gap: 0.15rem 0.7rem;
|
||||||
|
padding: 0.75rem 0.1rem; text-decoration: none; color: var(--fg);
|
||||||
|
}
|
||||||
|
.phone-list .when { font-family: var(--font-mono); font-size: 0.75rem; color: var(--muted); }
|
||||||
|
.phone-list .meta { grid-column: 1 / -1; font-family: var(--font-mono); font-size: 0.82rem; white-space: nowrap; overflow-x: auto; }
|
||||||
|
.phone-list .subj { grid-column: 1 / -1; margin: 0; }
|
||||||
|
|
||||||
|
/* Help drawer — CSS checkbox, no script required */
|
||||||
|
.help-drawer {
|
||||||
|
display: none; position: fixed; top: var(--gallery-h); right: 0; bottom: 0;
|
||||||
|
width: min(26rem, 100vw); z-index: 30;
|
||||||
|
background: var(--card-bg); border-left: 1px solid var(--border);
|
||||||
|
padding: 1.25rem 1.35rem 2rem; overflow-y: auto;
|
||||||
|
}
|
||||||
|
html:has(input[name="help"]:checked):not(:has(#help-off:checked)) .help-drawer,
|
||||||
|
html:has(input[name="help"]:checked):not(:has(#help-off:checked)) .help-scrim { display: block; }
|
||||||
|
.help-pane { display: none; }
|
||||||
|
html:has(#help-index:checked) .help-pane-index,
|
||||||
|
html:has(#help-status:checked) .help-pane-status,
|
||||||
|
html:has(#help-password:checked) .help-pane-password,
|
||||||
|
html:has(#help-dns:checked) .help-pane-dns,
|
||||||
|
html:has(#help-records:checked) .help-pane-records,
|
||||||
|
html:has(#help-dmarc:checked) .help-pane-dmarc,
|
||||||
|
html:has(#help-connection:checked) .help-pane-connection,
|
||||||
|
html:has(#help-apps:checked) .help-pane-apps,
|
||||||
|
html:has(#help-domain-settings:checked) .help-pane-domain-settings,
|
||||||
|
html:has(#help-export:checked) .help-pane-export { display: block; }
|
||||||
|
.help-scrim {
|
||||||
|
display: none; position: fixed; inset: var(--gallery-h) 0 0 0; z-index: 25;
|
||||||
|
background: rgba(18, 22, 28, 0.28);
|
||||||
|
}
|
||||||
|
.help-drawer h2 { margin-top: 1.2rem; }
|
||||||
|
.help-drawer h2:first-of-type,
|
||||||
|
.help-pane h2 { margin-top: 0; }
|
||||||
|
.help-drawer .toc { list-style: none; margin: 0.7rem 0 0; padding: 0; }
|
||||||
|
.help-drawer .toc li { margin: 0.35rem 0 0; }
|
||||||
|
.help-drawer .toc label {
|
||||||
|
margin: 0; font-weight: 600; color: var(--accent-text); cursor: pointer;
|
||||||
|
}
|
||||||
|
.help-drawer .more { margin-top: 1.2rem; font-size: 0.85rem; }
|
||||||
|
.help-drawer .more label {
|
||||||
|
display: inline; margin: 0; font-weight: 600; color: var(--accent-text); cursor: pointer;
|
||||||
|
}
|
||||||
|
.help-close {
|
||||||
|
position: absolute; top: 0.8rem; right: 0.8rem; margin: 0; padding: 0.35rem 0.6rem;
|
||||||
|
font-size: 0.8rem; font-weight: 600; background: var(--surface-bg); color: var(--fg);
|
||||||
|
border: 1px solid var(--control-border); border-radius: 5px; cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Phone preview (gallery toggle) and real narrow windows */
|
||||||
|
.nav-burger { display: none; }
|
||||||
|
|
||||||
|
#vp-phone:checked ~ .app {
|
||||||
|
position: relative;
|
||||||
|
width: 390px; margin: 0.75rem auto 2rem; min-height: 760px;
|
||||||
|
border: 1px solid var(--border); border-radius: 6px; overflow: hidden;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
#vp-phone:checked ~ .app .nav {
|
||||||
|
position: absolute; top: 0; left: 0; bottom: 0;
|
||||||
|
height: auto; max-height: none;
|
||||||
|
width: min(16rem, 82%); z-index: 20;
|
||||||
|
transform: translateX(-110%);
|
||||||
|
border-right: 1px solid var(--border); background: var(--card-bg);
|
||||||
|
}
|
||||||
|
#nav-open:checked ~ .app .nav { transform: translateX(0); }
|
||||||
|
#vp-phone:checked ~ .app .phone-bar {
|
||||||
|
display: flex; align-items: center; gap: 0.55rem;
|
||||||
|
margin: 0; padding: 0.65rem 0.85rem;
|
||||||
|
border-bottom: 1px solid var(--border); background: var(--card-bg);
|
||||||
|
}
|
||||||
|
#vp-phone:checked ~ .app .nav-burger {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 2.1rem; height: 2.1rem; margin: 0; padding: 0;
|
||||||
|
border: 1px solid var(--control-border); border-radius: 5px;
|
||||||
|
background: var(--surface-bg); cursor: pointer;
|
||||||
|
}
|
||||||
|
#vp-phone:checked ~ .app .phone-mark { width: 1.7rem; height: 1.7rem; }
|
||||||
|
#vp-phone:checked ~ .app .phone-bar .grow { flex: 1; font-weight: 600; }
|
||||||
|
#vp-phone:checked ~ .app main { padding: 1.5rem 0.9rem 2rem; }
|
||||||
|
#vp-phone:checked ~ .app .desk-only { display: none !important; }
|
||||||
|
#vp-phone:checked ~ .app .phone-only { display: block; }
|
||||||
|
#vp-phone:checked ~ .app .phone-list { display: block; }
|
||||||
|
#vp-phone:checked ~ .app .status-grid { display: flex; flex-direction: column; }
|
||||||
|
#vp-phone:checked ~ .app .status-grid .attn { order: -1; }
|
||||||
|
#vp-phone:checked ~ .app .pair { grid-template-columns: 1fr; }
|
||||||
|
#vp-phone:checked ~ .app .split,
|
||||||
|
#vp-phone:checked ~ .app .check-cols,
|
||||||
|
#vp-phone:checked ~ .app .field-pair:not(.host-type),
|
||||||
|
#vp-phone:checked ~ .app .facts,
|
||||||
|
#vp-phone:checked ~ .app .retry-facts {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
#vp-phone:checked ~ .app td.subject span { max-width: none; }
|
||||||
|
#vp-phone:checked ~ .help-drawer { width: 100vw; }
|
||||||
|
|
||||||
|
.phone-only { display: none; }
|
||||||
|
|
||||||
|
@media (max-width: 52rem) {
|
||||||
|
.app { min-height: calc(100vh - var(--gallery-h)); }
|
||||||
|
.nav {
|
||||||
|
position: fixed; top: var(--gallery-h); left: 0; bottom: 0;
|
||||||
|
z-index: 20; width: min(16rem, 84vw); max-height: none;
|
||||||
|
transform: translateX(-110%);
|
||||||
|
border-right: 1px solid var(--border); background: var(--card-bg);
|
||||||
|
}
|
||||||
|
#nav-open:checked ~ .app .nav { transform: translateX(0); }
|
||||||
|
.phone-bar {
|
||||||
|
display: flex; align-items: center; gap: 0.55rem;
|
||||||
|
margin: 0; padding: 0.65rem 0.85rem;
|
||||||
|
border-bottom: 1px solid var(--border); background: var(--card-bg);
|
||||||
|
}
|
||||||
|
.nav-burger {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 2.1rem; height: 2.1rem; margin: 0; padding: 0;
|
||||||
|
border: 1px solid var(--control-border); border-radius: 5px;
|
||||||
|
background: var(--surface-bg); cursor: pointer;
|
||||||
|
}
|
||||||
|
.phone-mark { width: 1.7rem; height: 1.7rem; }
|
||||||
|
.phone-bar .grow { flex: 1; font-weight: 600; }
|
||||||
|
main { padding: 1.5rem 0.9rem 2rem; }
|
||||||
|
.desk-only { display: none !important; }
|
||||||
|
.phone-only, .phone-list { display: block; }
|
||||||
|
.status-grid { display: flex; flex-direction: column; }
|
||||||
|
.status-grid .attn { order: -1; }
|
||||||
|
.split, .pair, #settings .split, #backup > .split,
|
||||||
|
.check-cols, .field-pair:not(.host-type), .facts, .retry-facts { grid-template-columns: 1fr; }
|
||||||
|
.help-drawer { width: 100vw; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Index page */
|
||||||
|
.wrap { max-width: 58rem; margin: 0 auto; padding: 2.5rem 1.25rem 5rem; }
|
||||||
|
.wrap > header { border-bottom: 2px solid var(--accent-fill); padding-bottom: 1.5rem; margin-bottom: 0.5rem; }
|
||||||
|
.eyebrow {
|
||||||
|
font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase; color: var(--accent-text); margin: 0 0 0.7rem;
|
||||||
|
}
|
||||||
|
.wrap h1 { font-size: clamp(1.7rem, 4vw, 2.4rem); font-weight: 200; }
|
||||||
|
.wrap h1 b { font-weight: 600; }
|
||||||
|
.wrap > header p { margin: 0; max-width: 62ch; color: var(--muted); }
|
||||||
|
.wrap section { border-bottom: 1px solid var(--border); padding: 2.2rem 0; }
|
||||||
|
.wrap h2 {
|
||||||
|
font-size: 0.8rem; font-weight: 500; margin: 0 0 1.1rem;
|
||||||
|
letter-spacing: 0.1em; text-transform: uppercase; font-family: var(--font-mono); color: var(--muted);
|
||||||
|
}
|
||||||
|
.wrap h3 { font-size: 1.05rem; font-weight: 600; margin: 1.2rem 0 0.4rem; }
|
||||||
|
.note {
|
||||||
|
background: #f1eae4; border-left: 2px solid var(--accent-fill);
|
||||||
|
padding: 0.9rem 1rem; font-size: 0.92rem; line-height: 1.6; max-width: 64ch; margin-top: 1rem;
|
||||||
|
}
|
||||||
|
#theme-dark:checked ~ .doc .note { background: #2a1f1b; }
|
||||||
|
.compare {
|
||||||
|
display: grid; grid-template-columns: 1fr; gap: 0.75rem; margin-top: 0.8rem;
|
||||||
|
}
|
||||||
|
.compare .row {
|
||||||
|
font-family: var(--font-mono); font-size: 0.75rem; color: var(--muted);
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
display: flex; height: 2.1rem; border: 1px solid var(--border); border-radius: 4px; overflow: hidden; background: var(--card-bg);
|
||||||
|
}
|
||||||
|
.bar i { display: flex; align-items: center; justify-content: center; font-style: normal; font-size: 0.7rem; }
|
||||||
|
.bar .navc { width: 14%; background: var(--surface-bg); color: var(--fg); border-right: 1px solid var(--border); }
|
||||||
|
.bar .col { background: var(--nav-active-bg); color: var(--accent-text); }
|
||||||
|
.bar .empty { flex: 1; background: var(--code-bg); color: var(--muted); }
|
||||||
|
.bar .fill { flex: 1; background: var(--st-ok-bg); color: var(--st-ok-fg); }
|
||||||
|
.ia { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||||
|
@media (max-width: 40rem) { .ia { grid-template-columns: 1fr; } }
|
||||||
|
.ia ul { margin: 0.3rem 0 0; padding-left: 1.1rem; }
|
||||||
|
.ia li { margin: 0.2rem 0; }
|
||||||
|
.screen-index { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: 0.4rem 1.5rem; }
|
||||||
|
.screen-index a { display: block; padding: 0.25rem 0; }
|
||||||
|
.icons-row { display: flex; flex-wrap: wrap; gap: 1.2rem; align-items: end; margin-top: 0.8rem; }
|
||||||
|
.icon-card {
|
||||||
|
background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
padding: 1rem 1.1rem 0.85rem; text-align: center; min-width: 7.5rem;
|
||||||
|
}
|
||||||
|
.icon-card svg { width: 1.5rem; height: 1.5rem; display: block; margin: 0 auto 0.45rem; }
|
||||||
|
.icon-card span { display: block; font-family: var(--font-mono); font-size: 0.7rem; color: var(--muted); }
|
||||||
|
.mark-row { display: flex; flex-wrap: wrap; gap: 1.5rem; align-items: end; }
|
||||||
|
.mark-row figure { margin: 0; }
|
||||||
|
.mark-row figcaption { font-family: var(--font-mono); font-size: 0.7rem; color: var(--muted); margin-top: 0.4rem; }
|
||||||
|
|
||||||
|
.cta {
|
||||||
|
display: inline-block; margin-top: 1rem; padding: 0.65rem 1.2rem;
|
||||||
|
background: var(--accent-fill); color: var(--on-accent); text-decoration: none;
|
||||||
|
font-weight: 600; border-radius: 5px;
|
||||||
|
}
|
||||||
|
.cta:hover { background: var(--accent-fill-hover); color: var(--on-accent); }
|
||||||
|
.cta-row { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-top: 1rem; }
|
||||||
|
.cta.quiet {
|
||||||
|
background: var(--surface-bg); color: var(--accent-text); border: 1px solid var(--control-border);
|
||||||
|
}
|
||||||
|
.cta.quiet:hover { background: var(--surface-bg-hover); color: var(--accent-text); }
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Settings — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-settings" data-page="settings" data-nav="settings" data-title="Settings">
|
||||||
|
<main class="stack">
|
||||||
|
<h1>Settings</h1>
|
||||||
|
<form class="stack" action="#" onsubmit="return false">
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Panel credentials</h2>
|
||||||
|
<p class="muted">These are the credentials for this control panel only. Applications keep their own logins and passwords, which are not affected.</p>
|
||||||
|
<label>Username</label><input value="admin" autocomplete="username">
|
||||||
|
<label>Current password</label><input type="password" autocomplete="current-password">
|
||||||
|
<label>New password</label><input type="password" autocomplete="new-password">
|
||||||
|
<label>Confirm new password</label><input type="password">
|
||||||
|
<div class="actions-row"><button type="button">Save changes</button></div>
|
||||||
|
<p class="muted">Leave both new-password fields empty to change the username or DMARC address only. Changing the password signs out every other session; this one stays signed in.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card g-only">
|
||||||
|
<h2>DMARC aggregate reports</h2>
|
||||||
|
<p class="muted">Default <code>rua=</code> for every sending domain (overridable per domain). When ingest is on, this can be an address SelfPost accepts.</p>
|
||||||
|
<label>Default report address</label>
|
||||||
|
<input type="email" value="dmarc@mail.example.org">
|
||||||
|
<p class="muted">When <code>rua=</code> points at another domain, that hub must publish a report-authorisation record. <a href="dmarc.html">DMARC reports</a> in the panel.</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<div class="code-row"><span class="code">mail.example.org._report._dmarc.example.com</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>Value</label>
|
||||||
|
<div class="code-row"><span class="code">v=DMARC1;</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
<label>Report authorization DNS <span class="st st-ok">ok</span></label>
|
||||||
|
<p class="muted">Published at mail.example.org._report._dmarc.example.com — aggregate reports addressed to dmarc@mail.example.org are authorised.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="fill g-only">
|
||||||
|
<div class="card" id="rate-limits">
|
||||||
|
<h2>Sending rate limits</h2>
|
||||||
|
<p class="muted">Level 1 is set in Compose; restart the container to change it. Domain and application ceilings live on each domain’s page.</p>
|
||||||
|
<div class="check-cols">
|
||||||
|
<div class="check-col">
|
||||||
|
<p class="check-col-title">Level 1 — per client IP</p>
|
||||||
|
<span class="code">100 messages / 60 seconds</span>
|
||||||
|
<p class="muted"><code>RATE_LIMIT_MESSAGES_PER_IP</code> / <code>RATE_LIMIT_WINDOW_SECONDS</code>. Hard ceiling for every connecting IP; the panel cannot raise a domain or application limit above this.</p>
|
||||||
|
</div>
|
||||||
|
<div class="check-col">
|
||||||
|
<p class="check-col-title">Level 2 — domain</p>
|
||||||
|
<p class="muted">Optional ceiling for <em>all</em> senders on a domain. When unset, only level 1 applies. Must be ≤ level 1.</p>
|
||||||
|
</div>
|
||||||
|
<div class="check-col">
|
||||||
|
<p class="check-col-title">Level 2 — application</p>
|
||||||
|
<p class="muted">Optional override for trusted IPs: a ceiling strictly above the domain limit (still ≤ level 1). Those IPs skip the domain check; everyone else stays under the domain (or level 1).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Create administrator — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-setup" data-page="setup" data-nav="setup" data-title="Create administrator" data-auth="1">
|
||||||
|
<main class="auth">
|
||||||
|
<img class="mark" src="../selfpost-stamp.svg" width="330" height="150" alt="SelfPost">
|
||||||
|
<h1>Create administrator</h1>
|
||||||
|
<div class="card">
|
||||||
|
<p class="muted">This one-time link creates the single panel administrator. After you submit, the link stops working for good.</p>
|
||||||
|
<form action="status.html">
|
||||||
|
<label>Username</label>
|
||||||
|
<input autocomplete="username">
|
||||||
|
<label>Password</label>
|
||||||
|
<input type="password" autocomplete="new-password">
|
||||||
|
<label>Confirm password</label>
|
||||||
|
<input type="password" autocomplete="new-password">
|
||||||
|
<div class="actions-row"><button type="submit">Create administrator</button></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
/* Shared chrome for panel UI page mockups. Each screen is its own HTML file;
|
||||||
|
this script injects radios, gallery, nav, sprite, and the help drawer so
|
||||||
|
file:// viewing does not need a module fetch. */
|
||||||
|
(function () {
|
||||||
|
if (document.body.dataset.shell === "1") return;
|
||||||
|
document.body.dataset.shell = "1";
|
||||||
|
var main = document.querySelector("main");
|
||||||
|
if (!main) return;
|
||||||
|
|
||||||
|
var page = document.body.getAttribute("data-page") || "";
|
||||||
|
var navKey = document.body.getAttribute("data-nav") || page;
|
||||||
|
var title = document.body.getAttribute("data-title") || document.title;
|
||||||
|
var globalOnly = document.body.getAttribute("data-global-only") === "1";
|
||||||
|
|
||||||
|
document.body.insertAdjacentHTML("afterbegin",
|
||||||
|
'<input class="ctrl" type="radio" name="role" id="role-global" checked>' +
|
||||||
|
'<input class="ctrl" type="radio" name="role" id="role-domain">' +
|
||||||
|
'<input class="ctrl" type="radio" name="vp" id="vp-desktop" checked>' +
|
||||||
|
'<input class="ctrl" type="radio" name="vp" id="vp-phone">' +
|
||||||
|
'<input class="ctrl" type="radio" name="theme" id="theme-light" checked>' +
|
||||||
|
'<input class="ctrl" type="radio" name="theme" id="theme-dark">' +
|
||||||
|
'<input class="ctrl" type="checkbox" id="feat-inbound" checked>' +
|
||||||
|
'<input class="ctrl" type="checkbox" id="nav-open">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-off" checked>' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-index">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-status">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-password">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-dns">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-records">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-dmarc">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-connection">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-apps">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-domain-settings">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-export">'
|
||||||
|
);
|
||||||
|
|
||||||
|
var q = new URLSearchParams(location.search);
|
||||||
|
if (q.get("view") === "phone") document.getElementById("vp-phone").checked = true;
|
||||||
|
if (q.get("role") === "domain") document.getElementById("role-domain").checked = true;
|
||||||
|
if (q.get("theme") === "dark") document.getElementById("theme-dark").checked = true;
|
||||||
|
if (q.get("inbound") === "0") document.getElementById("feat-inbound").checked = false;
|
||||||
|
|
||||||
|
var gallery =
|
||||||
|
'<header class="gallery">' +
|
||||||
|
'<a class="brand-mini" href="index.html">Макеты</a>' +
|
||||||
|
'<div class="seg"><span>Роль</span>' +
|
||||||
|
'<label for="role-global">Global</label>' +
|
||||||
|
'<label for="role-domain">Domain-admin</label></div>' +
|
||||||
|
'<div class="seg"><span>Ширина</span>' +
|
||||||
|
'<label for="vp-desktop">Desktop</label>' +
|
||||||
|
'<label for="vp-phone">Phone</label></div>' +
|
||||||
|
'<div class="seg"><span>Тема</span>' +
|
||||||
|
'<label for="theme-light">Light</label>' +
|
||||||
|
'<label for="theme-dark">Dark</label></div>' +
|
||||||
|
'<label class="g-only" for="feat-inbound">Inbound</label>' +
|
||||||
|
'<a href="index.html">Оглавление</a>' +
|
||||||
|
'<a href="system.html">Система</a>' +
|
||||||
|
"</header>";
|
||||||
|
|
||||||
|
var sprite =
|
||||||
|
'<svg class="sprite" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
|
||||||
|
'<symbol id="i-status" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1.25 8.5h2.9L6.2 3.4l3.1 9.4 1.9-4.3h3.55"/></symbol>' +
|
||||||
|
'<symbol id="i-domains" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6.25"/><path d="M1.9 8h12.2"/><path d="M8 1.75c1.85 1.8 2.8 4 2.8 6.25S9.85 12.45 8 14.25C6.15 12.45 5.2 10.25 5.2 8S6.15 3.55 8 1.75Z"/></symbol>' +
|
||||||
|
'<symbol id="i-deliveries" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14.25 1.75 1.6 6.6l5 2.05 2.05 5z"/><path d="M14.25 1.75 6.6 8.65"/></symbol>' +
|
||||||
|
'<symbol id="i-queue" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1.75 9.5h3.3l1 1.75h3.9l1-1.75h3.3v3.05a1.2 1.2 0 0 1-1.2 1.2H2.95a1.2 1.2 0 0 1-1.2-1.2z"/><path d="M1.75 9.5 3.4 3.2a1.25 1.25 0 0 1 1.2-.95h6.8a1.25 1.25 0 0 1 1.2.95l1.65 6.3"/></symbol>' +
|
||||||
|
'<symbol id="i-log" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3.75 1.75h5.1l3.4 3.4v8.05a1.05 1.05 0 0 1-1.05 1.05H3.75a1.05 1.05 0 0 1-1.05-1.05V2.8a1.05 1.05 0 0 1 1.05-1.05Z"/><path d="M8.85 1.75v3.4h3.4"/><path d="M5.35 8.6h5.3M5.35 11.1h3.5"/></symbol>' +
|
||||||
|
'<symbol id="i-inbound" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 9.5h11"/><path d="M8 2.75v6.2"/><path d="M5.4 6.4 8 9.05 10.6 6.4"/><path d="M3.2 12.6h9.6"/></symbol>' +
|
||||||
|
'<symbol id="i-dmarc" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 1.85 2.75 3.7v4.2c0 3.15 2.15 5.2 5.25 6.25 3.1-1.05 5.25-3.1 5.25-6.25V3.7Z"/><path d="M5.4 8.05 7.15 9.8 10.7 6.2"/></symbol>' +
|
||||||
|
'<symbol id="i-backup" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2.75" y="1.75" width="10.5" height="12.5" rx="1.15"/><path d="M2.75 8h10.5"/><path d="M6.4 4.85h3.2M6.4 11.15h3.2"/></symbol>' +
|
||||||
|
'<symbol id="i-users" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10.9 3.1a2.1 2.1 0 0 1 0 4.2"/><path d="M14.7 14.25a3.8 3.8 0 0 0-3.9-3.65"/><circle cx="5.5" cy="5.2" r="2.5"/><path d="M1.4 14.25a4.8 4.8 0 0 1 8.2 0"/></symbol>' +
|
||||||
|
'<symbol id="i-help" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6.25"/><path d="M8 7.2V11.4"/><path d="M8 5.05v.01"/></symbol>' +
|
||||||
|
'<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></symbol>' +
|
||||||
|
'<symbol id="i-account" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="5.4" r="2.75"/><path d="M2.9 14.25a5.1 5.1 0 0 1 10.2 0"/></symbol>' +
|
||||||
|
'<symbol id="i-out" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6.1 14.25H3.65a1.15 1.15 0 0 1-1.15-1.15V2.9a1.15 1.15 0 0 1 1.15-1.15H6.1"/><path d="M10.6 11.15 13.75 8 10.6 4.85"/><path d="M13.75 8H6.35"/></symbol>' +
|
||||||
|
'<symbol id="i-menu" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2.5 4h11M2.5 8h11M2.5 12h11"/></symbol>' +
|
||||||
|
"</svg>";
|
||||||
|
|
||||||
|
function icon(id) {
|
||||||
|
return '<svg class="icon"><use href="#' + id + '"/></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var navHtml =
|
||||||
|
'<nav class="nav">' +
|
||||||
|
'<a class="brand" href="status.html"><img src="../selfpost-stamp-compact.svg" width="220" height="100" alt="SelfPost"></a>' +
|
||||||
|
'<div class="links">' +
|
||||||
|
'<a class="n-status g-only" href="status.html">' + icon("i-status") + "Status</a>" +
|
||||||
|
'<a class="n-domains" href="domains.html">' + icon("i-domains") + "Domains</a>" +
|
||||||
|
'<a class="n-deliveries" href="deliveries.html">' + icon("i-deliveries") + "Deliveries</a>" +
|
||||||
|
'<a class="n-queue g-only" href="mail-queue.html">' + icon("i-queue") + "Mail queue</a>" +
|
||||||
|
'<a class="n-log g-only" href="system-log.html">' + icon("i-log") + "System log</a>" +
|
||||||
|
'<a class="n-inbound g-only in-only" href="inbound.html">' + icon("i-inbound") + "Inbound</a>" +
|
||||||
|
'<a class="n-dmarc g-only" href="dmarc.html">' + icon("i-dmarc") + 'DMARC <span class="tag future">1.x</span></a>' +
|
||||||
|
'<a class="n-backup g-only" href="backup.html">' + icon("i-backup") + "Backup</a>" +
|
||||||
|
'<a class="n-users g-only" href="users.html">' + icon("i-users") + "Users</a>" +
|
||||||
|
'<a class="n-help" href="help.html">' + icon("i-help") + 'Help <span class="tag future">1.x</span></a>' +
|
||||||
|
"</div>" +
|
||||||
|
'<div class="session">' +
|
||||||
|
'<span class="session-user muted">' + icon("i-account") + "User: admin</span>" +
|
||||||
|
'<a class="n-settings" href="settings.html">' + icon("i-settings") + "Settings</a>" +
|
||||||
|
'<a class="btn-ghost" href="login.html">' + icon("i-out") + "Sign out</a>" +
|
||||||
|
"</div>" +
|
||||||
|
"</nav>";
|
||||||
|
|
||||||
|
var phone =
|
||||||
|
'<div class="phone-bar">' +
|
||||||
|
'<label class="nav-burger" for="nav-open" title="Menu">' + icon("i-menu") + "</label>" +
|
||||||
|
'<img class="phone-mark" src="../selfpost-icon.svg" width="28" height="28" alt="">' +
|
||||||
|
'<span class="grow phone-title"></span>' +
|
||||||
|
'<label class="help-link" for="help-index" title="Help">?</label>' +
|
||||||
|
"</div>";
|
||||||
|
|
||||||
|
var help =
|
||||||
|
'<label class="help-scrim" for="help-off"></label>' +
|
||||||
|
'<aside class="help-drawer">' +
|
||||||
|
'<label class="help-close" for="help-off">Close</label>' +
|
||||||
|
'<article class="help-pane help-pane-index">' +
|
||||||
|
"<h2>Help</h2>" +
|
||||||
|
"<p>Short notes for the card you opened — not a second copy of the guide.</p>" +
|
||||||
|
'<p class="muted">Status</p>' +
|
||||||
|
'<ul class="toc"><li><label for="help-status">Status checks</label></li></ul>' +
|
||||||
|
'<p class="muted">Domain</p>' +
|
||||||
|
'<ul class="toc">' +
|
||||||
|
'<li><label for="help-password">New application password</label></li>' +
|
||||||
|
'<li><label for="help-dns">DNS status</label></li>' +
|
||||||
|
'<li><label for="help-records">DKIM and SPF records</label></li>' +
|
||||||
|
'<li><label for="help-dmarc">DMARC record</label></li>' +
|
||||||
|
'<li><label for="help-connection">Connection settings</label></li>' +
|
||||||
|
'<li><label for="help-apps">Applications</label></li>' +
|
||||||
|
'<li><label for="help-domain-settings">Domain settings</label></li>' +
|
||||||
|
'<li><label for="help-export">Export domain</label></li>' +
|
||||||
|
"</ul></article>" +
|
||||||
|
'<article class="help-pane help-pane-status">' +
|
||||||
|
"<h2>Status checks</h2>" +
|
||||||
|
"<p>The cards keep the readings. This drawer is what used to sit under them as paragraphs.</p>" +
|
||||||
|
"<h2>Machine</h2>" +
|
||||||
|
"<p>CPU and memory are the container’s readings. Network is a short rate window. These numbers explain load; they do not replace the queue.</p>" +
|
||||||
|
"<h2>TLS certificate</h2>" +
|
||||||
|
"<p>Presented on port 465. Issued and renewed outside SelfPost. Warn = expires soon; error = missing, and submission will fail.</p>" +
|
||||||
|
"<h2>Hostname / reverse DNS</h2>" +
|
||||||
|
"<p>The hostname must forward to this IP and the PTR must come back to the same name. Set PTR at the provider.</p>" +
|
||||||
|
"<h2>Mail queue</h2>" +
|
||||||
|
"<p>Deferred mail is retried on a time schedule (first delay, doubling cap, queue lifetime). There is no “attempt 3 of N”.</p>" +
|
||||||
|
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
|
||||||
|
'<article class="help-pane help-pane-password">' +
|
||||||
|
"<h2>New application password</h2>" +
|
||||||
|
"<p>Shown <strong>once only</strong> and not stored. Copy it now — if it is lost, regenerate a new one. The previous password stops working immediately.</p>" +
|
||||||
|
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
|
||||||
|
'<article class="help-pane help-pane-dns">' +
|
||||||
|
"<h2>DNS status</h2>" +
|
||||||
|
"<p>The badge is the worst of DKIM, SPF and DMARC. Results are cached a few minutes — use <em>Re-check</em> after publishing.</p>" +
|
||||||
|
"<p>SPF is a shallow check: the literal address only, no <code>include:</code> or <code>redirect=</code>. Report authorization is required only when <code>rua=</code> points at a domain this server does not accept.</p>" +
|
||||||
|
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
|
||||||
|
'<article class="help-pane help-pane-records">' +
|
||||||
|
"<h2>DKIM and SPF records</h2>" +
|
||||||
|
"<p>DKIM is not a secret. The selector on this page is the one this server signs with. Merge the SPF example into an existing record if the domain already has one — do not publish a second TXT.</p>" +
|
||||||
|
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
|
||||||
|
'<article class="help-pane help-pane-dmarc">' +
|
||||||
|
"<h2>DMARC record</h2>" +
|
||||||
|
"<p><code>p=none</code> does not affect delivery. Tighten to <code>p=quarantine</code> then <code>p=reject</code> once reports look clean. The report address is set under Domain settings (or the Settings default).</p>" +
|
||||||
|
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
|
||||||
|
'<article class="help-pane help-pane-connection">' +
|
||||||
|
"<h2>Connection settings</h2>" +
|
||||||
|
"<p>Same host for every domain. Authenticate with an application login from this page. Auth is required on every port. The password is shown once at create or regenerate.</p>" +
|
||||||
|
"<p>465 is implicit TLS; 587 is STARTTLS submission when that port is enabled.</p>" +
|
||||||
|
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
|
||||||
|
'<article class="help-pane help-pane-apps">' +
|
||||||
|
"<h2>Applications</h2>" +
|
||||||
|
"<p>SASL logins for this domain. Login is unique across domains; letters, digits, <code>.</code>, <code>-</code> and <code>_</code>. The password is shown once.</p>" +
|
||||||
|
"<p>Address mode is which From addresses this application may use: any address of the domain, or a fixed list. A trusted-IP override gives those clients a higher ceiling than the domain (still ≤ level 1) and skips the domain check; everyone else uses the domain limit if set, otherwise level 1.</p>" +
|
||||||
|
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
|
||||||
|
'<article class="help-pane help-pane-domain-settings">' +
|
||||||
|
"<h2>Domain settings</h2>" +
|
||||||
|
"<p>Aggregate reports (<code>rua=</code>) inherit the Settings default, or you override them per domain. Level 2 is an optional ceiling for all senders on this domain; it must be ≤ level 1. Application overrides live on each application.</p>" +
|
||||||
|
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
|
||||||
|
'<article class="help-pane help-pane-export">' +
|
||||||
|
"<h2>Export domain</h2>" +
|
||||||
|
"<p>The file is a secret: it carries the DKIM key and application passwords, so published DNS does not have to change on the other instance. Transfer it securely, or encrypt it as <code>.spde</code>.</p>" +
|
||||||
|
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
|
||||||
|
"</aside>";
|
||||||
|
|
||||||
|
var app = document.createElement("div");
|
||||||
|
app.className = "app";
|
||||||
|
app.innerHTML = navHtml + '<div class="stage">' + phone + "</div>";
|
||||||
|
main.parentNode.insertBefore(app, main);
|
||||||
|
app.insertAdjacentHTML("beforebegin", gallery + sprite);
|
||||||
|
app.querySelector(".stage").appendChild(main);
|
||||||
|
app.insertAdjacentHTML("afterend", help);
|
||||||
|
|
||||||
|
var cur = app.querySelector(".n-" + navKey);
|
||||||
|
if (cur) cur.setAttribute("aria-current", "page");
|
||||||
|
var grow = app.querySelector(".phone-title");
|
||||||
|
if (grow) grow.textContent = title;
|
||||||
|
|
||||||
|
var skip = { "index.html": 1, "system.html": 1, "app.html": 1 };
|
||||||
|
|
||||||
|
function qs() {
|
||||||
|
var p = new URLSearchParams();
|
||||||
|
if (document.getElementById("vp-phone").checked) p.set("view", "phone");
|
||||||
|
if (document.getElementById("role-domain").checked) p.set("role", "domain");
|
||||||
|
if (document.getElementById("theme-dark").checked) p.set("theme", "dark");
|
||||||
|
if (!document.getElementById("feat-inbound").checked) p.set("inbound", "0");
|
||||||
|
var s = p.toString();
|
||||||
|
return s ? "?" + s : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function withQuery(href) {
|
||||||
|
if (!href) return href;
|
||||||
|
if (href.charAt(0) === "#" || /^(https?:|mailto:|javascript:)/i.test(href)) return href;
|
||||||
|
var hash = "";
|
||||||
|
var path = href;
|
||||||
|
var hashAt = href.indexOf("#");
|
||||||
|
if (hashAt >= 0) {
|
||||||
|
hash = href.slice(hashAt);
|
||||||
|
path = href.slice(0, hashAt);
|
||||||
|
}
|
||||||
|
var qAt = path.indexOf("?");
|
||||||
|
var file = qAt >= 0 ? path.slice(0, qAt) : path;
|
||||||
|
var extra = qAt >= 0 ? path.slice(qAt + 1) : "";
|
||||||
|
var base = file.split("/").pop();
|
||||||
|
if (!base || !/\.html$/i.test(base) || skip[base]) return href;
|
||||||
|
var p = new URLSearchParams(extra);
|
||||||
|
var curQs = new URLSearchParams(qs().replace(/^\?/, ""));
|
||||||
|
["view", "role", "theme", "inbound"].forEach(function (k) {
|
||||||
|
if (curQs.has(k)) p.set(k, curQs.get(k));
|
||||||
|
else p.delete(k);
|
||||||
|
});
|
||||||
|
var s = p.toString();
|
||||||
|
return base + (s ? "?" + s : "") + hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteLinks() {
|
||||||
|
document.querySelectorAll("a[href]").forEach(function (a) {
|
||||||
|
var raw = a.getAttribute("href");
|
||||||
|
if (!raw) return;
|
||||||
|
a.setAttribute("href", withQuery(raw));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function brandHref() {
|
||||||
|
var brand = document.querySelector(".nav .brand");
|
||||||
|
if (!brand) return;
|
||||||
|
brand.setAttribute("href", withQuery(
|
||||||
|
document.getElementById("role-domain").checked ? "domains.html" : "status.html"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function gate() {
|
||||||
|
brandHref();
|
||||||
|
if (!document.getElementById("role-domain").checked) return;
|
||||||
|
if (globalOnly) location.replace(withQuery("domains.html"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncUrl() {
|
||||||
|
if (history.replaceState) {
|
||||||
|
history.replaceState(null, "", location.pathname.split("/").pop() + qs() + location.hash);
|
||||||
|
}
|
||||||
|
rewriteLinks();
|
||||||
|
brandHref();
|
||||||
|
gate();
|
||||||
|
}
|
||||||
|
|
||||||
|
["role-global", "role-domain", "vp-desktop", "vp-phone", "theme-light", "theme-dark", "feat-inbound"].forEach(function (id) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
if (el) el.addEventListener("change", syncUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll(".nav a").forEach(function (a) {
|
||||||
|
a.addEventListener("click", function () {
|
||||||
|
var open = document.getElementById("nav-open");
|
||||||
|
if (open) open.checked = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
rewriteLinks();
|
||||||
|
gate();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Status — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-status" data-page="status" data-nav="status" data-title="Status" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<div class="page-head">
|
||||||
|
<h1>Status</h1>
|
||||||
|
<label class="help-link" for="help-status" title="What these checks mean">?</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card" id="overall">
|
||||||
|
<h2>Overall <span class="st st-warn">warn</span></h2>
|
||||||
|
<p class="muted">Running, with warnings below.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card attn" id="queue">
|
||||||
|
<h2>Mail queue <span class="st st-warn">warn</span></h2>
|
||||||
|
<p>3 Kbytes in 3 Requests.</p>
|
||||||
|
<div class="actions-row"><a class="btn" href="mail-queue.html">View queue</a></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="certificate">
|
||||||
|
<h2>TLS certificate <span class="st st-ok">ok</span></h2>
|
||||||
|
<label>Expires</label>
|
||||||
|
<span class="code">2026-11-02 12:00 UTC</span>
|
||||||
|
<p class="muted">Valid for another 78 day(s).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card" id="sockets">
|
||||||
|
<h2>Milter sockets <span class="st st-ok">ok</span></h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Milter</th><th>State</th><th>Detail</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>OpenDKIM</td><td><span class="st st-ok">ok</span></td><td class="muted">Listening</td></tr>
|
||||||
|
<tr><td>send-log</td><td><span class="st st-ok">ok</span></td><td class="muted">Listening</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="hostname">
|
||||||
|
<h2>Hostname and reverse DNS <span class="st st-ok">ok</span></h2>
|
||||||
|
<label>Server hostname</label>
|
||||||
|
<span class="code">mail.example.org</span>
|
||||||
|
<label>Forward and reverse lookup</label>
|
||||||
|
<span class="code">203.0.113.10 → mail.example.org</span>
|
||||||
|
<p class="muted">mail.example.org resolves to 203.0.113.10 and the reverse lookup points back at it.</p>
|
||||||
|
<div class="actions-row"><button type="button">Re-check DNS</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fill in-only" id="inbound-status">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Inbound <span class="st st-warn">warn</span></h2>
|
||||||
|
<p><code>INBOUND_RELAY_ENABLE</code> is on. Port 25 accepts mail for 2 domains and forwards it upstream — not to local mailboxes.</p>
|
||||||
|
<p class="muted">One domain has no MX pointing at this server. Recipients are a list or any address at the domain. Open Inbound for the list, MX checks, upstream, and recipient maps.</p>
|
||||||
|
<p><a href="inbound.html">Inbound domains</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card" id="machine">
|
||||||
|
<h2>Machine <span class="st st-ok">ok</span></h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th class="metric">Resource</th><th>Usage</th><th>Detail</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="metric">CPU</td>
|
||||||
|
<td class="metric"><meter value="12" min="0" max="100" low="70" high="90" optimum="10">12%</meter> 12%</td>
|
||||||
|
<td class="muted">4 cores · 4 threads</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="metric">Memory</td>
|
||||||
|
<td class="metric"><meter value="41" min="0" max="100" low="70" high="90" optimum="10">41%</meter> 41%</td>
|
||||||
|
<td class="muted">1.6 GiB used of 4.0 GiB.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="metric">Network</td>
|
||||||
|
<td class="metric">↓ 2.0 KiB/s<br>↑ 1.0 KiB/s</td>
|
||||||
|
<td class="muted"><div>eth0: 1.0 MiB in, 512.0 KiB out</div></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="processes">
|
||||||
|
<h2>Processes <span class="st st-ok">ok</span></h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>opendkim</td><td><span class="st st-ok">RUNNING</span></td><td class="muted">pid 21, uptime 3 days, 4:12:01</td></tr>
|
||||||
|
<tr><td>panel</td><td><span class="st st-ok">RUNNING</span></td><td class="muted">pid 18, uptime 3 days, 4:12:03</td></tr>
|
||||||
|
<tr><td>postfix</td><td><span class="st st-ok">RUNNING</span></td><td class="muted">pid 42, uptime 3 days, 4:11:58</td></tr>
|
||||||
|
<tr><td>postfix-reload</td><td><span class="st st-ok">STOPPED</span></td><td class="muted">Not started</td></tr>
|
||||||
|
<tr><td>cert-reload</td><td><span class="st st-ok">STOPPED</span></td><td class="muted">Not started</td></tr>
|
||||||
|
<tr><td>logrotate</td><td><span class="st st-ok">STOPPED</span></td><td class="muted">Not started</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card" id="configuration">
|
||||||
|
<h2>Configuration</h2>
|
||||||
|
<p class="muted">Regenerates the OpenDKIM and Postfix configuration from the
|
||||||
|
database and reloads both daemons. Use it if you edited the files by hand,
|
||||||
|
restored a backup, or the running configuration looks out of step with the
|
||||||
|
domain and application lists. It does not touch the mail queue or the TLS
|
||||||
|
certificate, and it is safe to run at any time.</p>
|
||||||
|
<div class="actions-row"><button type="button">Reload configuration</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="version">SelfPost 1.2.3 · © Mixeme · <a href="#">License (AGPL-3.0)</a></p>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>System log — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-system-log" data-page="system-log" data-nav="log" data-title="System log" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<h1>System log</h1>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Recent log entries</h2>
|
||||||
|
<span class="code">Aug 15 20:14:08 mail postfix/smtp[230]: 4C3A1E2F1A: to=<ada@example.net>, relay=mx.example.net[198.51.100.20]:25, delay=6, delays=0.2/0.1/0.4/5.3, dsn=4.2.1, status=deferred (450 4.2.1 mailbox busy)
|
||||||
|
Aug 15 20:14:02 mail postfix/qmgr[119]: 4C3A1E2F1A: from=<billing@example.com>, size=12288, nrcpt=1 (queue active)
|
||||||
|
Aug 15 20:14:02 mail postfix/smtpd[221]: 4C3A1E2F1A: client=203.0.113.40[203.0.113.40], sasl_method=PLAIN, sasl_username=billing
|
||||||
|
Aug 15 20:11:40 mail postfix/smtp[228]: 4B19D0AA01: to=<list-bounces@example.net>, relay=mx.example.net[198.51.100.20]:25, delay=0.9, status=sent (250 2.0.0 Ok)
|
||||||
|
Aug 15 20:02:11 mail postfix/smtp[226]: 4B19C0BB12: to=<noreply@blocked.example>, status=bounced (host mx.blocked.example[203.0.113.99] said: 550 5.7.1 rejected)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/* Panel UI system primitives.
|
||||||
|
Source of truth: system.html. Screens are separate HTML files composed from
|
||||||
|
these classes. Do not “fix” empty columns, Host/Type height, or split
|
||||||
|
actions by one-off rules — compose with these instead. */
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: flex; flex-direction: column; gap: 1rem;
|
||||||
|
width: 100%; min-width: 0;
|
||||||
|
}
|
||||||
|
.stack > .card,
|
||||||
|
.stack > .pair,
|
||||||
|
.stack > .measure,
|
||||||
|
.stack > .fill { margin-top: 0; }
|
||||||
|
.stack > .card + .card { margin-top: 0; }
|
||||||
|
|
||||||
|
.measure { width: 100%; max-width: var(--form-max); min-width: 0; }
|
||||||
|
|
||||||
|
.fill { width: 100%; max-width: var(--ops-max); min-width: 0; }
|
||||||
|
.fill > .card { margin-top: 0; }
|
||||||
|
.fill .card:has(table) { overflow-x: auto; }
|
||||||
|
.card > .measure { margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
/* Two peer jobs. One child → reading measure (domain-admin Settings).
|
||||||
|
Never a full-width lonely card on an ops page. */
|
||||||
|
.pair {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%; max-width: var(--ops-max); min-width: 0;
|
||||||
|
}
|
||||||
|
.pair > * { min-width: 0; }
|
||||||
|
.card > .stack { min-width: 0; }
|
||||||
|
.pair > .card { margin-top: 0; }
|
||||||
|
.pair > .card + .card { margin-top: 0; }
|
||||||
|
.pair:has(> :only-child) {
|
||||||
|
display: block;
|
||||||
|
max-width: var(--form-max);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-row {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center;
|
||||||
|
margin-top: 1.1rem;
|
||||||
|
}
|
||||||
|
.actions-row > button,
|
||||||
|
.actions-row > a.btn,
|
||||||
|
.actions-row > a.danger { margin-top: 0; }
|
||||||
|
|
||||||
|
/* Two labelled controls on one row. Labels share row 1, values share row 2
|
||||||
|
so they are the same height by construction — not by matching padding. */
|
||||||
|
.field-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
grid-template-rows: auto minmax(2.5rem, auto);
|
||||||
|
column-gap: 1rem;
|
||||||
|
align-items: stretch;
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
}
|
||||||
|
.field-row.equal { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||||
|
.field-row > .field { display: contents; }
|
||||||
|
.field-row > .field:first-child > * { grid-column: 1; }
|
||||||
|
.field-row > .field:last-child > * { grid-column: 2; }
|
||||||
|
.field-row > .field > label { grid-row: 1; margin-top: 0.45rem; }
|
||||||
|
.field-row > .field > :not(label) {
|
||||||
|
grid-row: 2; align-self: stretch; min-width: 0;
|
||||||
|
margin-top: 0.3rem; box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.field-row > .field > .code,
|
||||||
|
.field-row > .field > .code-row {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.field-row > .field > .code {
|
||||||
|
display: flex; align-items: center;
|
||||||
|
padding-top: 0.45rem; padding-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
.field-row > .field:last-child > .code {
|
||||||
|
width: fit-content; min-width: 2.75rem;
|
||||||
|
justify-content: center; text-align: center;
|
||||||
|
}
|
||||||
|
.field-row .code-row {
|
||||||
|
display: flex; align-items: stretch; gap: 0.5rem; height: 100%;
|
||||||
|
}
|
||||||
|
.field-row .code-row .code {
|
||||||
|
flex: 1; min-width: 0; margin-top: 0; height: auto;
|
||||||
|
display: flex; align-items: center;
|
||||||
|
padding-top: 0.45rem; padding-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
.field-row .code-row .copy { margin-top: 0; align-self: stretch; }
|
||||||
|
|
||||||
|
.card-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.card-head h2 { margin: 0; }
|
||||||
|
|
||||||
|
@media (max-width: 52rem) {
|
||||||
|
.pair { grid-template-columns: 1fr; }
|
||||||
|
/* field-row stays two columns: Type is a token, not a second form. */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Specimens on the system page */
|
||||||
|
.sys-wrap { max-width: 70rem; }
|
||||||
|
.sys-wrap > header p,
|
||||||
|
.sys-wrap section > p,
|
||||||
|
.sys-wrap li { max-width: 68ch; }
|
||||||
|
.sys-toc {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 0.35rem 1.1rem;
|
||||||
|
margin: 1rem 0 0; padding: 0; list-style: none;
|
||||||
|
}
|
||||||
|
.sys-toc a { font-size: 0.92rem; }
|
||||||
|
.specimen {
|
||||||
|
margin: 1rem 0 0; padding: 0.9rem 1rem 1.1rem;
|
||||||
|
border: 1px dashed var(--border); border-radius: 6px; background: var(--code-bg);
|
||||||
|
}
|
||||||
|
.specimen > figcaption {
|
||||||
|
font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase; color: var(--muted); margin: 0 0 0.7rem;
|
||||||
|
}
|
||||||
|
.specimen.bad {
|
||||||
|
border-color: var(--danger-border); background: var(--danger-bg);
|
||||||
|
}
|
||||||
|
.specimen.bad > figcaption { color: var(--danger-fg); }
|
||||||
|
.recipes {
|
||||||
|
width: 100%; margin-top: 0.8rem; font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.recipes th, .recipes td { white-space: normal; vertical-align: top; }
|
||||||
|
.recipes code { font-size: 0.82rem; }
|
||||||
|
.forbid { margin: 0.4rem 0 0; padding-left: 1.1rem; }
|
||||||
|
.forbid li { margin: 0.35rem 0; }
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>SelfPost — система панели</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="doc">
|
||||||
|
<div class="wrap sys-wrap">
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<p class="eyebrow">система · не рескин</p>
|
||||||
|
<h1>Self<b>Post</b> — грамматика интерфейса</h1>
|
||||||
|
<p>Это проект системы. Экраны складываются из регионов и контролов. Запрещённые состояния нельзя «починить паддингом» — их не из чего собрать. Знак, кирпич и IBM Plex не меняются. Живая панель (<code>internal/web</code>) пока не трогается.</p>
|
||||||
|
<div class="cta-row">
|
||||||
|
<a class="cta" href="#regions">Регионы</a>
|
||||||
|
<a class="cta quiet" href="#controls">Контролы</a>
|
||||||
|
<a class="cta quiet" href="#recipes">Экраны</a>
|
||||||
|
<a class="cta quiet" href="status.html">Макеты экранов</a>
|
||||||
|
</div>
|
||||||
|
<ul class="sys-toc">
|
||||||
|
<li><a href="#why">Зачем</a></li>
|
||||||
|
<li><a href="#regions">Регионы</a></li>
|
||||||
|
<li><a href="#card">Карточка</a></li>
|
||||||
|
<li><a href="#controls">Контролы</a></li>
|
||||||
|
<li><a href="#actions">Действия</a></li>
|
||||||
|
<li><a href="#forbid">Нельзя</a></li>
|
||||||
|
<li><a href="#recipes">Рецепты экранов</a></li>
|
||||||
|
<li><a href="#phone">Телефон</a></li>
|
||||||
|
<li><a href="#apply">Как внедрять</a></li>
|
||||||
|
</ul>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section id="why">
|
||||||
|
<h2>Зачем</h2>
|
||||||
|
<p>Первый макет скопировал грамматику живой панели: страница помечается <code>ops</code> или <code>form</code>, карточки стопкой, пара полей — два независимых <code>.code</code>, Save внутри <code><form></code>, Delete снаружи. Дальше каждая мелочь чинилась отдельно — как в <code>panel.css</code> годами. Новый интерфейс должен <strong>не давать собрать</strong> пустую колонку, разные высоты Host/Type и кнопки на двух строках.</p>
|
||||||
|
<p>Три оси, которые больше не выбираются «на глаз» у каждой страницы:</p>
|
||||||
|
<ol>
|
||||||
|
<li><strong>Регион</strong> — как блок занимает ширину окна.</li>
|
||||||
|
<li><strong>Карточка</strong> — заголовок, справка, тело, действия.</li>
|
||||||
|
<li><strong>Контрол</strong> — одно поле или пара полей одной высоты.</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="regions">
|
||||||
|
<h2>Регионы</h2>
|
||||||
|
<p>Страница — это <code>stack</code> регионов, не класс на <code><main></code>. Оболочка одна: навбар прижат влево, контент забирает остаток. Навбар не прыгает, когда меняется содержимое.</p>
|
||||||
|
<table class="recipes">
|
||||||
|
<thead><tr><th>Регион</th><th>Ширина</th><th>Когда</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>measure</code></td>
|
||||||
|
<td>до 42rem, влево</td>
|
||||||
|
<td>Одна читаемая форма: login, setup, подтверждение удаления, форма пользователя. Не «вся страница Settings».</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>pair</code></td>
|
||||||
|
<td>две колонки до 90rem</td>
|
||||||
|
<td>Две равноправные задачи на одном экране. Один ребёнок — сам сжимается в <code>measure</code> (domain-admin без DMARC).</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>fill</code></td>
|
||||||
|
<td>до 90rem</td>
|
||||||
|
<td>Таблица, лог, DNS-статус, список приложений. Ячейки не переносят однострочные значения; карточка скроллится по X.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>stack</code></td>
|
||||||
|
<td>колонка с зазором</td>
|
||||||
|
<td>Вертикальный порядок регионов. Зазор даёт <code>gap</code>, не <code>.card + .card</code>.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<figure class="specimen bad">
|
||||||
|
<figcaption>Нельзя — страница-форма, две задачи стопкой</figcaption>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Full backup</h2>
|
||||||
|
<p class="muted">Карточка узкая. Справа пустое поле на 1600px.</p>
|
||||||
|
<button type="button">Download</button>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Import a domain</h2>
|
||||||
|
<p class="muted">Вторая задача под первой — та же пустота.</p>
|
||||||
|
<button type="button">Import</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<figure class="specimen">
|
||||||
|
<figcaption>Надо — pair</figcaption>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Full backup</h2>
|
||||||
|
<p class="muted">Секрет. Шифрование — внутри карточки, поля не растягиваются на 1440px.</p>
|
||||||
|
<div class="actions-row"><button type="button">Download full backup</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Import a domain</h2>
|
||||||
|
<p class="muted">Тот же экран, вторая задача. Не «ещё одна форма ниже».</p>
|
||||||
|
<div class="actions-row"><button type="button">Import domain</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="card">
|
||||||
|
<h2>Карточка</h2>
|
||||||
|
<p>Один хром: <code>card-head</code> (заголовок + слот «?»), тело, при необходимости <code>actions-row</code>. Справка — часть хрома, её не расставляют после того, как карточки уже собраны. Нет «?» — слот пустой, заголовки соседних карточек всё равно на одной линии.</p>
|
||||||
|
<p>Показания (очередь, PTR, CPU) остаются на карточке. В drawer уходит только то, чего на карточке быть не должно: что такое kernel counter, зачем PTR у провайдера, почему пароль показывают один раз.</p>
|
||||||
|
<figure class="specimen">
|
||||||
|
<figcaption>Хром карточки</figcaption>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>DNS status <span class="st st-ok">ok</span></h2>
|
||||||
|
<span class="help-link" title="What these checks mean">?</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Чтение и Re-check здесь. Абзац «зачем MX» — в справке.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Danger zone</h2>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Без «?»: действие очевидное. Карточка всё равно пара к форме, не на всю ширину.</p>
|
||||||
|
<div class="actions-row"><a class="danger" href="#forbid">Delete inbound domain</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="controls">
|
||||||
|
<h2>Контролы</h2>
|
||||||
|
<p>Одно поле — <code>field</code> (подпись + контроль). Два поля в ряд — <code>field-row</code>: подписи в первой сетке-строке, значения во второй. Высота значений общая, потому что это одна строка грида, а не два блока с подобранным padding. Баг «Type ниже Host» (<code>b0ebe06</code>) из этой разметки не собирается.</p>
|
||||||
|
<p><code>field-row</code> с узкой второй колонкой — Host / name ‖ Type. <code>field-row equal</code> — два равноправных инпута (лимит и окно).</p>
|
||||||
|
|
||||||
|
<figure class="specimen">
|
||||||
|
<figcaption>field-row — Host ‖ Type, с Copy и без</figcaption>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>With Copy</h2>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<div class="code-row">
|
||||||
|
<span class="code">mail._domainkey.example.com</span>
|
||||||
|
<button type="button" class="copy">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Lookup, no Copy</h2>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<span class="code">lists.example.com</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">MX</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<figure class="specimen">
|
||||||
|
<figcaption>field-row equal — лимит ‖ окно</figcaption>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Level-2 rate limit</h2>
|
||||||
|
<div class="field-row equal">
|
||||||
|
<div class="field">
|
||||||
|
<label>Message limit</label>
|
||||||
|
<input value="40">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Window (seconds)</label>
|
||||||
|
<input value="60">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="actions">
|
||||||
|
<h2>Действия</h2>
|
||||||
|
<p>Primary, secondary и danger — всегда <code>actions-row</code>. Для вёрстки неважно, POST это или переход на confirm: ряд один. Форма либо оборачивает всю карточку, либо кнопки несут <code>form=</code>. Нельзя оставить Submit внутри блочной формы, а Delete — следующим соседом: блок формы занимает строку целиком.</p>
|
||||||
|
<figure class="specimen">
|
||||||
|
<figcaption>Save и Delete в одном ряду</figcaption>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Edit user</h2>
|
||||||
|
<label>Username</label>
|
||||||
|
<input value="ops-alerts">
|
||||||
|
<div class="actions-row">
|
||||||
|
<button type="button">Save</button>
|
||||||
|
<a class="danger" href="#forbid">Delete user</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="forbid">
|
||||||
|
<h2>Нельзя</h2>
|
||||||
|
<p>Если хочется добавить правило «только на этой странице» — сначала проверить, какого региона не хватило.</p>
|
||||||
|
<ul class="forbid">
|
||||||
|
<li>Класс на <code>main</code> (<code>ops</code> / <code>form</code>) как способ выбрать ширину. Ширину выбирает регион.</li>
|
||||||
|
<li>Одиночная карточка на 90rem с двумя полями ввода. Это <code>measure</code> или <code>pair</code>.</li>
|
||||||
|
<li>Danger zone отдельным <code>fill</code>. Она вторая колонка последней пары (получатели ‖ удалить, экспорт ‖ удалить).</li>
|
||||||
|
<li>Два <code>.code</code> рядом с разным padding, чтобы «почти совпало». Только <code>field-row</code>.</li>
|
||||||
|
<li>Submit внутри <code><form></code>, danger-ссылка после <code></form></code>.</li>
|
||||||
|
<li>Перенос однострочного поля, адреса, статуса, hostname. <code>nowrap</code> + горизонтальный скролл карточки.</li>
|
||||||
|
<li>Status из одних бейджей без Detail / без строки очереди / без PTR.</li>
|
||||||
|
<li>Телефон как уменьшенный десктоп с шестиколоночной таблицей. Таблица → список; <code>pair</code> → одна колонка; <code>field-row</code> остаётся парой.</li>
|
||||||
|
<li>Копировать разметку <code>internal/web/view/templates</code> «как есть» в макет. Рецепт экрана — ниже, не шаблон Go.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="recipes">
|
||||||
|
<h2>Рецепты экранов</h2>
|
||||||
|
<p>Экраны — отдельные HTML-файлы рядом с этой спецификацией. Собираются только так:</p>
|
||||||
|
<table class="recipes">
|
||||||
|
<thead><tr><th>Экран</th><th>Стек регионов</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><a href="login.html">Login</a> / <a href="setup.html">setup</a></td>
|
||||||
|
<td>центр оболочки, <code>measure</code> 24rem, полный stamp</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="status.html">Status</a></td>
|
||||||
|
<td><code>stack</code>: overall → <code>pair</code> очередь‖TLS → <code>pair</code> milters‖PTR → inbound если есть → <code>pair</code> machine‖processes → configuration. На карточках остаются числа и Detail.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="domains.html">Domains</a> / <a href="inbound.html">Inbound</a> / <a href="deliveries.html">Deliveries</a> / <a href="system-log.html">log</a> / <a href="mail-queue.html">queue</a></td>
|
||||||
|
<td><code>fill</code> таблица. Добавление домена — поле в той же карточке списка (<code>measure</code> внутри, не вторая карточка и не инпут на 90rem).</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="domain.html">Domain</a></td>
|
||||||
|
<td><code>fill</code> DNS status → <code>pair</code> DKIM/SPF ‖ DMARC → <code>pair</code> connection ‖ add app → <code>fill</code> applications → <code>pair</code> domain settings (две половины) → <code>pair</code> export ‖ danger. «?» на рабочих карточках.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="inbound-domain.html">Inbound domain</a></td>
|
||||||
|
<td><code>fill</code> MX DNS → <code>pair</code> upstream ‖ MX to publish → <code>pair</code> recipients ‖ danger</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="dmarc.html">DMARC</a></td>
|
||||||
|
<td>hub: <code>pair</code> ingest ‖ this week, затем <code>fill</code> список отчётов. Домен: <a href="dmarc-domain.html">roll-up</a> (<code>pair</code> 7 days ‖ third-party, <code>fill</code> reports + sources). Один XML: <a href="dmarc-report.html">просмотр</a> — <code>pair</code> report ‖ policy, <code>fill</code> records. Не дашборд, не <code>ruf=</code>.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="backup.html">Backup</a></td>
|
||||||
|
<td><code>pair</code> full backup ‖ import</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="settings.html">Settings</a></td>
|
||||||
|
<td>global: <code>pair</code> credentials ‖ DMARC, затем <code>fill</code> rate limits. Domain-admin: один ребёнок в <code>pair</code> → сам <code>measure</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="users.html">Users</a></td>
|
||||||
|
<td><code>fill</code> таблица. <a href="user-form.html">Create/Edit</a> — <code>measure</code> + <code>actions-row</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="domain-delete.html">Confirm delete</a></td>
|
||||||
|
<td><code>measure</code> одна карточка</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="phone">
|
||||||
|
<h2>Телефон</h2>
|
||||||
|
<p>Аварийный доступ, не продукт. <code>pair</code> складывается в одну колонку. Таблица заменяется списком (как сейчас в прототипе). <code>field-row</code> не складывается: Type — токен. Навбар — выезжающая колонка на checkbox, без обязательного JS. Знак в шапке — SP-иконка, не второй wordmark.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="apply" style="border-bottom:none">
|
||||||
|
<h2>Как внедрять</h2>
|
||||||
|
<ol>
|
||||||
|
<li>Новый экран или правка макета — только классы из <code>system.css</code> (<code>stack</code>, <code>pair</code>, <code>measure</code>, <code>fill</code>, <code>field-row</code>, <code>actions-row</code>).</li>
|
||||||
|
<li>Не добавлять исключения в <code>mock.css</code> «для этой страницы», если это ширина, высота пары полей или ряд кнопок.</li>
|
||||||
|
<li>Click-through — отдельные страницы (<code>status.html</code>, <code>domain.html</code>, …), не простыня с якорями. Оболочка общая: <code>shell.js</code>.</li>
|
||||||
|
<li>Вёрстка <code>internal/web</code> — отдельная задача после утверждения системы, не параллельный рескин шаблонов.</li>
|
||||||
|
</ol>
|
||||||
|
<p class="muted">Классы живут в <code>docs/assets/panel-ui/system.css</code>. Этот файл — спецификация. Экраны — <a href="status.html">status.html</a> и соседние страницы; оглавление — <a href="index.html">index.html</a>.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Delete ops-alerts — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-user-delete" data-page="user-delete" data-nav="users" data-title="Delete user" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1>Delete ops-alerts</h1>
|
||||||
|
<a class="back" href="user-form.html">← Back to ops-alerts</a>
|
||||||
|
</div>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Confirm deletion</h2>
|
||||||
|
<p>You are about to delete the panel user <strong>ops-alerts</strong>. A signed-in session for this user stops working immediately.</p>
|
||||||
|
<div class="actions-row"><button type="button" class="danger">Delete ops-alerts</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Edit user — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-user-form" data-page="user-form" data-nav="users" data-title="Edit user" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1>Edit user</h1>
|
||||||
|
<a class="back" href="users.html">← Back to users</a>
|
||||||
|
</div>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="card">
|
||||||
|
<form action="#" onsubmit="return false">
|
||||||
|
<label>Username</label>
|
||||||
|
<input value="ops-alerts">
|
||||||
|
<label>Password (leave empty to keep)</label>
|
||||||
|
<input type="password">
|
||||||
|
<label>Role</label>
|
||||||
|
<select><option selected>Domain administrator</option><option>Global administrator</option></select>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Assigned domains</legend>
|
||||||
|
<p class="muted">Required for domain administrators.</p>
|
||||||
|
<label class="check"><input type="checkbox"> example.com</label>
|
||||||
|
<label class="check"><input type="checkbox" checked> alerts.example.com</label>
|
||||||
|
</fieldset>
|
||||||
|
<div class="actions-row">
|
||||||
|
<button type="button">Save</button>
|
||||||
|
<a class="danger" href="user-delete.html">Delete user</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Users — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-users" data-page="users" data-nav="users" data-title="Users" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<h1>Users</h1>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<p><a href="user-form.html">Create user</a></p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Username</th><th>Role</th><th>Domains</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>admin</td><td>Global</td><td class="muted">All</td><td class="actions"><a href="user-form.html">Edit</a></td></tr>
|
||||||
|
<tr><td>ops-alerts</td><td>Domain admin</td><td class="muted">alerts.example.com</td><td class="actions"><a href="user-form.html">Edit</a></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="SelfPost">
|
||||||
|
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
|
||||||
|
Wordmark set in IBM Plex Sans (OFL) and converted to outlines: the
|
||||||
|
file needs no font installed to render as drawn.
|
||||||
|
This is the mark's small-size variant, and its S is Medium where the
|
||||||
|
wordmark's is ExtraLight. At the wordmark's weights the S stem is
|
||||||
|
0.90 against the P's 3.40, which at 16px is a quarter of a pixel
|
||||||
|
against most of one: the pair rasterised to a P with a smudge beside
|
||||||
|
it. Medium costs the Self/Post weight play, which needs more pixels
|
||||||
|
than this variant exists to work in, and keeps both letters. -->
|
||||||
|
<path d="M7.80 5.00 L13.00 5.00 A2.8 2.8 0 0 0 18.60 5.00 L23.80 5.00 A2.8 2.8 0 0 0 29.40 5.00 L34.60 5.00 A2.8 2.8 0 0 0 40.20 5.00 L45.40 5.00 A2.8 2.8 0 0 0 51.00 5.00 L56.20 5.00 A2.8 2.8 0 0 0 59.00 7.80 L59.00 13.00 A2.8 2.8 0 0 0 59.00 18.60 L59.00 23.80 A2.8 2.8 0 0 0 59.00 29.40 L59.00 34.60 A2.8 2.8 0 0 0 59.00 40.20 L59.00 45.40 A2.8 2.8 0 0 0 59.00 51.00 L59.00 56.20 A2.8 2.8 0 0 0 56.20 59.00 L51.00 59.00 A2.8 2.8 0 0 0 45.40 59.00 L40.20 59.00 A2.8 2.8 0 0 0 34.60 59.00 L29.40 59.00 A2.8 2.8 0 0 0 23.80 59.00 L18.60 59.00 A2.8 2.8 0 0 0 13.00 59.00 L7.80 59.00 A2.8 2.8 0 0 0 5.00 56.20 L5.00 51.00 A2.8 2.8 0 0 0 5.00 45.40 L5.00 40.20 A2.8 2.8 0 0 0 5.00 34.60 L5.00 29.40 A2.8 2.8 0 0 0 5.00 23.80 L5.00 18.60 A2.8 2.8 0 0 0 5.00 13.00 L5.00 7.80 A2.8 2.8 0 0 0 7.80 5.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round" />
|
||||||
|
<rect x="11" y="11" width="42" height="42" fill="#7A3B2E" />
|
||||||
|
<path d="M24.55 43.31Q22.29 43.31 20.68 42.47Q19.06 41.62 17.92 40.24L19.97 38.35Q20.91 39.52 22.07 40.11Q23.22 40.71 24.71 40.71Q26.45 40.71 27.33 39.92Q28.22 39.13 28.22 37.83Q28.22 37.12 27.96 36.6Q27.7 36.08 27.07 35.75Q26.45 35.41 25.41 35.2L23.8 34.91Q22.03 34.58 20.84 33.93Q19.66 33.28 19.06 32.22Q18.47 31.17 18.47 29.74Q18.47 28.13 19.25 26.96Q20.03 25.79 21.47 25.16Q22.91 24.54 24.84 24.54Q26.89 24.54 28.4 25.26Q29.91 25.97 30.95 27.32L28.89 29.14Q28.19 28.23 27.18 27.69Q26.16 27.14 24.65 27.14Q23.09 27.14 22.25 27.76Q21.4 28.39 21.4 29.58Q21.4 30.34 21.72 30.83Q22.03 31.33 22.66 31.64Q23.3 31.95 24.26 32.13L25.88 32.47Q27.7 32.81 28.87 33.48Q30.04 34.16 30.59 35.19Q31.15 36.21 31.15 37.67Q31.15 39.36 30.37 40.62Q29.59 41.88 28.11 42.6Q26.63 43.31 24.55 43.31Z M37.02 43H33.59V24.85H41.75Q43.44 24.85 44.64 25.55Q45.83 26.26 46.48 27.52Q47.13 28.78 47.13 30.47Q47.13 32.16 46.48 33.42Q45.83 34.68 44.64 35.38Q43.44 36.08 41.75 36.08H37.02ZM37.02 27.84V33.12H41.44Q42.11 33.12 42.58 32.87Q43.05 32.63 43.31 32.17Q43.57 31.72 43.57 31.07V29.87Q43.57 29.19 43.31 28.75Q43.05 28.31 42.58 28.08Q42.11 27.84 41.44 27.84Z" fill="#F6EDE2" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="SelfPost">
|
||||||
|
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
|
||||||
|
Wordmark set in IBM Plex Sans (OFL) and converted to outlines: the
|
||||||
|
file needs no font installed to render as drawn. -->
|
||||||
|
<path d="M7.90 6.00 L10.60 6.00 A1.9 1.9 0 0 0 14.40 6.00 L17.10 6.00 A1.9 1.9 0 0 0 20.90 6.00 L23.60 6.00 A1.9 1.9 0 0 0 27.40 6.00 L30.10 6.00 A1.9 1.9 0 0 0 33.90 6.00 L36.60 6.00 A1.9 1.9 0 0 0 40.40 6.00 L43.10 6.00 A1.9 1.9 0 0 0 46.90 6.00 L49.60 6.00 A1.9 1.9 0 0 0 53.40 6.00 L56.10 6.00 A1.9 1.9 0 0 0 58.00 7.90 L58.00 10.60 A1.9 1.9 0 0 0 58.00 14.40 L58.00 17.10 A1.9 1.9 0 0 0 58.00 20.90 L58.00 23.60 A1.9 1.9 0 0 0 58.00 27.40 L58.00 30.10 A1.9 1.9 0 0 0 58.00 33.90 L58.00 36.60 A1.9 1.9 0 0 0 58.00 40.40 L58.00 43.10 A1.9 1.9 0 0 0 58.00 46.90 L58.00 49.60 A1.9 1.9 0 0 0 58.00 53.40 L58.00 56.10 A1.9 1.9 0 0 0 56.10 58.00 L53.40 58.00 A1.9 1.9 0 0 0 49.60 58.00 L46.90 58.00 A1.9 1.9 0 0 0 43.10 58.00 L40.40 58.00 A1.9 1.9 0 0 0 36.60 58.00 L33.90 58.00 A1.9 1.9 0 0 0 30.10 58.00 L27.40 58.00 A1.9 1.9 0 0 0 23.60 58.00 L20.90 58.00 A1.9 1.9 0 0 0 17.10 58.00 L14.40 58.00 A1.9 1.9 0 0 0 10.60 58.00 L7.90 58.00 A1.9 1.9 0 0 0 6.00 56.10 L6.00 53.40 A1.9 1.9 0 0 0 6.00 49.60 L6.00 46.90 A1.9 1.9 0 0 0 6.00 43.10 L6.00 40.40 A1.9 1.9 0 0 0 6.00 36.60 L6.00 33.90 A1.9 1.9 0 0 0 6.00 30.10 L6.00 27.40 A1.9 1.9 0 0 0 6.00 23.60 L6.00 20.90 A1.9 1.9 0 0 0 6.00 17.10 L6.00 14.40 A1.9 1.9 0 0 0 6.00 10.60 L6.00 7.90 A1.9 1.9 0 0 0 7.90 6.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.6" stroke-linejoin="round" />
|
||||||
|
<rect x="11" y="11" width="42" height="42" fill="#7A3B2E" />
|
||||||
|
<path d="M25.21 31.17Q24.01 31.17 23.22 30.71Q22.44 30.26 21.89 29.54L22.29 29.24Q22.8 29.91 23.48 30.31Q24.16 30.71 25.23 30.71Q26.44 30.71 27.11 30.13Q27.77 29.56 27.77 28.48Q27.77 27.74 27.49 27.31Q27.21 26.88 26.72 26.66Q26.23 26.44 25.63 26.31L24.69 26.13Q23.73 25.95 23.17 25.57Q22.62 25.2 22.38 24.69Q22.13 24.18 22.13 23.58Q22.13 22.73 22.51 22.17Q22.89 21.61 23.57 21.33Q24.26 21.06 25.17 21.06Q26.26 21.06 26.99 21.44Q27.72 21.82 28.14 22.45L27.75 22.74Q27.33 22.14 26.7 21.83Q26.07 21.52 25.14 21.52Q23.98 21.52 23.31 22.03Q22.65 22.53 22.65 23.57Q22.65 24.25 22.94 24.66Q23.22 25.08 23.71 25.3Q24.2 25.53 24.81 25.65L25.74 25.83Q26.74 26.02 27.29 26.4Q27.84 26.79 28.06 27.31Q28.28 27.84 28.28 28.44Q28.28 29.33 27.91 29.94Q27.54 30.54 26.84 30.85Q26.15 31.17 25.21 31.17Z M32.51 31.17Q31.57 31.17 30.89 30.73Q30.22 30.3 29.84 29.47Q29.47 28.65 29.47 27.44Q29.47 26.24 29.84 25.41Q30.2 24.57 30.87 24.15Q31.53 23.72 32.47 23.72Q33.34 23.72 34 24.15Q34.65 24.59 35.02 25.38Q35.4 26.17 35.4 27.25V27.47H29.99V27.85Q29.99 29.15 30.66 29.94Q31.32 30.73 32.53 30.73Q33.37 30.73 33.96 30.32Q34.56 29.91 34.86 29.11L35.24 29.32Q34.93 30.16 34.23 30.66Q33.53 31.17 32.51 31.17ZM32.47 24.14Q31.69 24.14 31.13 24.5Q30.58 24.85 30.29 25.5Q29.99 26.16 29.99 27.02V27.08H34.86V27Q34.86 26.13 34.56 25.49Q34.26 24.85 33.72 24.5Q33.18 24.14 32.47 24.14Z M38.85 31H37.8Q37.49 31 37.34 30.85Q37.19 30.69 37.19 30.41V20.64H37.68V30.57H38.85Z M40.67 31V24.32H39.4V23.89H40.67V21.35Q40.67 21.02 40.84 20.83Q41.01 20.64 41.36 20.64H42.76V21.07H41.16V23.89H42.76V24.32H41.16V31Z" fill="#F6EDE2" />
|
||||||
|
<path d="M21.26 45H19.41V35.23H23.81Q24.72 35.23 25.36 35.61Q26 35.98 26.35 36.66Q26.7 37.34 26.7 38.25Q26.7 39.16 26.35 39.84Q26 40.52 25.36 40.9Q24.72 41.28 23.81 41.28H21.26ZM21.26 36.84V39.68H23.64Q24 39.68 24.25 39.55Q24.51 39.41 24.65 39.17Q24.79 38.92 24.79 38.57V37.93Q24.79 37.57 24.65 37.33Q24.51 37.09 24.25 36.96Q24 36.84 23.64 36.84Z M30.77 45.17Q29.73 45.17 28.97 44.7Q28.21 44.23 27.79 43.37Q27.38 42.51 27.38 41.33Q27.38 40.16 27.79 39.31Q28.21 38.46 28.97 37.99Q29.73 37.52 30.77 37.52Q31.82 37.52 32.58 37.99Q33.34 38.46 33.76 39.31Q34.17 40.16 34.17 41.33Q34.17 42.51 33.76 43.37Q33.34 44.23 32.58 44.7Q31.82 45.17 30.77 45.17ZM30.77 43.73Q31.48 43.73 31.89 43.28Q32.3 42.84 32.3 42.02V40.66Q32.3 39.83 31.89 39.4Q31.48 38.97 30.77 38.97Q30.08 38.97 29.67 39.4Q29.26 39.83 29.26 40.66V42.02Q29.26 42.84 29.67 43.28Q30.08 43.73 30.77 43.73Z M37.75 45.17Q36.7 45.17 35.98 44.81Q35.27 44.45 34.72 43.82L35.81 42.76Q36.22 43.22 36.7 43.49Q37.19 43.75 37.82 43.75Q38.46 43.75 38.75 43.53Q39.03 43.31 39.03 42.91Q39.03 42.59 38.83 42.4Q38.63 42.21 38.14 42.14L37.41 42.05Q36.63 41.95 36.08 41.68Q35.53 41.42 35.25 40.95Q34.98 40.49 34.98 39.83Q34.98 38.77 35.74 38.15Q36.5 37.52 37.75 37.52Q38.46 37.52 38.97 37.66Q39.48 37.79 39.88 38.05Q40.27 38.31 40.6 38.67L39.55 39.72Q39.23 39.39 38.78 39.16Q38.34 38.94 37.8 38.94Q37.24 38.94 36.97 39.14Q36.7 39.34 36.7 39.69Q36.7 40.06 36.91 40.25Q37.13 40.44 37.65 40.52L38.39 40.62Q39.59 40.79 40.18 41.33Q40.76 41.86 40.76 42.77Q40.76 43.47 40.39 44.02Q40.02 44.57 39.34 44.87Q38.66 45.17 37.75 45.17Z M45.51 45H44.22Q43.3 45 42.81 44.52Q42.33 44.03 42.33 43.14V39.11H41.25V37.69H41.81Q42.22 37.69 42.37 37.5Q42.51 37.31 42.51 36.94V35.69H44.12V37.69H45.62V39.11H44.12V43.57H45.51Z" fill="#F6EDE2" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.0 KiB |
@@ -0,0 +1,246 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru"><head><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>SelfPost — знак на утверждение</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@200;300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root{--ink:#12161C;--paper:#F7F6F4;--brick:#7A3B2E;--slate:#6B7684;--line:#DEDCD7}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;background:var(--paper);color:var(--ink);font-family:'IBM Plex Sans',system-ui,sans-serif;font-weight:300;-webkit-font-smoothing:antialiased}
|
||||||
|
.wrap{max-width:960px;margin:0 auto;padding:56px 24px 96px}
|
||||||
|
header{border-bottom:2px solid var(--brick);padding-bottom:28px}
|
||||||
|
.eyebrow{font-family:'IBM Plex Mono',monospace;font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--brick);margin:0 0 14px}
|
||||||
|
h1{font-size:clamp(28px,5vw,40px);font-weight:200;letter-spacing:-.02em;margin:0 0 10px}
|
||||||
|
h1 b{font-weight:600}
|
||||||
|
header p{margin:0;max-width:62ch;color:#3C444F;line-height:1.55;font-size:15px}
|
||||||
|
section{border-bottom:1px solid var(--line);padding:40px 0}
|
||||||
|
h2{font-size:13px;font-weight:500;margin:0 0 20px;letter-spacing:.1em;text-transform:uppercase;font-family:'IBM Plex Mono',monospace;color:var(--slate)}
|
||||||
|
.hero{background:#fff;border:1px solid var(--line);border-radius:3px;padding:44px;display:flex;justify-content:center}
|
||||||
|
.hero svg{width:100%;max-width:430px;height:auto}
|
||||||
|
.swatches{display:flex;gap:8px;flex-wrap:wrap;margin-top:18px}
|
||||||
|
.sw{font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--slate);border:1px solid var(--line);border-radius:2px;padding:6px 10px;display:flex;align-items:center;gap:7px;background:#fff}
|
||||||
|
.sw i{width:14px;height:14px;border:1px solid rgba(0,0,0,.12);display:block}
|
||||||
|
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||||
|
@media(max-width:760px){.grid2{grid-template-columns:1fr}}
|
||||||
|
.cap{font-size:12.5px;line-height:1.5;color:#5A6470;margin-top:8px}
|
||||||
|
.cap b{font-family:'IBM Plex Mono',monospace;font-size:11px;font-weight:600;color:var(--ink)}
|
||||||
|
|
||||||
|
/* mock: github */
|
||||||
|
.gh{border:1px solid #D1D9E0;border-radius:6px;padding:18px;background:#fff;font-size:13px}
|
||||||
|
.gh.dk{background:#0D1117;border-color:#30363D;color:#E6EDF3}
|
||||||
|
.gh .bc{font-size:14px;color:#0969DA;margin-bottom:14px}
|
||||||
|
.gh.dk .bc{color:#4493F8}
|
||||||
|
.gh .bc b{font-weight:600}
|
||||||
|
.gh .rd{border:1px solid #D1D9E0;border-radius:6px;padding:20px}
|
||||||
|
.gh.dk .rd{border-color:#30363D}
|
||||||
|
.gh .rd svg{width:280px;max-width:100%;height:auto;display:block;margin-bottom:16px}
|
||||||
|
.gh h3{font-size:15px;font-weight:600;margin:0 0 8px;padding-bottom:6px;border-bottom:1px solid #D1D9E0}
|
||||||
|
.gh.dk h3{border-color:#30363D}
|
||||||
|
.gh p{margin:0;color:#59636E;line-height:1.6;font-size:12.5px}
|
||||||
|
.gh.dk p{color:#9198A1}
|
||||||
|
|
||||||
|
/* mock: browser tab */
|
||||||
|
.tabs{background:#DEE1E6;border-radius:8px 8px 0 0;padding:8px 8px 0;display:flex;gap:4px}
|
||||||
|
.tab{background:#fff;border-radius:8px 8px 0 0;padding:8px 12px;display:flex;align-items:center;gap:7px;font-size:12px;color:#3C4043;max-width:190px}
|
||||||
|
.tab.off{background:#C9CDD3;color:#5F6368}
|
||||||
|
.tab svg{width:16px;height:16px;flex:none}
|
||||||
|
.tab span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.url{background:#fff;padding:10px 12px;border-radius:0 0 8px 8px;font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--slate);border-top:1px solid #DEE1E6}
|
||||||
|
|
||||||
|
/* mock: panel */
|
||||||
|
.panel{border:1px solid var(--line);border-radius:4px;overflow:hidden;background:#fff}
|
||||||
|
.ph{background:#12161C;padding:12px 16px;display:flex;align-items:center;gap:20px}
|
||||||
|
.ph svg{height:34px;width:auto}
|
||||||
|
.nav{display:flex;gap:16px;font-size:12.5px;color:#9AA3AE}
|
||||||
|
.nav b{color:#fff;font-weight:500}
|
||||||
|
.pb{padding:18px 16px;display:flex;flex-direction:column;gap:9px}
|
||||||
|
.row{display:flex;align-items:center;gap:10px;font-family:'IBM Plex Mono',monospace;font-size:11.5px;color:#3C444F}
|
||||||
|
.badge{font-size:10px;padding:2px 7px;border-radius:10px;font-weight:600;letter-spacing:.03em}
|
||||||
|
.ok{background:#DCFCE7;color:#166534}
|
||||||
|
.er{background:#FEE2E2;color:#991B1B}
|
||||||
|
|
||||||
|
/* avatars */
|
||||||
|
.avs{display:flex;gap:14px;align-items:flex-end;flex-wrap:wrap}
|
||||||
|
.av{border-radius:6px;overflow:hidden;border:1px solid var(--line);display:block}
|
||||||
|
.sizes{display:flex;gap:22px;align-items:flex-end;flex-wrap:wrap;background:#fff;border:1px solid var(--line);border-radius:3px;padding:24px}
|
||||||
|
.sizes .u{text-align:center}
|
||||||
|
.sizes svg{display:block;margin:0 auto 8px}
|
||||||
|
.sizes .u span{font-family:'IBM Plex Mono',monospace;font-size:10px;color:var(--slate)}
|
||||||
|
|
||||||
|
.rules{display:grid;grid-template-columns:1fr 1fr;gap:2px;background:var(--line);border:1px solid var(--line);border-radius:3px;overflow:hidden}
|
||||||
|
@media(max-width:760px){.rules{grid-template-columns:1fr}}
|
||||||
|
.rule{background:#fff;padding:16px 18px}
|
||||||
|
.rule b{display:block;font-weight:500;font-size:13.5px;margin-bottom:5px;color:var(--brick)}
|
||||||
|
.rule span{font-size:12.5px;line-height:1.55;color:#5A6470}
|
||||||
|
.files{font-family:'IBM Plex Mono',monospace;font-size:12px;line-height:2;color:#3C444F}
|
||||||
|
.files b{color:var(--ink);font-weight:600}
|
||||||
|
.note{background:#F1EAE4;border-left:2px solid var(--brick);padding:14px 16px;font-size:13.5px;line-height:1.6;color:#3C444F;max-width:64ch;margin-top:20px}
|
||||||
|
code{font-family:'IBM Plex Mono',monospace;font-size:12px;background:#EFEDE9;padding:1px 5px;border-radius:2px}
|
||||||
|
</style></head>
|
||||||
|
<body><div class="wrap">
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<p class="eyebrow">на утверждение · кирпич #7A3B2E</p>
|
||||||
|
<h1>Self<b>Post</b></h1>
|
||||||
|
<p>Марка-надпись: имя — само поле знака, край держат зубцовка и волосяной контур. Один файл работает на светлой и тёмной теме.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Эталон</h2>
|
||||||
|
<div class="hero"><svg class="" viewBox="0 0 330 150" width="330" height="150" role="img" aria-label="SelfPost — self-hosted SMTP relay">
|
||||||
|
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
|
||||||
|
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
|
||||||
|
<path d="M24.70 14.00 L27.97 14.00 A2.7 2.7 0 0 0 33.37 14.00 L36.63 14.00 A2.7 2.7 0 0 0 42.03 14.00 L45.30 14.00 A2.7 2.7 0 0 0 50.70 14.00 L53.97 14.00 A2.7 2.7 0 0 0 59.37 14.00 L62.63 14.00 A2.7 2.7 0 0 0 68.03 14.00 L71.30 14.00 A2.7 2.7 0 0 0 76.70 14.00 L79.97 14.00 A2.7 2.7 0 0 0 85.37 14.00 L88.63 14.00 A2.7 2.7 0 0 0 94.03 14.00 L97.30 14.00 A2.7 2.7 0 0 0 102.70 14.00 L105.97 14.00 A2.7 2.7 0 0 0 111.37 14.00 L114.63 14.00 A2.7 2.7 0 0 0 120.03 14.00 L123.30 14.00 A2.7 2.7 0 0 0 128.70 14.00 L131.97 14.00 A2.7 2.7 0 0 0 137.37 14.00 L140.63 14.00 A2.7 2.7 0 0 0 146.03 14.00 L149.30 14.00 A2.7 2.7 0 0 0 154.70 14.00 L157.97 14.00 A2.7 2.7 0 0 0 163.37 14.00 L166.63 14.00 A2.7 2.7 0 0 0 172.03 14.00 L175.30 14.00 A2.7 2.7 0 0 0 180.70 14.00 L183.97 14.00 A2.7 2.7 0 0 0 189.37 14.00 L192.63 14.00 A2.7 2.7 0 0 0 198.03 14.00 L201.30 14.00 A2.7 2.7 0 0 0 206.70 14.00 L209.97 14.00 A2.7 2.7 0 0 0 215.37 14.00 L218.63 14.00 A2.7 2.7 0 0 0 224.03 14.00 L227.30 14.00 A2.7 2.7 0 0 0 232.70 14.00 L235.97 14.00 A2.7 2.7 0 0 0 241.37 14.00 L244.63 14.00 A2.7 2.7 0 0 0 250.03 14.00 L253.30 14.00 A2.7 2.7 0 0 0 258.70 14.00 L261.97 14.00 A2.7 2.7 0 0 0 267.37 14.00 L270.63 14.00 A2.7 2.7 0 0 0 276.03 14.00 L279.30 14.00 A2.7 2.7 0 0 0 284.70 14.00 L287.97 14.00 A2.7 2.7 0 0 0 293.37 14.00 L296.63 14.00 A2.7 2.7 0 0 0 302.03 14.00 L305.30 14.00 A2.7 2.7 0 0 0 308.00 16.70 L308.00 20.01 A2.7 2.7 0 0 0 308.00 25.41 L308.00 28.73 A2.7 2.7 0 0 0 308.00 34.13 L308.00 37.44 A2.7 2.7 0 0 0 308.00 42.84 L308.00 46.16 A2.7 2.7 0 0 0 308.00 51.56 L308.00 54.87 A2.7 2.7 0 0 0 308.00 60.27 L308.00 63.59 A2.7 2.7 0 0 0 308.00 68.99 L308.00 72.30 A2.7 2.7 0 0 0 308.00 77.70 L308.00 81.01 A2.7 2.7 0 0 0 308.00 86.41 L308.00 89.73 A2.7 2.7 0 0 0 308.00 95.13 L308.00 98.44 A2.7 2.7 0 0 0 308.00 103.84 L308.00 107.16 A2.7 2.7 0 0 0 308.00 112.56 L308.00 115.87 A2.7 2.7 0 0 0 308.00 121.27 L308.00 124.59 A2.7 2.7 0 0 0 308.00 129.99 L308.00 133.30 A2.7 2.7 0 0 0 305.30 136.00 L302.03 136.00 A2.7 2.7 0 0 0 296.63 136.00 L293.37 136.00 A2.7 2.7 0 0 0 287.97 136.00 L284.70 136.00 A2.7 2.7 0 0 0 279.30 136.00 L276.03 136.00 A2.7 2.7 0 0 0 270.63 136.00 L267.37 136.00 A2.7 2.7 0 0 0 261.97 136.00 L258.70 136.00 A2.7 2.7 0 0 0 253.30 136.00 L250.03 136.00 A2.7 2.7 0 0 0 244.63 136.00 L241.37 136.00 A2.7 2.7 0 0 0 235.97 136.00 L232.70 136.00 A2.7 2.7 0 0 0 227.30 136.00 L224.03 136.00 A2.7 2.7 0 0 0 218.63 136.00 L215.37 136.00 A2.7 2.7 0 0 0 209.97 136.00 L206.70 136.00 A2.7 2.7 0 0 0 201.30 136.00 L198.03 136.00 A2.7 2.7 0 0 0 192.63 136.00 L189.37 136.00 A2.7 2.7 0 0 0 183.97 136.00 L180.70 136.00 A2.7 2.7 0 0 0 175.30 136.00 L172.03 136.00 A2.7 2.7 0 0 0 166.63 136.00 L163.37 136.00 A2.7 2.7 0 0 0 157.97 136.00 L154.70 136.00 A2.7 2.7 0 0 0 149.30 136.00 L146.03 136.00 A2.7 2.7 0 0 0 140.63 136.00 L137.37 136.00 A2.7 2.7 0 0 0 131.97 136.00 L128.70 136.00 A2.7 2.7 0 0 0 123.30 136.00 L120.03 136.00 A2.7 2.7 0 0 0 114.63 136.00 L111.37 136.00 A2.7 2.7 0 0 0 105.97 136.00 L102.70 136.00 A2.7 2.7 0 0 0 97.30 136.00 L94.03 136.00 A2.7 2.7 0 0 0 88.63 136.00 L85.37 136.00 A2.7 2.7 0 0 0 79.97 136.00 L76.70 136.00 A2.7 2.7 0 0 0 71.30 136.00 L68.03 136.00 A2.7 2.7 0 0 0 62.63 136.00 L59.37 136.00 A2.7 2.7 0 0 0 53.97 136.00 L50.70 136.00 A2.7 2.7 0 0 0 45.30 136.00 L42.03 136.00 A2.7 2.7 0 0 0 36.63 136.00 L33.37 136.00 A2.7 2.7 0 0 0 27.97 136.00 L24.70 136.00 A2.7 2.7 0 0 0 22.00 133.30 L22.00 129.99 A2.7 2.7 0 0 0 22.00 124.59 L22.00 121.27 A2.7 2.7 0 0 0 22.00 115.87 L22.00 112.56 A2.7 2.7 0 0 0 22.00 107.16 L22.00 103.84 A2.7 2.7 0 0 0 22.00 98.44 L22.00 95.13 A2.7 2.7 0 0 0 22.00 89.73 L22.00 86.41 A2.7 2.7 0 0 0 22.00 81.01 L22.00 77.70 A2.7 2.7 0 0 0 22.00 72.30 L22.00 68.99 A2.7 2.7 0 0 0 22.00 63.59 L22.00 60.27 A2.7 2.7 0 0 0 22.00 54.87 L22.00 51.56 A2.7 2.7 0 0 0 22.00 46.16 L22.00 42.84 A2.7 2.7 0 0 0 22.00 37.44 L22.00 34.13 A2.7 2.7 0 0 0 22.00 28.73 L22.00 25.41 A2.7 2.7 0 0 0 22.00 20.01 L22.00 16.70 A2.7 2.7 0 0 0 24.70 14.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round"/>
|
||||||
|
<rect x="31" y="23" width="268" height="104" fill="#7A3B2E"/>
|
||||||
|
<text x="165.0" y="82" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="38" letter-spacing="-1.4" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
|
||||||
|
<path d="M137.0 98 H193.0" stroke="#F6EDE2" stroke-width="1" opacity=".5"/>
|
||||||
|
<text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="11.5" letter-spacing=".15" fill="#F6EDE2">SELF-HOSTED SMTP RELAY</text>
|
||||||
|
</svg></div>
|
||||||
|
<div class="swatches">
|
||||||
|
<div class="sw"><i style="background:#7A3B2E"></i>#7A3B2E · печать</div>
|
||||||
|
<div class="sw"><i style="background:#F3EDE1"></i>#F3EDE1 · поле</div>
|
||||||
|
<div class="sw"><i style="background:#F6EDE2"></i>#F6EDE2 · краска по полю</div>
|
||||||
|
<div class="sw"><i style="background:#12161C"></i>#12161C · контур 0.8</div>
|
||||||
|
<div class="sw">контраст 7,3:1 · подзаголовок 4,6:1</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>README на GitHub</h2>
|
||||||
|
<div class="grid2">
|
||||||
|
<div><div class="gh">
|
||||||
|
<div class="bc">mixeme / <b>selfpost</b></div>
|
||||||
|
<div class="rd"><svg class="" viewBox="0 0 330 150" width="330" height="150" role="img" aria-label="SelfPost — self-hosted SMTP relay">
|
||||||
|
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
|
||||||
|
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
|
||||||
|
<path d="M24.70 14.00 L27.97 14.00 A2.7 2.7 0 0 0 33.37 14.00 L36.63 14.00 A2.7 2.7 0 0 0 42.03 14.00 L45.30 14.00 A2.7 2.7 0 0 0 50.70 14.00 L53.97 14.00 A2.7 2.7 0 0 0 59.37 14.00 L62.63 14.00 A2.7 2.7 0 0 0 68.03 14.00 L71.30 14.00 A2.7 2.7 0 0 0 76.70 14.00 L79.97 14.00 A2.7 2.7 0 0 0 85.37 14.00 L88.63 14.00 A2.7 2.7 0 0 0 94.03 14.00 L97.30 14.00 A2.7 2.7 0 0 0 102.70 14.00 L105.97 14.00 A2.7 2.7 0 0 0 111.37 14.00 L114.63 14.00 A2.7 2.7 0 0 0 120.03 14.00 L123.30 14.00 A2.7 2.7 0 0 0 128.70 14.00 L131.97 14.00 A2.7 2.7 0 0 0 137.37 14.00 L140.63 14.00 A2.7 2.7 0 0 0 146.03 14.00 L149.30 14.00 A2.7 2.7 0 0 0 154.70 14.00 L157.97 14.00 A2.7 2.7 0 0 0 163.37 14.00 L166.63 14.00 A2.7 2.7 0 0 0 172.03 14.00 L175.30 14.00 A2.7 2.7 0 0 0 180.70 14.00 L183.97 14.00 A2.7 2.7 0 0 0 189.37 14.00 L192.63 14.00 A2.7 2.7 0 0 0 198.03 14.00 L201.30 14.00 A2.7 2.7 0 0 0 206.70 14.00 L209.97 14.00 A2.7 2.7 0 0 0 215.37 14.00 L218.63 14.00 A2.7 2.7 0 0 0 224.03 14.00 L227.30 14.00 A2.7 2.7 0 0 0 232.70 14.00 L235.97 14.00 A2.7 2.7 0 0 0 241.37 14.00 L244.63 14.00 A2.7 2.7 0 0 0 250.03 14.00 L253.30 14.00 A2.7 2.7 0 0 0 258.70 14.00 L261.97 14.00 A2.7 2.7 0 0 0 267.37 14.00 L270.63 14.00 A2.7 2.7 0 0 0 276.03 14.00 L279.30 14.00 A2.7 2.7 0 0 0 284.70 14.00 L287.97 14.00 A2.7 2.7 0 0 0 293.37 14.00 L296.63 14.00 A2.7 2.7 0 0 0 302.03 14.00 L305.30 14.00 A2.7 2.7 0 0 0 308.00 16.70 L308.00 20.01 A2.7 2.7 0 0 0 308.00 25.41 L308.00 28.73 A2.7 2.7 0 0 0 308.00 34.13 L308.00 37.44 A2.7 2.7 0 0 0 308.00 42.84 L308.00 46.16 A2.7 2.7 0 0 0 308.00 51.56 L308.00 54.87 A2.7 2.7 0 0 0 308.00 60.27 L308.00 63.59 A2.7 2.7 0 0 0 308.00 68.99 L308.00 72.30 A2.7 2.7 0 0 0 308.00 77.70 L308.00 81.01 A2.7 2.7 0 0 0 308.00 86.41 L308.00 89.73 A2.7 2.7 0 0 0 308.00 95.13 L308.00 98.44 A2.7 2.7 0 0 0 308.00 103.84 L308.00 107.16 A2.7 2.7 0 0 0 308.00 112.56 L308.00 115.87 A2.7 2.7 0 0 0 308.00 121.27 L308.00 124.59 A2.7 2.7 0 0 0 308.00 129.99 L308.00 133.30 A2.7 2.7 0 0 0 305.30 136.00 L302.03 136.00 A2.7 2.7 0 0 0 296.63 136.00 L293.37 136.00 A2.7 2.7 0 0 0 287.97 136.00 L284.70 136.00 A2.7 2.7 0 0 0 279.30 136.00 L276.03 136.00 A2.7 2.7 0 0 0 270.63 136.00 L267.37 136.00 A2.7 2.7 0 0 0 261.97 136.00 L258.70 136.00 A2.7 2.7 0 0 0 253.30 136.00 L250.03 136.00 A2.7 2.7 0 0 0 244.63 136.00 L241.37 136.00 A2.7 2.7 0 0 0 235.97 136.00 L232.70 136.00 A2.7 2.7 0 0 0 227.30 136.00 L224.03 136.00 A2.7 2.7 0 0 0 218.63 136.00 L215.37 136.00 A2.7 2.7 0 0 0 209.97 136.00 L206.70 136.00 A2.7 2.7 0 0 0 201.30 136.00 L198.03 136.00 A2.7 2.7 0 0 0 192.63 136.00 L189.37 136.00 A2.7 2.7 0 0 0 183.97 136.00 L180.70 136.00 A2.7 2.7 0 0 0 175.30 136.00 L172.03 136.00 A2.7 2.7 0 0 0 166.63 136.00 L163.37 136.00 A2.7 2.7 0 0 0 157.97 136.00 L154.70 136.00 A2.7 2.7 0 0 0 149.30 136.00 L146.03 136.00 A2.7 2.7 0 0 0 140.63 136.00 L137.37 136.00 A2.7 2.7 0 0 0 131.97 136.00 L128.70 136.00 A2.7 2.7 0 0 0 123.30 136.00 L120.03 136.00 A2.7 2.7 0 0 0 114.63 136.00 L111.37 136.00 A2.7 2.7 0 0 0 105.97 136.00 L102.70 136.00 A2.7 2.7 0 0 0 97.30 136.00 L94.03 136.00 A2.7 2.7 0 0 0 88.63 136.00 L85.37 136.00 A2.7 2.7 0 0 0 79.97 136.00 L76.70 136.00 A2.7 2.7 0 0 0 71.30 136.00 L68.03 136.00 A2.7 2.7 0 0 0 62.63 136.00 L59.37 136.00 A2.7 2.7 0 0 0 53.97 136.00 L50.70 136.00 A2.7 2.7 0 0 0 45.30 136.00 L42.03 136.00 A2.7 2.7 0 0 0 36.63 136.00 L33.37 136.00 A2.7 2.7 0 0 0 27.97 136.00 L24.70 136.00 A2.7 2.7 0 0 0 22.00 133.30 L22.00 129.99 A2.7 2.7 0 0 0 22.00 124.59 L22.00 121.27 A2.7 2.7 0 0 0 22.00 115.87 L22.00 112.56 A2.7 2.7 0 0 0 22.00 107.16 L22.00 103.84 A2.7 2.7 0 0 0 22.00 98.44 L22.00 95.13 A2.7 2.7 0 0 0 22.00 89.73 L22.00 86.41 A2.7 2.7 0 0 0 22.00 81.01 L22.00 77.70 A2.7 2.7 0 0 0 22.00 72.30 L22.00 68.99 A2.7 2.7 0 0 0 22.00 63.59 L22.00 60.27 A2.7 2.7 0 0 0 22.00 54.87 L22.00 51.56 A2.7 2.7 0 0 0 22.00 46.16 L22.00 42.84 A2.7 2.7 0 0 0 22.00 37.44 L22.00 34.13 A2.7 2.7 0 0 0 22.00 28.73 L22.00 25.41 A2.7 2.7 0 0 0 22.00 20.01 L22.00 16.70 A2.7 2.7 0 0 0 24.70 14.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round"/>
|
||||||
|
<rect x="31" y="23" width="268" height="104" fill="#7A3B2E"/>
|
||||||
|
<text x="165.0" y="82" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="38" letter-spacing="-1.4" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
|
||||||
|
<path d="M137.0 98 H193.0" stroke="#F6EDE2" stroke-width="1" opacity=".5"/>
|
||||||
|
<text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="11.5" letter-spacing=".15" fill="#F6EDE2">SELF-HOSTED SMTP RELAY</text>
|
||||||
|
</svg>
|
||||||
|
<h3>SelfPost</h3>
|
||||||
|
<p>Self-hosted outbound SMTP relay with a web control panel, shipped as a single Docker image.</p></div>
|
||||||
|
</div><div class="cap"><b>light</b><br>Тёплое поле отделяет марку от белого GitHub, контур ловит край.</div></div>
|
||||||
|
<div><div class="gh dk">
|
||||||
|
<div class="bc">mixeme / <b>selfpost</b></div>
|
||||||
|
<div class="rd"><svg class="" viewBox="0 0 330 150" width="330" height="150" role="img" aria-label="SelfPost — self-hosted SMTP relay">
|
||||||
|
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
|
||||||
|
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
|
||||||
|
<path d="M24.70 14.00 L27.97 14.00 A2.7 2.7 0 0 0 33.37 14.00 L36.63 14.00 A2.7 2.7 0 0 0 42.03 14.00 L45.30 14.00 A2.7 2.7 0 0 0 50.70 14.00 L53.97 14.00 A2.7 2.7 0 0 0 59.37 14.00 L62.63 14.00 A2.7 2.7 0 0 0 68.03 14.00 L71.30 14.00 A2.7 2.7 0 0 0 76.70 14.00 L79.97 14.00 A2.7 2.7 0 0 0 85.37 14.00 L88.63 14.00 A2.7 2.7 0 0 0 94.03 14.00 L97.30 14.00 A2.7 2.7 0 0 0 102.70 14.00 L105.97 14.00 A2.7 2.7 0 0 0 111.37 14.00 L114.63 14.00 A2.7 2.7 0 0 0 120.03 14.00 L123.30 14.00 A2.7 2.7 0 0 0 128.70 14.00 L131.97 14.00 A2.7 2.7 0 0 0 137.37 14.00 L140.63 14.00 A2.7 2.7 0 0 0 146.03 14.00 L149.30 14.00 A2.7 2.7 0 0 0 154.70 14.00 L157.97 14.00 A2.7 2.7 0 0 0 163.37 14.00 L166.63 14.00 A2.7 2.7 0 0 0 172.03 14.00 L175.30 14.00 A2.7 2.7 0 0 0 180.70 14.00 L183.97 14.00 A2.7 2.7 0 0 0 189.37 14.00 L192.63 14.00 A2.7 2.7 0 0 0 198.03 14.00 L201.30 14.00 A2.7 2.7 0 0 0 206.70 14.00 L209.97 14.00 A2.7 2.7 0 0 0 215.37 14.00 L218.63 14.00 A2.7 2.7 0 0 0 224.03 14.00 L227.30 14.00 A2.7 2.7 0 0 0 232.70 14.00 L235.97 14.00 A2.7 2.7 0 0 0 241.37 14.00 L244.63 14.00 A2.7 2.7 0 0 0 250.03 14.00 L253.30 14.00 A2.7 2.7 0 0 0 258.70 14.00 L261.97 14.00 A2.7 2.7 0 0 0 267.37 14.00 L270.63 14.00 A2.7 2.7 0 0 0 276.03 14.00 L279.30 14.00 A2.7 2.7 0 0 0 284.70 14.00 L287.97 14.00 A2.7 2.7 0 0 0 293.37 14.00 L296.63 14.00 A2.7 2.7 0 0 0 302.03 14.00 L305.30 14.00 A2.7 2.7 0 0 0 308.00 16.70 L308.00 20.01 A2.7 2.7 0 0 0 308.00 25.41 L308.00 28.73 A2.7 2.7 0 0 0 308.00 34.13 L308.00 37.44 A2.7 2.7 0 0 0 308.00 42.84 L308.00 46.16 A2.7 2.7 0 0 0 308.00 51.56 L308.00 54.87 A2.7 2.7 0 0 0 308.00 60.27 L308.00 63.59 A2.7 2.7 0 0 0 308.00 68.99 L308.00 72.30 A2.7 2.7 0 0 0 308.00 77.70 L308.00 81.01 A2.7 2.7 0 0 0 308.00 86.41 L308.00 89.73 A2.7 2.7 0 0 0 308.00 95.13 L308.00 98.44 A2.7 2.7 0 0 0 308.00 103.84 L308.00 107.16 A2.7 2.7 0 0 0 308.00 112.56 L308.00 115.87 A2.7 2.7 0 0 0 308.00 121.27 L308.00 124.59 A2.7 2.7 0 0 0 308.00 129.99 L308.00 133.30 A2.7 2.7 0 0 0 305.30 136.00 L302.03 136.00 A2.7 2.7 0 0 0 296.63 136.00 L293.37 136.00 A2.7 2.7 0 0 0 287.97 136.00 L284.70 136.00 A2.7 2.7 0 0 0 279.30 136.00 L276.03 136.00 A2.7 2.7 0 0 0 270.63 136.00 L267.37 136.00 A2.7 2.7 0 0 0 261.97 136.00 L258.70 136.00 A2.7 2.7 0 0 0 253.30 136.00 L250.03 136.00 A2.7 2.7 0 0 0 244.63 136.00 L241.37 136.00 A2.7 2.7 0 0 0 235.97 136.00 L232.70 136.00 A2.7 2.7 0 0 0 227.30 136.00 L224.03 136.00 A2.7 2.7 0 0 0 218.63 136.00 L215.37 136.00 A2.7 2.7 0 0 0 209.97 136.00 L206.70 136.00 A2.7 2.7 0 0 0 201.30 136.00 L198.03 136.00 A2.7 2.7 0 0 0 192.63 136.00 L189.37 136.00 A2.7 2.7 0 0 0 183.97 136.00 L180.70 136.00 A2.7 2.7 0 0 0 175.30 136.00 L172.03 136.00 A2.7 2.7 0 0 0 166.63 136.00 L163.37 136.00 A2.7 2.7 0 0 0 157.97 136.00 L154.70 136.00 A2.7 2.7 0 0 0 149.30 136.00 L146.03 136.00 A2.7 2.7 0 0 0 140.63 136.00 L137.37 136.00 A2.7 2.7 0 0 0 131.97 136.00 L128.70 136.00 A2.7 2.7 0 0 0 123.30 136.00 L120.03 136.00 A2.7 2.7 0 0 0 114.63 136.00 L111.37 136.00 A2.7 2.7 0 0 0 105.97 136.00 L102.70 136.00 A2.7 2.7 0 0 0 97.30 136.00 L94.03 136.00 A2.7 2.7 0 0 0 88.63 136.00 L85.37 136.00 A2.7 2.7 0 0 0 79.97 136.00 L76.70 136.00 A2.7 2.7 0 0 0 71.30 136.00 L68.03 136.00 A2.7 2.7 0 0 0 62.63 136.00 L59.37 136.00 A2.7 2.7 0 0 0 53.97 136.00 L50.70 136.00 A2.7 2.7 0 0 0 45.30 136.00 L42.03 136.00 A2.7 2.7 0 0 0 36.63 136.00 L33.37 136.00 A2.7 2.7 0 0 0 27.97 136.00 L24.70 136.00 A2.7 2.7 0 0 0 22.00 133.30 L22.00 129.99 A2.7 2.7 0 0 0 22.00 124.59 L22.00 121.27 A2.7 2.7 0 0 0 22.00 115.87 L22.00 112.56 A2.7 2.7 0 0 0 22.00 107.16 L22.00 103.84 A2.7 2.7 0 0 0 22.00 98.44 L22.00 95.13 A2.7 2.7 0 0 0 22.00 89.73 L22.00 86.41 A2.7 2.7 0 0 0 22.00 81.01 L22.00 77.70 A2.7 2.7 0 0 0 22.00 72.30 L22.00 68.99 A2.7 2.7 0 0 0 22.00 63.59 L22.00 60.27 A2.7 2.7 0 0 0 22.00 54.87 L22.00 51.56 A2.7 2.7 0 0 0 22.00 46.16 L22.00 42.84 A2.7 2.7 0 0 0 22.00 37.44 L22.00 34.13 A2.7 2.7 0 0 0 22.00 28.73 L22.00 25.41 A2.7 2.7 0 0 0 22.00 20.01 L22.00 16.70 A2.7 2.7 0 0 0 24.70 14.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round"/>
|
||||||
|
<rect x="31" y="23" width="268" height="104" fill="#7A3B2E"/>
|
||||||
|
<text x="165.0" y="82" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="38" letter-spacing="-1.4" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
|
||||||
|
<path d="M137.0 98 H193.0" stroke="#F6EDE2" stroke-width="1" opacity=".5"/>
|
||||||
|
<text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="11.5" letter-spacing=".15" fill="#F6EDE2">SELF-HOSTED SMTP RELAY</text>
|
||||||
|
</svg>
|
||||||
|
<h3>SelfPost</h3>
|
||||||
|
<p>Self-hosted outbound SMTP relay with a web control panel, shipped as a single Docker image.</p></div>
|
||||||
|
</div><div class="cap"><b>dark</b><br>Тот же файл: на тёмном работает поле, контур просто исчезает.</div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Шапка панели</h2>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="ph"><svg class="" viewBox="0 0 220 100" width="220" height="100" role="img" aria-label="SelfPost">
|
||||||
|
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
|
||||||
|
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
|
||||||
|
<path d="M10.20 8.00 L12.83 8.00 A2.2 2.2 0 0 0 17.23 8.00 L19.87 8.00 A2.2 2.2 0 0 0 24.27 8.00 L26.90 8.00 A2.2 2.2 0 0 0 31.30 8.00 L33.94 8.00 A2.2 2.2 0 0 0 38.34 8.00 L40.97 8.00 A2.2 2.2 0 0 0 45.37 8.00 L48.01 8.00 A2.2 2.2 0 0 0 52.41 8.00 L55.04 8.00 A2.2 2.2 0 0 0 59.44 8.00 L62.08 8.00 A2.2 2.2 0 0 0 66.48 8.00 L69.11 8.00 A2.2 2.2 0 0 0 73.51 8.00 L76.14 8.00 A2.2 2.2 0 0 0 80.54 8.00 L83.18 8.00 A2.2 2.2 0 0 0 87.58 8.00 L90.21 8.00 A2.2 2.2 0 0 0 94.61 8.00 L97.25 8.00 A2.2 2.2 0 0 0 101.65 8.00 L104.28 8.00 A2.2 2.2 0 0 0 108.68 8.00 L111.32 8.00 A2.2 2.2 0 0 0 115.72 8.00 L118.35 8.00 A2.2 2.2 0 0 0 122.75 8.00 L125.39 8.00 A2.2 2.2 0 0 0 129.79 8.00 L132.42 8.00 A2.2 2.2 0 0 0 136.82 8.00 L139.46 8.00 A2.2 2.2 0 0 0 143.86 8.00 L146.49 8.00 A2.2 2.2 0 0 0 150.89 8.00 L153.52 8.00 A2.2 2.2 0 0 0 157.92 8.00 L160.56 8.00 A2.2 2.2 0 0 0 164.96 8.00 L167.59 8.00 A2.2 2.2 0 0 0 171.99 8.00 L174.63 8.00 A2.2 2.2 0 0 0 179.03 8.00 L181.66 8.00 A2.2 2.2 0 0 0 186.06 8.00 L188.70 8.00 A2.2 2.2 0 0 0 193.10 8.00 L195.73 8.00 A2.2 2.2 0 0 0 200.13 8.00 L202.77 8.00 A2.2 2.2 0 0 0 207.17 8.00 L209.80 8.00 A2.2 2.2 0 0 0 212.00 10.20 L212.00 12.80 A2.2 2.2 0 0 0 212.00 17.20 L212.00 19.80 A2.2 2.2 0 0 0 212.00 24.20 L212.00 26.80 A2.2 2.2 0 0 0 212.00 31.20 L212.00 33.80 A2.2 2.2 0 0 0 212.00 38.20 L212.00 40.80 A2.2 2.2 0 0 0 212.00 45.20 L212.00 47.80 A2.2 2.2 0 0 0 212.00 52.20 L212.00 54.80 A2.2 2.2 0 0 0 212.00 59.20 L212.00 61.80 A2.2 2.2 0 0 0 212.00 66.20 L212.00 68.80 A2.2 2.2 0 0 0 212.00 73.20 L212.00 75.80 A2.2 2.2 0 0 0 212.00 80.20 L212.00 82.80 A2.2 2.2 0 0 0 212.00 87.20 L212.00 89.80 A2.2 2.2 0 0 0 209.80 92.00 L207.17 92.00 A2.2 2.2 0 0 0 202.77 92.00 L200.13 92.00 A2.2 2.2 0 0 0 195.73 92.00 L193.10 92.00 A2.2 2.2 0 0 0 188.70 92.00 L186.06 92.00 A2.2 2.2 0 0 0 181.66 92.00 L179.03 92.00 A2.2 2.2 0 0 0 174.63 92.00 L171.99 92.00 A2.2 2.2 0 0 0 167.59 92.00 L164.96 92.00 A2.2 2.2 0 0 0 160.56 92.00 L157.92 92.00 A2.2 2.2 0 0 0 153.52 92.00 L150.89 92.00 A2.2 2.2 0 0 0 146.49 92.00 L143.86 92.00 A2.2 2.2 0 0 0 139.46 92.00 L136.82 92.00 A2.2 2.2 0 0 0 132.42 92.00 L129.79 92.00 A2.2 2.2 0 0 0 125.39 92.00 L122.75 92.00 A2.2 2.2 0 0 0 118.35 92.00 L115.72 92.00 A2.2 2.2 0 0 0 111.32 92.00 L108.68 92.00 A2.2 2.2 0 0 0 104.28 92.00 L101.65 92.00 A2.2 2.2 0 0 0 97.25 92.00 L94.61 92.00 A2.2 2.2 0 0 0 90.21 92.00 L87.58 92.00 A2.2 2.2 0 0 0 83.18 92.00 L80.54 92.00 A2.2 2.2 0 0 0 76.14 92.00 L73.51 92.00 A2.2 2.2 0 0 0 69.11 92.00 L66.48 92.00 A2.2 2.2 0 0 0 62.08 92.00 L59.44 92.00 A2.2 2.2 0 0 0 55.04 92.00 L52.41 92.00 A2.2 2.2 0 0 0 48.01 92.00 L45.37 92.00 A2.2 2.2 0 0 0 40.97 92.00 L38.34 92.00 A2.2 2.2 0 0 0 33.94 92.00 L31.30 92.00 A2.2 2.2 0 0 0 26.90 92.00 L24.27 92.00 A2.2 2.2 0 0 0 19.87 92.00 L17.23 92.00 A2.2 2.2 0 0 0 12.83 92.00 L10.20 92.00 A2.2 2.2 0 0 0 8.00 89.80 L8.00 87.20 A2.2 2.2 0 0 0 8.00 82.80 L8.00 80.20 A2.2 2.2 0 0 0 8.00 75.80 L8.00 73.20 A2.2 2.2 0 0 0 8.00 68.80 L8.00 66.20 A2.2 2.2 0 0 0 8.00 61.80 L8.00 59.20 A2.2 2.2 0 0 0 8.00 54.80 L8.00 52.20 A2.2 2.2 0 0 0 8.00 47.80 L8.00 45.20 A2.2 2.2 0 0 0 8.00 40.80 L8.00 38.20 A2.2 2.2 0 0 0 8.00 33.80 L8.00 31.20 A2.2 2.2 0 0 0 8.00 26.80 L8.00 24.20 A2.2 2.2 0 0 0 8.00 19.80 L8.00 17.20 A2.2 2.2 0 0 0 8.00 12.80 L8.00 10.20 A2.2 2.2 0 0 0 10.20 8.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.7" stroke-linejoin="round"/>
|
||||||
|
<rect x="15" y="15" width="190" height="70" fill="#7A3B2E"/>
|
||||||
|
<text x="110.0" y="66" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="34" letter-spacing="-1.2" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
|
||||||
|
</svg>
|
||||||
|
<div class="nav"><b>Domains</b><span>Applications</span><span>Send log</span><span>Backup</span></div></div>
|
||||||
|
<div class="pb">
|
||||||
|
<div class="row">example.org 14:22:07 <span class="badge ok">delivered</span></div>
|
||||||
|
<div class="row">shop.example.org 14:21:55 <span class="badge er">bounced</span></div>
|
||||||
|
<div class="row">example.org 14:19:03 <span class="badge ok">delivered</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cap"><b>проверка на конфликт</b><br>Ровно тот экран, ради которого мы отбраковывали краски: кирпич рядом с зелёным «delivered» и красным «bounced» не читается ни как один из них.</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Фавикон и аватар</h2>
|
||||||
|
<div class="grid2">
|
||||||
|
<div>
|
||||||
|
<div class="tabs">
|
||||||
|
<div class="tab"><svg class="" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="SelfPost">
|
||||||
|
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
|
||||||
|
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
|
||||||
|
<path d="M7.80 5.00 L13.00 5.00 A2.8 2.8 0 0 0 18.60 5.00 L23.80 5.00 A2.8 2.8 0 0 0 29.40 5.00 L34.60 5.00 A2.8 2.8 0 0 0 40.20 5.00 L45.40 5.00 A2.8 2.8 0 0 0 51.00 5.00 L56.20 5.00 A2.8 2.8 0 0 0 59.00 7.80 L59.00 13.00 A2.8 2.8 0 0 0 59.00 18.60 L59.00 23.80 A2.8 2.8 0 0 0 59.00 29.40 L59.00 34.60 A2.8 2.8 0 0 0 59.00 40.20 L59.00 45.40 A2.8 2.8 0 0 0 59.00 51.00 L59.00 56.20 A2.8 2.8 0 0 0 56.20 59.00 L51.00 59.00 A2.8 2.8 0 0 0 45.40 59.00 L40.20 59.00 A2.8 2.8 0 0 0 34.60 59.00 L29.40 59.00 A2.8 2.8 0 0 0 23.80 59.00 L18.60 59.00 A2.8 2.8 0 0 0 13.00 59.00 L7.80 59.00 A2.8 2.8 0 0 0 5.00 56.20 L5.00 51.00 A2.8 2.8 0 0 0 5.00 45.40 L5.00 40.20 A2.8 2.8 0 0 0 5.00 34.60 L5.00 29.40 A2.8 2.8 0 0 0 5.00 23.80 L5.00 18.60 A2.8 2.8 0 0 0 5.00 13.00 L5.00 7.80 A2.8 2.8 0 0 0 7.80 5.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round"/>
|
||||||
|
<rect x="11" y="11" width="42" height="42" fill="#7A3B2E"/>
|
||||||
|
<text x="32" y="43" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="26" letter-spacing="-1" fill="#F6EDE2"><tspan font-weight="500">S</tspan><tspan font-weight="600">P</tspan></text>
|
||||||
|
</svg><span>SelfPost — Domains</span></div>
|
||||||
|
<div class="tab off"><svg class="" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="SelfPost">
|
||||||
|
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
|
||||||
|
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
|
||||||
|
<path d="M7.90 6.00 L10.60 6.00 A1.9 1.9 0 0 0 14.40 6.00 L17.10 6.00 A1.9 1.9 0 0 0 20.90 6.00 L23.60 6.00 A1.9 1.9 0 0 0 27.40 6.00 L30.10 6.00 A1.9 1.9 0 0 0 33.90 6.00 L36.60 6.00 A1.9 1.9 0 0 0 40.40 6.00 L43.10 6.00 A1.9 1.9 0 0 0 46.90 6.00 L49.60 6.00 A1.9 1.9 0 0 0 53.40 6.00 L56.10 6.00 A1.9 1.9 0 0 0 58.00 7.90 L58.00 10.60 A1.9 1.9 0 0 0 58.00 14.40 L58.00 17.10 A1.9 1.9 0 0 0 58.00 20.90 L58.00 23.60 A1.9 1.9 0 0 0 58.00 27.40 L58.00 30.10 A1.9 1.9 0 0 0 58.00 33.90 L58.00 36.60 A1.9 1.9 0 0 0 58.00 40.40 L58.00 43.10 A1.9 1.9 0 0 0 58.00 46.90 L58.00 49.60 A1.9 1.9 0 0 0 58.00 53.40 L58.00 56.10 A1.9 1.9 0 0 0 56.10 58.00 L53.40 58.00 A1.9 1.9 0 0 0 49.60 58.00 L46.90 58.00 A1.9 1.9 0 0 0 43.10 58.00 L40.40 58.00 A1.9 1.9 0 0 0 36.60 58.00 L33.90 58.00 A1.9 1.9 0 0 0 30.10 58.00 L27.40 58.00 A1.9 1.9 0 0 0 23.60 58.00 L20.90 58.00 A1.9 1.9 0 0 0 17.10 58.00 L14.40 58.00 A1.9 1.9 0 0 0 10.60 58.00 L7.90 58.00 A1.9 1.9 0 0 0 6.00 56.10 L6.00 53.40 A1.9 1.9 0 0 0 6.00 49.60 L6.00 46.90 A1.9 1.9 0 0 0 6.00 43.10 L6.00 40.40 A1.9 1.9 0 0 0 6.00 36.60 L6.00 33.90 A1.9 1.9 0 0 0 6.00 30.10 L6.00 27.40 A1.9 1.9 0 0 0 6.00 23.60 L6.00 20.90 A1.9 1.9 0 0 0 6.00 17.10 L6.00 14.40 A1.9 1.9 0 0 0 6.00 10.60 L6.00 7.90 A1.9 1.9 0 0 0 7.90 6.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.6" stroke-linejoin="round"/>
|
||||||
|
<rect x="11" y="11" width="42" height="42" fill="#7A3B2E"/>
|
||||||
|
<text x="32" y="31" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="14" font-weight="200" letter-spacing="-.4" fill="#F6EDE2">Self</text>
|
||||||
|
<text x="32" y="45" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="14" font-weight="600" letter-spacing="-.4" fill="#F6EDE2">Post</text>
|
||||||
|
</svg><span>docs</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="url">https://mail.example.org/domains</div>
|
||||||
|
<div class="cap"><b>16 px</b><br>Слева версия для мелкого размера: зубцы крупнее и реже, вместо двух строк — SP. Справа для сравнения полная иконка в том же размере: превращается в кашу.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="avs">
|
||||||
|
<img class="av" width="80" height="80" alt="" src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' width='64' height='64' role='img' aria-label='SelfPost'%3E%3C!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0 Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. --%3E%3Cpath d='M7.90 6.00 L10.60 6.00 A1.9 1.9 0 0 0 14.40 6.00 L17.10 6.00 A1.9 1.9 0 0 0 20.90 6.00 L23.60 6.00 A1.9 1.9 0 0 0 27.40 6.00 L30.10 6.00 A1.9 1.9 0 0 0 33.90 6.00 L36.60 6.00 A1.9 1.9 0 0 0 40.40 6.00 L43.10 6.00 A1.9 1.9 0 0 0 46.90 6.00 L49.60 6.00 A1.9 1.9 0 0 0 53.40 6.00 L56.10 6.00 A1.9 1.9 0 0 0 58.00 7.90 L58.00 10.60 A1.9 1.9 0 0 0 58.00 14.40 L58.00 17.10 A1.9 1.9 0 0 0 58.00 20.90 L58.00 23.60 A1.9 1.9 0 0 0 58.00 27.40 L58.00 30.10 A1.9 1.9 0 0 0 58.00 33.90 L58.00 36.60 A1.9 1.9 0 0 0 58.00 40.40 L58.00 43.10 A1.9 1.9 0 0 0 58.00 46.90 L58.00 49.60 A1.9 1.9 0 0 0 58.00 53.40 L58.00 56.10 A1.9 1.9 0 0 0 56.10 58.00 L53.40 58.00 A1.9 1.9 0 0 0 49.60 58.00 L46.90 58.00 A1.9 1.9 0 0 0 43.10 58.00 L40.40 58.00 A1.9 1.9 0 0 0 36.60 58.00 L33.90 58.00 A1.9 1.9 0 0 0 30.10 58.00 L27.40 58.00 A1.9 1.9 0 0 0 23.60 58.00 L20.90 58.00 A1.9 1.9 0 0 0 17.10 58.00 L14.40 58.00 A1.9 1.9 0 0 0 10.60 58.00 L7.90 58.00 A1.9 1.9 0 0 0 6.00 56.10 L6.00 53.40 A1.9 1.9 0 0 0 6.00 49.60 L6.00 46.90 A1.9 1.9 0 0 0 6.00 43.10 L6.00 40.40 A1.9 1.9 0 0 0 6.00 36.60 L6.00 33.90 A1.9 1.9 0 0 0 6.00 30.10 L6.00 27.40 A1.9 1.9 0 0 0 6.00 23.60 L6.00 20.90 A1.9 1.9 0 0 0 6.00 17.10 L6.00 14.40 A1.9 1.9 0 0 0 6.00 10.60 L6.00 7.90 A1.9 1.9 0 0 0 7.90 6.00 Z' fill='%23F3EDE1' stroke='%2312161C' stroke-width='0.6' stroke-linejoin='round'/%3E%3Crect x='11' y='11' width='42' height='42' fill='%237A3B2E'/%3E%3Ctext x='32' y='31' text-anchor='middle' font-family=''IBM Plex Sans','Helvetica Neue',sans-serif' font-size='14' font-weight='200' letter-spacing='-.4' fill='%23F6EDE2'%3ESelf%3C/text%3E%3Ctext x='32' y='45' text-anchor='middle' font-family=''IBM Plex Sans','Helvetica Neue',sans-serif' font-size='14' font-weight='600' letter-spacing='-.4' fill='%23F6EDE2'%3EPost%3C/text%3E%3C/svg%3E">
|
||||||
|
<img class="av" width="48" height="48" alt="" src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' width='64' height='64' role='img' aria-label='SelfPost'%3E%3C!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0 Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. --%3E%3Cpath d='M7.90 6.00 L10.60 6.00 A1.9 1.9 0 0 0 14.40 6.00 L17.10 6.00 A1.9 1.9 0 0 0 20.90 6.00 L23.60 6.00 A1.9 1.9 0 0 0 27.40 6.00 L30.10 6.00 A1.9 1.9 0 0 0 33.90 6.00 L36.60 6.00 A1.9 1.9 0 0 0 40.40 6.00 L43.10 6.00 A1.9 1.9 0 0 0 46.90 6.00 L49.60 6.00 A1.9 1.9 0 0 0 53.40 6.00 L56.10 6.00 A1.9 1.9 0 0 0 58.00 7.90 L58.00 10.60 A1.9 1.9 0 0 0 58.00 14.40 L58.00 17.10 A1.9 1.9 0 0 0 58.00 20.90 L58.00 23.60 A1.9 1.9 0 0 0 58.00 27.40 L58.00 30.10 A1.9 1.9 0 0 0 58.00 33.90 L58.00 36.60 A1.9 1.9 0 0 0 58.00 40.40 L58.00 43.10 A1.9 1.9 0 0 0 58.00 46.90 L58.00 49.60 A1.9 1.9 0 0 0 58.00 53.40 L58.00 56.10 A1.9 1.9 0 0 0 56.10 58.00 L53.40 58.00 A1.9 1.9 0 0 0 49.60 58.00 L46.90 58.00 A1.9 1.9 0 0 0 43.10 58.00 L40.40 58.00 A1.9 1.9 0 0 0 36.60 58.00 L33.90 58.00 A1.9 1.9 0 0 0 30.10 58.00 L27.40 58.00 A1.9 1.9 0 0 0 23.60 58.00 L20.90 58.00 A1.9 1.9 0 0 0 17.10 58.00 L14.40 58.00 A1.9 1.9 0 0 0 10.60 58.00 L7.90 58.00 A1.9 1.9 0 0 0 6.00 56.10 L6.00 53.40 A1.9 1.9 0 0 0 6.00 49.60 L6.00 46.90 A1.9 1.9 0 0 0 6.00 43.10 L6.00 40.40 A1.9 1.9 0 0 0 6.00 36.60 L6.00 33.90 A1.9 1.9 0 0 0 6.00 30.10 L6.00 27.40 A1.9 1.9 0 0 0 6.00 23.60 L6.00 20.90 A1.9 1.9 0 0 0 6.00 17.10 L6.00 14.40 A1.9 1.9 0 0 0 6.00 10.60 L6.00 7.90 A1.9 1.9 0 0 0 7.90 6.00 Z' fill='%23F3EDE1' stroke='%2312161C' stroke-width='0.6' stroke-linejoin='round'/%3E%3Crect x='11' y='11' width='42' height='42' fill='%237A3B2E'/%3E%3Ctext x='32' y='31' text-anchor='middle' font-family=''IBM Plex Sans','Helvetica Neue',sans-serif' font-size='14' font-weight='200' letter-spacing='-.4' fill='%23F6EDE2'%3ESelf%3C/text%3E%3Ctext x='32' y='45' text-anchor='middle' font-family=''IBM Plex Sans','Helvetica Neue',sans-serif' font-size='14' font-weight='600' letter-spacing='-.4' fill='%23F6EDE2'%3EPost%3C/text%3E%3C/svg%3E">
|
||||||
|
<img class="av" width="32" height="32" alt="" src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' width='64' height='64' role='img' aria-label='SelfPost'%3E%3C!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0 Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. --%3E%3Cpath d='M7.80 5.00 L13.00 5.00 A2.8 2.8 0 0 0 18.60 5.00 L23.80 5.00 A2.8 2.8 0 0 0 29.40 5.00 L34.60 5.00 A2.8 2.8 0 0 0 40.20 5.00 L45.40 5.00 A2.8 2.8 0 0 0 51.00 5.00 L56.20 5.00 A2.8 2.8 0 0 0 59.00 7.80 L59.00 13.00 A2.8 2.8 0 0 0 59.00 18.60 L59.00 23.80 A2.8 2.8 0 0 0 59.00 29.40 L59.00 34.60 A2.8 2.8 0 0 0 59.00 40.20 L59.00 45.40 A2.8 2.8 0 0 0 59.00 51.00 L59.00 56.20 A2.8 2.8 0 0 0 56.20 59.00 L51.00 59.00 A2.8 2.8 0 0 0 45.40 59.00 L40.20 59.00 A2.8 2.8 0 0 0 34.60 59.00 L29.40 59.00 A2.8 2.8 0 0 0 23.80 59.00 L18.60 59.00 A2.8 2.8 0 0 0 13.00 59.00 L7.80 59.00 A2.8 2.8 0 0 0 5.00 56.20 L5.00 51.00 A2.8 2.8 0 0 0 5.00 45.40 L5.00 40.20 A2.8 2.8 0 0 0 5.00 34.60 L5.00 29.40 A2.8 2.8 0 0 0 5.00 23.80 L5.00 18.60 A2.8 2.8 0 0 0 5.00 13.00 L5.00 7.80 A2.8 2.8 0 0 0 7.80 5.00 Z' fill='%23F3EDE1' stroke='%2312161C' stroke-width='0.8' stroke-linejoin='round'/%3E%3Crect x='11' y='11' width='42' height='42' fill='%237A3B2E'/%3E%3Ctext x='32' y='43' text-anchor='middle' font-family=''IBM Plex Sans','Helvetica Neue',sans-serif' font-size='26' letter-spacing='-1' fill='%23F6EDE2'%3E%3Ctspan font-weight='500'%3ES%3C/tspan%3E%3Ctspan font-weight='600'%3EP%3C/tspan%3E%3C/text%3E%3C/svg%3E">
|
||||||
|
</div>
|
||||||
|
<div class="cap"><b>80 / 48 / 32 px</b><br>Аватар организации на GitHub. Порог перехода на версию SP — 32 px.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Размеры и охранное поле</h2>
|
||||||
|
<div class="sizes">
|
||||||
|
<div class="u"><svg class="s1" viewBox="0 0 330 150" width="330" height="150" role="img" aria-label="SelfPost — self-hosted SMTP relay">
|
||||||
|
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
|
||||||
|
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
|
||||||
|
<rect x="-4" y="-12" width="338" height="174" fill="none" stroke="#7A3B2E" stroke-width="1" stroke-dasharray="4 3" opacity=".55"/><path d="M24.70 14.00 L27.97 14.00 A2.7 2.7 0 0 0 33.37 14.00 L36.63 14.00 A2.7 2.7 0 0 0 42.03 14.00 L45.30 14.00 A2.7 2.7 0 0 0 50.70 14.00 L53.97 14.00 A2.7 2.7 0 0 0 59.37 14.00 L62.63 14.00 A2.7 2.7 0 0 0 68.03 14.00 L71.30 14.00 A2.7 2.7 0 0 0 76.70 14.00 L79.97 14.00 A2.7 2.7 0 0 0 85.37 14.00 L88.63 14.00 A2.7 2.7 0 0 0 94.03 14.00 L97.30 14.00 A2.7 2.7 0 0 0 102.70 14.00 L105.97 14.00 A2.7 2.7 0 0 0 111.37 14.00 L114.63 14.00 A2.7 2.7 0 0 0 120.03 14.00 L123.30 14.00 A2.7 2.7 0 0 0 128.70 14.00 L131.97 14.00 A2.7 2.7 0 0 0 137.37 14.00 L140.63 14.00 A2.7 2.7 0 0 0 146.03 14.00 L149.30 14.00 A2.7 2.7 0 0 0 154.70 14.00 L157.97 14.00 A2.7 2.7 0 0 0 163.37 14.00 L166.63 14.00 A2.7 2.7 0 0 0 172.03 14.00 L175.30 14.00 A2.7 2.7 0 0 0 180.70 14.00 L183.97 14.00 A2.7 2.7 0 0 0 189.37 14.00 L192.63 14.00 A2.7 2.7 0 0 0 198.03 14.00 L201.30 14.00 A2.7 2.7 0 0 0 206.70 14.00 L209.97 14.00 A2.7 2.7 0 0 0 215.37 14.00 L218.63 14.00 A2.7 2.7 0 0 0 224.03 14.00 L227.30 14.00 A2.7 2.7 0 0 0 232.70 14.00 L235.97 14.00 A2.7 2.7 0 0 0 241.37 14.00 L244.63 14.00 A2.7 2.7 0 0 0 250.03 14.00 L253.30 14.00 A2.7 2.7 0 0 0 258.70 14.00 L261.97 14.00 A2.7 2.7 0 0 0 267.37 14.00 L270.63 14.00 A2.7 2.7 0 0 0 276.03 14.00 L279.30 14.00 A2.7 2.7 0 0 0 284.70 14.00 L287.97 14.00 A2.7 2.7 0 0 0 293.37 14.00 L296.63 14.00 A2.7 2.7 0 0 0 302.03 14.00 L305.30 14.00 A2.7 2.7 0 0 0 308.00 16.70 L308.00 20.01 A2.7 2.7 0 0 0 308.00 25.41 L308.00 28.73 A2.7 2.7 0 0 0 308.00 34.13 L308.00 37.44 A2.7 2.7 0 0 0 308.00 42.84 L308.00 46.16 A2.7 2.7 0 0 0 308.00 51.56 L308.00 54.87 A2.7 2.7 0 0 0 308.00 60.27 L308.00 63.59 A2.7 2.7 0 0 0 308.00 68.99 L308.00 72.30 A2.7 2.7 0 0 0 308.00 77.70 L308.00 81.01 A2.7 2.7 0 0 0 308.00 86.41 L308.00 89.73 A2.7 2.7 0 0 0 308.00 95.13 L308.00 98.44 A2.7 2.7 0 0 0 308.00 103.84 L308.00 107.16 A2.7 2.7 0 0 0 308.00 112.56 L308.00 115.87 A2.7 2.7 0 0 0 308.00 121.27 L308.00 124.59 A2.7 2.7 0 0 0 308.00 129.99 L308.00 133.30 A2.7 2.7 0 0 0 305.30 136.00 L302.03 136.00 A2.7 2.7 0 0 0 296.63 136.00 L293.37 136.00 A2.7 2.7 0 0 0 287.97 136.00 L284.70 136.00 A2.7 2.7 0 0 0 279.30 136.00 L276.03 136.00 A2.7 2.7 0 0 0 270.63 136.00 L267.37 136.00 A2.7 2.7 0 0 0 261.97 136.00 L258.70 136.00 A2.7 2.7 0 0 0 253.30 136.00 L250.03 136.00 A2.7 2.7 0 0 0 244.63 136.00 L241.37 136.00 A2.7 2.7 0 0 0 235.97 136.00 L232.70 136.00 A2.7 2.7 0 0 0 227.30 136.00 L224.03 136.00 A2.7 2.7 0 0 0 218.63 136.00 L215.37 136.00 A2.7 2.7 0 0 0 209.97 136.00 L206.70 136.00 A2.7 2.7 0 0 0 201.30 136.00 L198.03 136.00 A2.7 2.7 0 0 0 192.63 136.00 L189.37 136.00 A2.7 2.7 0 0 0 183.97 136.00 L180.70 136.00 A2.7 2.7 0 0 0 175.30 136.00 L172.03 136.00 A2.7 2.7 0 0 0 166.63 136.00 L163.37 136.00 A2.7 2.7 0 0 0 157.97 136.00 L154.70 136.00 A2.7 2.7 0 0 0 149.30 136.00 L146.03 136.00 A2.7 2.7 0 0 0 140.63 136.00 L137.37 136.00 A2.7 2.7 0 0 0 131.97 136.00 L128.70 136.00 A2.7 2.7 0 0 0 123.30 136.00 L120.03 136.00 A2.7 2.7 0 0 0 114.63 136.00 L111.37 136.00 A2.7 2.7 0 0 0 105.97 136.00 L102.70 136.00 A2.7 2.7 0 0 0 97.30 136.00 L94.03 136.00 A2.7 2.7 0 0 0 88.63 136.00 L85.37 136.00 A2.7 2.7 0 0 0 79.97 136.00 L76.70 136.00 A2.7 2.7 0 0 0 71.30 136.00 L68.03 136.00 A2.7 2.7 0 0 0 62.63 136.00 L59.37 136.00 A2.7 2.7 0 0 0 53.97 136.00 L50.70 136.00 A2.7 2.7 0 0 0 45.30 136.00 L42.03 136.00 A2.7 2.7 0 0 0 36.63 136.00 L33.37 136.00 A2.7 2.7 0 0 0 27.97 136.00 L24.70 136.00 A2.7 2.7 0 0 0 22.00 133.30 L22.00 129.99 A2.7 2.7 0 0 0 22.00 124.59 L22.00 121.27 A2.7 2.7 0 0 0 22.00 115.87 L22.00 112.56 A2.7 2.7 0 0 0 22.00 107.16 L22.00 103.84 A2.7 2.7 0 0 0 22.00 98.44 L22.00 95.13 A2.7 2.7 0 0 0 22.00 89.73 L22.00 86.41 A2.7 2.7 0 0 0 22.00 81.01 L22.00 77.70 A2.7 2.7 0 0 0 22.00 72.30 L22.00 68.99 A2.7 2.7 0 0 0 22.00 63.59 L22.00 60.27 A2.7 2.7 0 0 0 22.00 54.87 L22.00 51.56 A2.7 2.7 0 0 0 22.00 46.16 L22.00 42.84 A2.7 2.7 0 0 0 22.00 37.44 L22.00 34.13 A2.7 2.7 0 0 0 22.00 28.73 L22.00 25.41 A2.7 2.7 0 0 0 22.00 20.01 L22.00 16.70 A2.7 2.7 0 0 0 24.70 14.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round"/>
|
||||||
|
<rect x="31" y="23" width="268" height="104" fill="#7A3B2E"/>
|
||||||
|
<text x="165.0" y="82" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="38" letter-spacing="-1.4" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
|
||||||
|
<path d="M137.0 98 H193.0" stroke="#F6EDE2" stroke-width="1" opacity=".5"/>
|
||||||
|
<text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="11.5" letter-spacing=".15" fill="#F6EDE2">SELF-HOSTED SMTP RELAY</text>
|
||||||
|
</svg><span>полная · от 280 px</span></div>
|
||||||
|
<div class="u"><svg class="" viewBox="0 0 220 100" width="220" height="100" role="img" aria-label="SelfPost">
|
||||||
|
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
|
||||||
|
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
|
||||||
|
<path d="M10.20 8.00 L12.83 8.00 A2.2 2.2 0 0 0 17.23 8.00 L19.87 8.00 A2.2 2.2 0 0 0 24.27 8.00 L26.90 8.00 A2.2 2.2 0 0 0 31.30 8.00 L33.94 8.00 A2.2 2.2 0 0 0 38.34 8.00 L40.97 8.00 A2.2 2.2 0 0 0 45.37 8.00 L48.01 8.00 A2.2 2.2 0 0 0 52.41 8.00 L55.04 8.00 A2.2 2.2 0 0 0 59.44 8.00 L62.08 8.00 A2.2 2.2 0 0 0 66.48 8.00 L69.11 8.00 A2.2 2.2 0 0 0 73.51 8.00 L76.14 8.00 A2.2 2.2 0 0 0 80.54 8.00 L83.18 8.00 A2.2 2.2 0 0 0 87.58 8.00 L90.21 8.00 A2.2 2.2 0 0 0 94.61 8.00 L97.25 8.00 A2.2 2.2 0 0 0 101.65 8.00 L104.28 8.00 A2.2 2.2 0 0 0 108.68 8.00 L111.32 8.00 A2.2 2.2 0 0 0 115.72 8.00 L118.35 8.00 A2.2 2.2 0 0 0 122.75 8.00 L125.39 8.00 A2.2 2.2 0 0 0 129.79 8.00 L132.42 8.00 A2.2 2.2 0 0 0 136.82 8.00 L139.46 8.00 A2.2 2.2 0 0 0 143.86 8.00 L146.49 8.00 A2.2 2.2 0 0 0 150.89 8.00 L153.52 8.00 A2.2 2.2 0 0 0 157.92 8.00 L160.56 8.00 A2.2 2.2 0 0 0 164.96 8.00 L167.59 8.00 A2.2 2.2 0 0 0 171.99 8.00 L174.63 8.00 A2.2 2.2 0 0 0 179.03 8.00 L181.66 8.00 A2.2 2.2 0 0 0 186.06 8.00 L188.70 8.00 A2.2 2.2 0 0 0 193.10 8.00 L195.73 8.00 A2.2 2.2 0 0 0 200.13 8.00 L202.77 8.00 A2.2 2.2 0 0 0 207.17 8.00 L209.80 8.00 A2.2 2.2 0 0 0 212.00 10.20 L212.00 12.80 A2.2 2.2 0 0 0 212.00 17.20 L212.00 19.80 A2.2 2.2 0 0 0 212.00 24.20 L212.00 26.80 A2.2 2.2 0 0 0 212.00 31.20 L212.00 33.80 A2.2 2.2 0 0 0 212.00 38.20 L212.00 40.80 A2.2 2.2 0 0 0 212.00 45.20 L212.00 47.80 A2.2 2.2 0 0 0 212.00 52.20 L212.00 54.80 A2.2 2.2 0 0 0 212.00 59.20 L212.00 61.80 A2.2 2.2 0 0 0 212.00 66.20 L212.00 68.80 A2.2 2.2 0 0 0 212.00 73.20 L212.00 75.80 A2.2 2.2 0 0 0 212.00 80.20 L212.00 82.80 A2.2 2.2 0 0 0 212.00 87.20 L212.00 89.80 A2.2 2.2 0 0 0 209.80 92.00 L207.17 92.00 A2.2 2.2 0 0 0 202.77 92.00 L200.13 92.00 A2.2 2.2 0 0 0 195.73 92.00 L193.10 92.00 A2.2 2.2 0 0 0 188.70 92.00 L186.06 92.00 A2.2 2.2 0 0 0 181.66 92.00 L179.03 92.00 A2.2 2.2 0 0 0 174.63 92.00 L171.99 92.00 A2.2 2.2 0 0 0 167.59 92.00 L164.96 92.00 A2.2 2.2 0 0 0 160.56 92.00 L157.92 92.00 A2.2 2.2 0 0 0 153.52 92.00 L150.89 92.00 A2.2 2.2 0 0 0 146.49 92.00 L143.86 92.00 A2.2 2.2 0 0 0 139.46 92.00 L136.82 92.00 A2.2 2.2 0 0 0 132.42 92.00 L129.79 92.00 A2.2 2.2 0 0 0 125.39 92.00 L122.75 92.00 A2.2 2.2 0 0 0 118.35 92.00 L115.72 92.00 A2.2 2.2 0 0 0 111.32 92.00 L108.68 92.00 A2.2 2.2 0 0 0 104.28 92.00 L101.65 92.00 A2.2 2.2 0 0 0 97.25 92.00 L94.61 92.00 A2.2 2.2 0 0 0 90.21 92.00 L87.58 92.00 A2.2 2.2 0 0 0 83.18 92.00 L80.54 92.00 A2.2 2.2 0 0 0 76.14 92.00 L73.51 92.00 A2.2 2.2 0 0 0 69.11 92.00 L66.48 92.00 A2.2 2.2 0 0 0 62.08 92.00 L59.44 92.00 A2.2 2.2 0 0 0 55.04 92.00 L52.41 92.00 A2.2 2.2 0 0 0 48.01 92.00 L45.37 92.00 A2.2 2.2 0 0 0 40.97 92.00 L38.34 92.00 A2.2 2.2 0 0 0 33.94 92.00 L31.30 92.00 A2.2 2.2 0 0 0 26.90 92.00 L24.27 92.00 A2.2 2.2 0 0 0 19.87 92.00 L17.23 92.00 A2.2 2.2 0 0 0 12.83 92.00 L10.20 92.00 A2.2 2.2 0 0 0 8.00 89.80 L8.00 87.20 A2.2 2.2 0 0 0 8.00 82.80 L8.00 80.20 A2.2 2.2 0 0 0 8.00 75.80 L8.00 73.20 A2.2 2.2 0 0 0 8.00 68.80 L8.00 66.20 A2.2 2.2 0 0 0 8.00 61.80 L8.00 59.20 A2.2 2.2 0 0 0 8.00 54.80 L8.00 52.20 A2.2 2.2 0 0 0 8.00 47.80 L8.00 45.20 A2.2 2.2 0 0 0 8.00 40.80 L8.00 38.20 A2.2 2.2 0 0 0 8.00 33.80 L8.00 31.20 A2.2 2.2 0 0 0 8.00 26.80 L8.00 24.20 A2.2 2.2 0 0 0 8.00 19.80 L8.00 17.20 A2.2 2.2 0 0 0 8.00 12.80 L8.00 10.20 A2.2 2.2 0 0 0 10.20 8.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.7" stroke-linejoin="round"/>
|
||||||
|
<rect x="15" y="15" width="190" height="70" fill="#7A3B2E"/>
|
||||||
|
<text x="110.0" y="66" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="34" letter-spacing="-1.2" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
|
||||||
|
</svg><span>компактная · от 100 px</span></div>
|
||||||
|
<div class="u"><svg class="" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="SelfPost">
|
||||||
|
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
|
||||||
|
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
|
||||||
|
<path d="M7.90 6.00 L10.60 6.00 A1.9 1.9 0 0 0 14.40 6.00 L17.10 6.00 A1.9 1.9 0 0 0 20.90 6.00 L23.60 6.00 A1.9 1.9 0 0 0 27.40 6.00 L30.10 6.00 A1.9 1.9 0 0 0 33.90 6.00 L36.60 6.00 A1.9 1.9 0 0 0 40.40 6.00 L43.10 6.00 A1.9 1.9 0 0 0 46.90 6.00 L49.60 6.00 A1.9 1.9 0 0 0 53.40 6.00 L56.10 6.00 A1.9 1.9 0 0 0 58.00 7.90 L58.00 10.60 A1.9 1.9 0 0 0 58.00 14.40 L58.00 17.10 A1.9 1.9 0 0 0 58.00 20.90 L58.00 23.60 A1.9 1.9 0 0 0 58.00 27.40 L58.00 30.10 A1.9 1.9 0 0 0 58.00 33.90 L58.00 36.60 A1.9 1.9 0 0 0 58.00 40.40 L58.00 43.10 A1.9 1.9 0 0 0 58.00 46.90 L58.00 49.60 A1.9 1.9 0 0 0 58.00 53.40 L58.00 56.10 A1.9 1.9 0 0 0 56.10 58.00 L53.40 58.00 A1.9 1.9 0 0 0 49.60 58.00 L46.90 58.00 A1.9 1.9 0 0 0 43.10 58.00 L40.40 58.00 A1.9 1.9 0 0 0 36.60 58.00 L33.90 58.00 A1.9 1.9 0 0 0 30.10 58.00 L27.40 58.00 A1.9 1.9 0 0 0 23.60 58.00 L20.90 58.00 A1.9 1.9 0 0 0 17.10 58.00 L14.40 58.00 A1.9 1.9 0 0 0 10.60 58.00 L7.90 58.00 A1.9 1.9 0 0 0 6.00 56.10 L6.00 53.40 A1.9 1.9 0 0 0 6.00 49.60 L6.00 46.90 A1.9 1.9 0 0 0 6.00 43.10 L6.00 40.40 A1.9 1.9 0 0 0 6.00 36.60 L6.00 33.90 A1.9 1.9 0 0 0 6.00 30.10 L6.00 27.40 A1.9 1.9 0 0 0 6.00 23.60 L6.00 20.90 A1.9 1.9 0 0 0 6.00 17.10 L6.00 14.40 A1.9 1.9 0 0 0 6.00 10.60 L6.00 7.90 A1.9 1.9 0 0 0 7.90 6.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.6" stroke-linejoin="round"/>
|
||||||
|
<rect x="11" y="11" width="42" height="42" fill="#7A3B2E"/>
|
||||||
|
<text x="32" y="31" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="14" font-weight="200" letter-spacing="-.4" fill="#F6EDE2">Self</text>
|
||||||
|
<text x="32" y="45" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="14" font-weight="600" letter-spacing="-.4" fill="#F6EDE2">Post</text>
|
||||||
|
</svg><span>иконка · до 100 px</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="note"><b>Числа не на глаз.</b> Полная версия живёт до 280 px по ширине — ниже подзаголовок опускается под 6 px и перестаёт читаться. Компактная — до 100 px. Охранное поле (пунктир) равно <b>трём шагам зубцовки</b>, ≈ 21 % высоты марки: мера взята из самого знака, а не назначена.</div>
|
||||||
|
<style>.s1{width:330px;max-width:100%;height:auto} .sizes .u:nth-child(2) svg{width:220px;max-width:100%;height:auto} .sizes .u:nth-child(3) svg{width:64px;height:64px}</style>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Чего не делать</h2>
|
||||||
|
<div class="rules"><div class="rule"><b>Не растягивать</b><span>Пропорции 1:2,3 — часть знака: зубцовка рассчитана на них и при неравном масштабе зубцы становятся овалами.</span></div><div class="rule"><b>Не менять краску</b><span>Кирпич выбран по контрасту (7,3:1) и по тому, что он не спорит со статусами панели. Другой цвет ломает оба расчёта.</span></div><div class="rule"><b>Не класть на пёстрое</b><span>Волосяной контур рассчитан на ровный фон. На фото и градиенте край пропадает.</span></div><div class="rule"><b>Не добавлять рамок и теней</b><span>Марка держит край зубцовкой и контуром. Всё остальное — уже наклейка, а не марка.</span></div><div class="rule"><b>Не набирать подзаголовок мельче 6 px</b><span>Ниже полная версия перестаёт читаться — там уже компактная.</span></div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style="border-bottom:none">
|
||||||
|
<h2>Файлы</h2>
|
||||||
|
<div class="files">
|
||||||
|
<b>docs/assets/selfpost-stamp.svg</b> — эталон, README, страницы входа панели<br>
|
||||||
|
<b>docs/assets/selfpost-stamp-compact.svg</b> — шапка панели<br>
|
||||||
|
<b>docs/assets/selfpost-icon.svg</b> — аватар, фавикон от 32 px<br>
|
||||||
|
<b>docs/assets/selfpost-icon-16.svg</b> — фавикон 16–32 px
|
||||||
|
</div>
|
||||||
|
<div class="note"><b>Контуры сделаны.</b> Текст в четырёх файлах выше переведён в контуры по IBM Plex Sans (OFL) — шрифт на машине читателя больше не нужен, и <code>Self</code> с <code>Post</code> держат свои 200 и 600 везде. Панель отдаёт свои копии из <code>internal/web/static/</code>: <code>logo.svg</code>, <code>logo-compact.svg</code>, <code>favicon.svg</code> плюс растровый <code>favicon.png</code> 32×32 для браузеров без SVG-фавиконов — <code>go:embed</code> не дотягивается за пределы пакета, поэтому это копии, а не ссылки.<br>Макеты на этой странице остались с живым текстом: это протокол разбора знака, а не источник файлов.</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div></body></html>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 100" width="220" height="100" role="img" aria-label="SelfPost">
|
||||||
|
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
|
||||||
|
Wordmark set in IBM Plex Sans (OFL) and converted to outlines: the
|
||||||
|
file needs no font installed to render as drawn. -->
|
||||||
|
<path d="M10.20 8.00 L12.83 8.00 A2.2 2.2 0 0 0 17.23 8.00 L19.87 8.00 A2.2 2.2 0 0 0 24.27 8.00 L26.90 8.00 A2.2 2.2 0 0 0 31.30 8.00 L33.94 8.00 A2.2 2.2 0 0 0 38.34 8.00 L40.97 8.00 A2.2 2.2 0 0 0 45.37 8.00 L48.01 8.00 A2.2 2.2 0 0 0 52.41 8.00 L55.04 8.00 A2.2 2.2 0 0 0 59.44 8.00 L62.08 8.00 A2.2 2.2 0 0 0 66.48 8.00 L69.11 8.00 A2.2 2.2 0 0 0 73.51 8.00 L76.14 8.00 A2.2 2.2 0 0 0 80.54 8.00 L83.18 8.00 A2.2 2.2 0 0 0 87.58 8.00 L90.21 8.00 A2.2 2.2 0 0 0 94.61 8.00 L97.25 8.00 A2.2 2.2 0 0 0 101.65 8.00 L104.28 8.00 A2.2 2.2 0 0 0 108.68 8.00 L111.32 8.00 A2.2 2.2 0 0 0 115.72 8.00 L118.35 8.00 A2.2 2.2 0 0 0 122.75 8.00 L125.39 8.00 A2.2 2.2 0 0 0 129.79 8.00 L132.42 8.00 A2.2 2.2 0 0 0 136.82 8.00 L139.46 8.00 A2.2 2.2 0 0 0 143.86 8.00 L146.49 8.00 A2.2 2.2 0 0 0 150.89 8.00 L153.52 8.00 A2.2 2.2 0 0 0 157.92 8.00 L160.56 8.00 A2.2 2.2 0 0 0 164.96 8.00 L167.59 8.00 A2.2 2.2 0 0 0 171.99 8.00 L174.63 8.00 A2.2 2.2 0 0 0 179.03 8.00 L181.66 8.00 A2.2 2.2 0 0 0 186.06 8.00 L188.70 8.00 A2.2 2.2 0 0 0 193.10 8.00 L195.73 8.00 A2.2 2.2 0 0 0 200.13 8.00 L202.77 8.00 A2.2 2.2 0 0 0 207.17 8.00 L209.80 8.00 A2.2 2.2 0 0 0 212.00 10.20 L212.00 12.80 A2.2 2.2 0 0 0 212.00 17.20 L212.00 19.80 A2.2 2.2 0 0 0 212.00 24.20 L212.00 26.80 A2.2 2.2 0 0 0 212.00 31.20 L212.00 33.80 A2.2 2.2 0 0 0 212.00 38.20 L212.00 40.80 A2.2 2.2 0 0 0 212.00 45.20 L212.00 47.80 A2.2 2.2 0 0 0 212.00 52.20 L212.00 54.80 A2.2 2.2 0 0 0 212.00 59.20 L212.00 61.80 A2.2 2.2 0 0 0 212.00 66.20 L212.00 68.80 A2.2 2.2 0 0 0 212.00 73.20 L212.00 75.80 A2.2 2.2 0 0 0 212.00 80.20 L212.00 82.80 A2.2 2.2 0 0 0 212.00 87.20 L212.00 89.80 A2.2 2.2 0 0 0 209.80 92.00 L207.17 92.00 A2.2 2.2 0 0 0 202.77 92.00 L200.13 92.00 A2.2 2.2 0 0 0 195.73 92.00 L193.10 92.00 A2.2 2.2 0 0 0 188.70 92.00 L186.06 92.00 A2.2 2.2 0 0 0 181.66 92.00 L179.03 92.00 A2.2 2.2 0 0 0 174.63 92.00 L171.99 92.00 A2.2 2.2 0 0 0 167.59 92.00 L164.96 92.00 A2.2 2.2 0 0 0 160.56 92.00 L157.92 92.00 A2.2 2.2 0 0 0 153.52 92.00 L150.89 92.00 A2.2 2.2 0 0 0 146.49 92.00 L143.86 92.00 A2.2 2.2 0 0 0 139.46 92.00 L136.82 92.00 A2.2 2.2 0 0 0 132.42 92.00 L129.79 92.00 A2.2 2.2 0 0 0 125.39 92.00 L122.75 92.00 A2.2 2.2 0 0 0 118.35 92.00 L115.72 92.00 A2.2 2.2 0 0 0 111.32 92.00 L108.68 92.00 A2.2 2.2 0 0 0 104.28 92.00 L101.65 92.00 A2.2 2.2 0 0 0 97.25 92.00 L94.61 92.00 A2.2 2.2 0 0 0 90.21 92.00 L87.58 92.00 A2.2 2.2 0 0 0 83.18 92.00 L80.54 92.00 A2.2 2.2 0 0 0 76.14 92.00 L73.51 92.00 A2.2 2.2 0 0 0 69.11 92.00 L66.48 92.00 A2.2 2.2 0 0 0 62.08 92.00 L59.44 92.00 A2.2 2.2 0 0 0 55.04 92.00 L52.41 92.00 A2.2 2.2 0 0 0 48.01 92.00 L45.37 92.00 A2.2 2.2 0 0 0 40.97 92.00 L38.34 92.00 A2.2 2.2 0 0 0 33.94 92.00 L31.30 92.00 A2.2 2.2 0 0 0 26.90 92.00 L24.27 92.00 A2.2 2.2 0 0 0 19.87 92.00 L17.23 92.00 A2.2 2.2 0 0 0 12.83 92.00 L10.20 92.00 A2.2 2.2 0 0 0 8.00 89.80 L8.00 87.20 A2.2 2.2 0 0 0 8.00 82.80 L8.00 80.20 A2.2 2.2 0 0 0 8.00 75.80 L8.00 73.20 A2.2 2.2 0 0 0 8.00 68.80 L8.00 66.20 A2.2 2.2 0 0 0 8.00 61.80 L8.00 59.20 A2.2 2.2 0 0 0 8.00 54.80 L8.00 52.20 A2.2 2.2 0 0 0 8.00 47.80 L8.00 45.20 A2.2 2.2 0 0 0 8.00 40.80 L8.00 38.20 A2.2 2.2 0 0 0 8.00 33.80 L8.00 31.20 A2.2 2.2 0 0 0 8.00 26.80 L8.00 24.20 A2.2 2.2 0 0 0 8.00 19.80 L8.00 17.20 A2.2 2.2 0 0 0 8.00 12.80 L8.00 10.20 A2.2 2.2 0 0 0 10.20 8.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.7" stroke-linejoin="round" />
|
||||||
|
<rect x="15" y="15" width="190" height="70" fill="#7A3B2E" />
|
||||||
|
<path d="M61.06 66.41Q58.14 66.41 56.23 65.3Q54.33 64.2 53 62.46L53.96 61.72Q55.21 63.35 56.86 64.32Q58.51 65.29 61.1 65.29Q64.05 65.29 65.67 63.89Q67.28 62.5 67.28 59.88Q67.28 58.08 66.6 57.04Q65.92 56 64.73 55.46Q63.54 54.92 62.08 54.61L59.8 54.17Q57.46 53.73 56.12 52.83Q54.77 51.92 54.18 50.68Q53.58 49.44 53.58 47.98Q53.58 45.91 54.5 44.55Q55.42 43.19 57.08 42.52Q58.75 41.86 60.96 41.86Q63.61 41.86 65.38 42.78Q67.15 43.7 68.17 45.23L67.22 45.94Q66.2 44.48 64.67 43.73Q63.14 42.98 60.89 42.98Q58.07 42.98 56.46 44.21Q54.84 45.43 54.84 47.95Q54.84 49.61 55.54 50.61Q56.23 51.62 57.42 52.16Q58.61 52.71 60.08 53.01L62.35 53.45Q64.77 53.9 66.11 54.83Q67.45 55.77 67.98 57.04Q68.51 58.32 68.51 59.78Q68.51 61.95 67.61 63.42Q66.71 64.88 65.02 65.64Q63.34 66.41 61.06 66.41Z M78.56 66.41Q76.28 66.41 74.64 65.35Q72.99 64.3 72.09 62.29Q71.18 60.29 71.18 57.36Q71.18 54.44 72.07 52.42Q72.95 50.39 74.57 49.36Q76.18 48.32 78.46 48.32Q80.57 48.32 82.17 49.37Q83.76 50.43 84.67 52.35Q85.57 54.27 85.57 56.89V57.43H72.44V58.35Q72.44 61.51 74.06 63.43Q75.67 65.35 78.6 65.35Q80.64 65.35 82.08 64.35Q83.53 63.35 84.27 61.41L85.19 61.92Q84.44 63.96 82.74 65.18Q81.04 66.41 78.56 66.41ZM78.46 49.34Q76.56 49.34 75.21 50.21Q73.87 51.07 73.16 52.66Q72.44 54.24 72.44 56.34V56.48H84.27V56.28Q84.27 54.17 83.54 52.62Q82.81 51.07 81.5 50.21Q80.19 49.34 78.46 49.34Z M93.72 66H91.17Q90.42 66 90.06 65.63Q89.7 65.25 89.7 64.57V40.84H90.89V64.95H93.72Z M97.92 66V49.78H94.83V48.73H97.92V42.57Q97.92 41.76 98.33 41.3Q98.74 40.84 99.59 40.84H102.99V41.89H99.11V48.73H102.99V49.78H99.11V66Z M109.88 66H105.39V42.27H116.07Q118.28 42.27 119.84 43.19Q121.41 44.1 122.26 45.75Q123.11 47.4 123.11 49.61Q123.11 51.82 122.26 53.47Q121.41 55.12 119.84 56.04Q118.28 56.96 116.07 56.96H109.88ZM109.88 46.18V53.08H115.66Q116.55 53.08 117.16 52.76Q117.77 52.43 118.11 51.84Q118.45 51.24 118.45 50.39V48.83Q118.45 47.95 118.11 47.37Q117.77 46.79 117.16 46.48Q116.55 46.18 115.66 46.18Z M132.75 66.41Q130.24 66.41 128.38 65.27Q126.53 64.13 125.53 62.04Q124.53 59.95 124.53 57.09Q124.53 54.24 125.53 52.18Q126.53 50.12 128.38 48.98Q130.24 47.84 132.75 47.84Q135.3 47.84 137.16 48.98Q139.01 50.12 140.01 52.18Q141.02 54.24 141.02 57.09Q141.02 59.95 140.01 62.04Q139.01 64.13 137.16 65.27Q135.3 66.41 132.75 66.41ZM132.75 62.91Q134.49 62.91 135.47 61.83Q136.46 60.76 136.46 58.76V55.46Q136.46 53.45 135.47 52.4Q134.49 51.35 132.75 51.35Q131.09 51.35 130.08 52.4Q129.08 53.45 129.08 55.46V58.76Q129.08 60.76 130.08 61.83Q131.09 62.91 132.75 62.91Z M149.47 66.41Q146.92 66.41 145.19 65.54Q143.45 64.67 142.13 63.14L144.78 60.56Q145.77 61.68 146.94 62.33Q148.11 62.97 149.64 62.97Q151.21 62.97 151.9 62.43Q152.6 61.89 152.6 60.93Q152.6 60.15 152.11 59.69Q151.61 59.23 150.42 59.06L148.66 58.83Q146.75 58.59 145.43 57.94Q144.1 57.3 143.42 56.17Q142.74 55.05 142.74 53.45Q142.74 50.87 144.59 49.36Q146.45 47.84 149.47 47.84Q151.21 47.84 152.45 48.17Q153.69 48.49 154.64 49.12Q155.59 49.75 156.41 50.63L153.86 53.18Q153.08 52.37 151.99 51.82Q150.9 51.28 149.61 51.28Q148.25 51.28 147.58 51.77Q146.92 52.26 146.92 53.11Q146.92 54 147.45 54.46Q147.98 54.92 149.23 55.12L151.04 55.36Q153.96 55.77 155.37 57.08Q156.78 58.38 156.78 60.59Q156.78 62.29 155.88 63.62Q154.98 64.95 153.33 65.68Q151.68 66.41 149.47 66.41Z M168.09 66H164.97Q162.72 66 161.55 64.83Q160.38 63.65 160.38 61.48V51.69H157.76V48.25H159.12Q160.1 48.25 160.46 47.79Q160.82 47.33 160.82 46.42V43.39H164.73V48.25H168.37V51.69H164.73V62.53H168.09Z" fill="#F6EDE2" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.1 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,426 @@
|
|||||||
|
# SelfPost — development
|
||||||
|
|
||||||
|
**What this file is.** How to build, test, document, and ship changes. Open
|
||||||
|
work after 1.0 (1.x+) lives in [roadmap.md](roadmap.md) and linked
|
||||||
|
[plans/](plans/). Product boundaries: [product.md](product.md). As-built layout:
|
||||||
|
[architecture.md](architecture.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resuming work
|
||||||
|
|
||||||
|
After `/clear` or a fresh chat:
|
||||||
|
|
||||||
|
1. Read this file (process, docs rules, model routing).
|
||||||
|
2. Open [roadmap.md](roadmap.md) for the index of open work; follow the linked
|
||||||
|
plan file for the active item. Accepted risks — [security.md](security.md);
|
||||||
|
as-built — [architecture.md](architecture.md).
|
||||||
|
3. Skim [product.md](product.md) if scope is in doubt.
|
||||||
|
4. Continue from the next unchecked step in the **active** plan file (not the
|
||||||
|
roadmap index).
|
||||||
|
|
||||||
|
History of closed phases is in `git log` and [CHANGELOG.md](../CHANGELOG.md),
|
||||||
|
not duplicated here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authorship and disclosure
|
||||||
|
|
||||||
|
**Decided, not open for re-litigation.** SelfPost is written by AI agents under
|
||||||
|
a maintainer's direction, and the project says so rather than hiding it.
|
||||||
|
|
||||||
|
Concretely, this is what "says so" means, and none of it is an oversight to be
|
||||||
|
tidied away later:
|
||||||
|
|
||||||
|
- `Co-Authored-By: Claude <model>` trailers stay in commit messages, including
|
||||||
|
the ~140 commits that predate v1.0.
|
||||||
|
- The model routing table below is public, in a file the README links to.
|
||||||
|
- [.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc) ships in
|
||||||
|
the repository.
|
||||||
|
- Process notes written for an agent — "after a context reset, pick an item
|
||||||
|
marked `agreed`" in [roadmap.md](roadmap.md) — stay as they are.
|
||||||
|
|
||||||
|
**Why not quietly drop it.** Once the trailers are in the history, removing the
|
||||||
|
routing table or the rules file would not conceal authorship, it would only
|
||||||
|
make the project look like it was trying to. Partial concealment reads worse
|
||||||
|
than the plain statement, and the plain statement costs nothing: the code is
|
||||||
|
reviewed, tested, and shipped under the same rules either way, and the
|
||||||
|
[security design](security.md) records what was audited and what was accepted.
|
||||||
|
|
||||||
|
**Revisit if:** the disclosure ever conflicts with the licence or a downstream
|
||||||
|
obligation — not because the convention around AI authorship shifts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Model routing
|
||||||
|
|
||||||
|
| Kind of work | Model | Examples |
|
||||||
|
|---|---|---|
|
||||||
|
| Security, infra, file permissions, Postfix/`postqueue`, open-relay risk | **Opus** | `mail.log` under `/data`, entrypoint permissions, queue reconcile |
|
||||||
|
| UI / JS / CSS, templates, documentation (English), README | **Sonnet** | adaptive polling, this file's Documentation section |
|
||||||
|
| Trivial mechanics: retarget links, grep, compose bump, CHANGELOG cut | **Haiku** | Makefile / release.yml comment fixes, deleting closed plan files |
|
||||||
|
| Security **review** (not authorship) | **Fable** | pre-release checklist (CHANGELOG `[0.5.0]` Security / [security.md](security.md) — done) |
|
||||||
|
|
||||||
|
Default rule: risk-critical → Opus; UI / docs / boilerplate → Sonnet; trivial
|
||||||
|
mechanics → Haiku. Reviewers must not be the author of the code under review.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technology stack and tools
|
||||||
|
|
||||||
|
| Component | Version / notes |
|
||||||
|
|---|---|
|
||||||
|
| **Go** | 1.26+ (`go.mod`); `CGO_ENABLED=0` — pure Go, static linking |
|
||||||
|
| **SQLite** | `modernc.org/sqlite` (pure Go, no cgo) |
|
||||||
|
| **Build** | [Makefile](../Makefile): primary targets `vet`, `test`, `build`, `e2e` (also `all`, `clean`) |
|
||||||
|
| **Container** | Docker + Compose v2 on the dev host and in CI |
|
||||||
|
| **Image (build stage)** | `golang:1.26-bookworm` — [build/Dockerfile](../build/Dockerfile) |
|
||||||
|
| **Image (runtime)** | `debian:bookworm-slim` + Postfix, OpenDKIM, supervisord, SASL, logrotate |
|
||||||
|
| **CI** | GitHub Actions — [.github/workflows/](../.github/workflows/) |
|
||||||
|
| **Image registry** | `ghcr.io/mixeme/selfpost` |
|
||||||
|
|
||||||
|
**Repository layout** (brief; process details in [architecture.md](architecture.md)):
|
||||||
|
|
||||||
|
- `cmd/panel` — HTTP panel + journal-milter + log-tailer
|
||||||
|
- `cmd/selfpost-backup` — backup CLI (`docker exec … selfpost-backup`)
|
||||||
|
- `internal/` — domain logic, store, web, health
|
||||||
|
- `build/` — Dockerfile, supervisord, Postfix/OpenDKIM, entrypoint
|
||||||
|
- `deploy/` — `docker-compose.yml`, proxy examples, `.env.example`
|
||||||
|
- `test/e2e/` — **separate Go module**; container integration tests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## External libraries
|
||||||
|
|
||||||
|
The project is **AGPL-3.0** ([LICENSE](../LICENSE)). Copyright holder and
|
||||||
|
third-party notices: [NOTICE](../NOTICE). The tree does not use per-file
|
||||||
|
`SPDX-License-Identifier` headers; AGPL-3.0 does not require them. New Go
|
||||||
|
dependencies must be permissive or GPL-family (see
|
||||||
|
[.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)).
|
||||||
|
|
||||||
|
### Main module (`go.mod`)
|
||||||
|
|
||||||
|
| Package | Version | Repository | License |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `github.com/emersion/go-milter` | v0.4.1 | <https://github.com/emersion/go-milter> | BSD-2-Clause |
|
||||||
|
| `golang.org/x/crypto` | v0.54.0 | <https://github.com/golang/crypto> | BSD-3-Clause |
|
||||||
|
| `modernc.org/sqlite` | v1.53.0 | <https://gitlab.com/cznic/sqlite> (mirror: <https://github.com/modernc-org/sqlite>) | BSD-3-Clause |
|
||||||
|
|
||||||
|
Transitive dependencies — `go mod graph` / `go.sum`; all indirect packages in
|
||||||
|
the tree are AGPL-3.0-compatible.
|
||||||
|
|
||||||
|
### Vendored front-end
|
||||||
|
|
||||||
|
| Asset | Version | Repository | License |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `internal/web/view/static/htmx.min.js` | 2.0.4 | <https://github.com/bigskysoftware/htmx> | 0BSD |
|
||||||
|
| `internal/web/view/static/ibm-plex-*.woff2` | latin subset | <https://github.com/IBM/plex> | SIL OFL 1.1 (`OFL.txt` beside the files) |
|
||||||
|
|
||||||
|
### E2e module (`test/e2e/go.mod`)
|
||||||
|
|
||||||
|
| Package | Version | Repository | License |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `github.com/emersion/go-msgauth` | v0.6.8 | <https://github.com/emersion/go-msgauth> | BSD-2-Clause |
|
||||||
|
|
||||||
|
The test module is not part of the main `go build` graph and is not shipped in
|
||||||
|
the image.
|
||||||
|
|
||||||
|
### Debian packages in the runtime image
|
||||||
|
|
||||||
|
Postfix, OpenDKIM, `supervisord`, `sasl2-bin`, `logrotate`, and others come
|
||||||
|
from Debian bookworm repositories; licenses are in each package's `copyright`
|
||||||
|
file on <https://packages.debian.org/bookworm/>.
|
||||||
|
The image also ships [LICENSE](../LICENSE), [NOTICE](../NOTICE), and the IBM
|
||||||
|
Plex [OFL.txt](../internal/web/view/static/OFL.txt) under
|
||||||
|
`/usr/share/doc/selfpost/`. The panel serves the AGPL text at `/license` and
|
||||||
|
the OFL text at `/static/OFL.txt`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Building binaries and the image
|
||||||
|
|
||||||
|
### Local binaries
|
||||||
|
|
||||||
|
Requires Go 1.26+ and `CGO_ENABLED=0`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make build # bin/panel, bin/selfpost-backup (VERSION=dev by default)
|
||||||
|
make build VERSION=1.2.3
|
||||||
|
```
|
||||||
|
|
||||||
|
Or directly:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go build -trimpath -ldflags "-X github.com/mixeme/selfpost/internal/buildinfo.Version=dev" -o bin/panel ./cmd/panel
|
||||||
|
```
|
||||||
|
|
||||||
|
The version is stamped into both binaries via `-ldflags` and **must match the
|
||||||
|
Docker image tag** — restore checks backup version compatibility.
|
||||||
|
|
||||||
|
### Docker image
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .
|
||||||
|
```
|
||||||
|
|
||||||
|
The Dockerfile has a build stage (`go vet`, `go build` with `VERSION`) and a
|
||||||
|
runtime stage (Debian + mail stack). Runtime config and scripts use `COPY
|
||||||
|
--chmod` so file modes in the image do not depend on how the build context was
|
||||||
|
synced (e.g. a Windows checkout widening permissions on `logrotate-mail.conf`).
|
||||||
|
See [architecture.md](architecture.md) § Image and processes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commits and release build
|
||||||
|
|
||||||
|
Commit on every meaningful step (a working sub-feature, a green build, end of a
|
||||||
|
phase) — not every file save, and not only at phase end. Minimum: one commit per
|
||||||
|
closed phase, plus intermediate commits for coherent sub-steps. Branch `main`
|
||||||
|
unless a separate branch is requested. Push / PR only on explicit request.
|
||||||
|
|
||||||
|
Commit messages end with
|
||||||
|
`Co-Authored-By: Claude <model> <noreply@anthropic.com>` for the model that did
|
||||||
|
the step (e.g. `Claude Sonnet 4.6`).
|
||||||
|
|
||||||
|
Every such step also updates [CHANGELOG.md](../CHANGELOG.md) under
|
||||||
|
`[Unreleased]` (Keep a Changelog). On an explicit version cut, rename
|
||||||
|
`[Unreleased]` to `[X.Y.Z] - date` and open a fresh empty `[Unreleased]`. Image
|
||||||
|
tag / push only on explicit request (see `release.yml`).
|
||||||
|
|
||||||
|
### Release image
|
||||||
|
|
||||||
|
The release image is published **only** for a SemVer version `X.Y.Z`: a
|
||||||
|
**published** GitHub Release whose tag is `vX.Y.Z`, or a `workflow_dispatch`
|
||||||
|
that supplies that version. Pushing a git tag alone does not publish. Ordinary
|
||||||
|
commits, and a dispatch from `main` without a version input, do not publish.
|
||||||
|
The version is the single source that drives the image tag and `-ldflags` in
|
||||||
|
the binaries so they cannot drift apart.
|
||||||
|
|
||||||
|
**Steps (on explicit request):**
|
||||||
|
|
||||||
|
1. Close `[Unreleased]` in [CHANGELOG.md](../CHANGELOG.md) and bump the pinned
|
||||||
|
tag in [deploy/docker-compose.yml](../deploy/docker-compose.yml) (and any
|
||||||
|
local-trial image references) in the **same** release commit.
|
||||||
|
2. Create and push git tag `vX.Y.Z` on that commit.
|
||||||
|
3. Publish the GitHub Release for `vX.Y.Z` (not a draft).
|
||||||
|
4. Workflow [release.yml](../.github/workflows/release.yml) builds, e2e-gates,
|
||||||
|
and publishes `ghcr.io/mixeme/selfpost:X.Y.Z` (checks out tag `vX.Y.Z`).
|
||||||
|
|
||||||
|
**GitHub Release vs GHCR.** The public [Releases](https://github.com/mixeme/selfpost/releases)
|
||||||
|
page lists only **published** releases. A draft is visible to maintainers only —
|
||||||
|
it looks like “no releases” to everyone else. CI does not create or publish the
|
||||||
|
GitHub Release; you do that in the UI. Deleting a release’s git tag on GitHub
|
||||||
|
(or re-pushing tags while cleaning the registry) converts a published release
|
||||||
|
back into a **draft** — that matches “I published three times and it keeps
|
||||||
|
disappearing”. After publish, leave the tag on GitHub; clean up only unwanted
|
||||||
|
GHCR package versions, not the git tag.
|
||||||
|
|
||||||
|
Push workflow and source changes to **github.com/mixeme/selfpost** before
|
||||||
|
publishing — Actions reads that repo, not Gitea.
|
||||||
|
|
||||||
|
**Gitea → GitHub tag mirror.** If every tag push from Gitea is mirrored to
|
||||||
|
GitHub, two things follow:
|
||||||
|
|
||||||
|
1. **GitHub Release tags must not be deleted on GitHub.** Many mirror setups
|
||||||
|
prune remote tags that are absent on Gitea (or re-push with `--force` /
|
||||||
|
`--prune`). Deleting `v1.0.0` / `v1.3.0` on GitHub converts a published
|
||||||
|
Release back to draft. Mirror **branches and new tags forward**; do not
|
||||||
|
delete release tags on the GitHub side. GHCR cleanup is package versions in
|
||||||
|
the UI — not `git push github --delete` and not tag prune on the mirror.
|
||||||
|
|
||||||
|
2. **Tag push runs the workflow file at that tag's commit**, not `main`. `v1.0.0`
|
||||||
|
still points at a commit whose `release.yml` has `on: push: tags` and no
|
||||||
|
per-arch GHCR cleanup — every mirror (re)push of that tag can republish
|
||||||
|
`1.0.0-amd64` / `1.0.0-arm64`. Tags from `v1.3.0` onward only run
|
||||||
|
`release.yml` on **Publish release** (`release: published`), so mirroring
|
||||||
|
those tags alone does not start the image build.
|
||||||
|
|
||||||
|
Safe mirror: push tags to GitHub without deleting existing ones; keep release
|
||||||
|
tags on Gitea; publish the GitHub Release on github.com after the mirror has
|
||||||
|
the tag.
|
||||||
|
|
||||||
|
Ordinary commits **do not** publish an image. The compose pin and the git tag
|
||||||
|
must match (`1.0.0` / `v1.0.0` for the first published release). Intermediate
|
||||||
|
CHANGELOG sections (`0.2.0`…`0.6.0`) record development history before that cut.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase closure
|
||||||
|
|
||||||
|
Before `/clear` at the end of a finished step:
|
||||||
|
|
||||||
|
1. Update [roadmap.md](roadmap.md) (and the active plan checklist, if any): what
|
||||||
|
changed, what is next.
|
||||||
|
2. Check the applicable «Done when…» criteria.
|
||||||
|
3. Append [CHANGELOG.md](../CHANGELOG.md) under `[Unreleased]`.
|
||||||
|
4. Make the final commit for the step (when the user asks for a commit).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Static analysis and unit tests
|
||||||
|
|
||||||
|
Main module (`go test ./...`); e2e is a separate module — see below.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make vet # go vet ./...
|
||||||
|
make test # go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Or directly:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gofmt -l . # in CI — fails on drift
|
||||||
|
go vet ./...
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Env documentation regression
|
||||||
|
|
||||||
|
`go test ./cmd/panel -run 'TestLoadConfigKeysDocumented|TestBuildScriptKeysDocumented|TestDocumentedKeysAreRead'`
|
||||||
|
— every new `loadConfig` key must appear in the env lists in [guide.md](guide.md)
|
||||||
|
([cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go)).
|
||||||
|
|
||||||
|
### End-to-end (container suite)
|
||||||
|
|
||||||
|
Separate Go module `test/e2e/`; **not** included in the main module's
|
||||||
|
`go test ./...`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make e2e
|
||||||
|
# same as: cd test/e2e && go test -v -timeout 20m ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stack:** [deploy/docker-compose.yml](../deploy/docker-compose.yml) +
|
||||||
|
[test/e2e/compose.override.yml](../test/e2e/compose.override.yml) — same
|
||||||
|
`cap_drop`/`cap_add`/`no-new-privileges` as production. Override: high ports
|
||||||
|
(`20465`/`20587`/`20080`), test hostname, self-signed TLS,
|
||||||
|
`PANEL_COOKIE_SECURE=false`, isolated compose project. Mail is hermetic:
|
||||||
|
CoreDNS (fake zone) + Postfix `smtp-sink` as sink-MX; DKIM TXT is scraped from
|
||||||
|
the panel and published into the zone — the test verifies the records an
|
||||||
|
operator would actually use.
|
||||||
|
|
||||||
|
**Coverage (summary):** bootstrap → SMTP AUTH → delivery → DKIM verify →
|
||||||
|
send-log `queued → sent`; negatives (no AUTH, relay, sender/login mismatch,
|
||||||
|
L1/L2 limits, milter fail-open, bad `SELFPOST_HOSTNAME`, session survives
|
||||||
|
`docker restart`); startup checks that supervisord actually brought up
|
||||||
|
OpenDKIM, the panel, and Postfix (`checkSupervisorProcesses`), plus logrotate
|
||||||
|
config-mode and forced-rotation checks (`checkLogrotateConfigMode`,
|
||||||
|
`checkLogrotateRotation` — [test/e2e/logrotate_check.go](../test/e2e/logrotate_check.go)).
|
||||||
|
Polling with timeouts only — no fixed `sleep`.
|
||||||
|
|
||||||
|
Requires **Docker + Compose v2** on the machine running the suite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
Workflows in [.github/workflows/](../.github/workflows/). What each job runs —
|
||||||
|
[§ Testing](#testing) above.
|
||||||
|
|
||||||
|
### `test.yml` — every push and PR to `main`
|
||||||
|
|
||||||
|
`gofmt -l` → `go vet ./...` → `go test ./...` (main module, no e2e).
|
||||||
|
|
||||||
|
### `release.yml` — published GitHub Release, or `workflow_dispatch` with SemVer
|
||||||
|
|
||||||
|
Publishing a GitHub Release runs `release.yml` directly (`release: published`,
|
||||||
|
same pattern as gosentry / imap-scrub). You can also run it manually via
|
||||||
|
`workflow_dispatch` with an explicit `X.Y.Z` input. A bare git tag push does not
|
||||||
|
run the workflow. The build always checks out `vX.Y.Z`, not `main` HEAD.
|
||||||
|
|
||||||
|
`prepare` takes the version from `github.event.release.tag_name` on a release
|
||||||
|
event, or from the `workflow_dispatch` `version` input. A dispatch whose input
|
||||||
|
is missing or not `X.Y.Z` fails in `prepare`.
|
||||||
|
|
||||||
|
```
|
||||||
|
release: published
|
||||||
|
prepare (version from release tag or workflow_dispatch input; checkout vX.Y.Z)
|
||||||
|
→ build [matrix: ubuntu-latest / ubuntu-24.04-arm]
|
||||||
|
→ docker build --load (VERSION from prepare)
|
||||||
|
→ e2e (test/e2e)
|
||||||
|
→ push ghcr.io/...:X.Y.Z-amd64 | X.Y.Z-arm64
|
||||||
|
→ merge
|
||||||
|
→ docker buildx imagetools create → unified manifest X.Y.Z
|
||||||
|
→ GitHub Packages API → drop X.Y.Z-amd64 and X.Y.Z-arm64 from GHCR
|
||||||
|
```
|
||||||
|
|
||||||
|
Native per-arch matrix (no QEMU): running the full Postfix/OpenDKIM stack under
|
||||||
|
emulation for e2e is impractical. E2e first, then push — the registry receives
|
||||||
|
the bytes that passed the gate. Only `ghcr.io/mixeme/selfpost:X.Y.Z` remains
|
||||||
|
tagged in GHCR; per-arch names exist briefly during the merge job.
|
||||||
|
|
||||||
|
A failed e2e **blocks** image publication.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
History of deleted plans lives in git and [CHANGELOG.md](../CHANGELOG.md).
|
||||||
|
There is no `docs/archive/` directory.
|
||||||
|
|
||||||
|
### Documentation map
|
||||||
|
|
||||||
|
| Home | File |
|
||||||
|
|---|---|
|
||||||
|
| Operator install / quick start | [README.md](../README.md) |
|
||||||
|
| Operator guide | [guide.md](guide.md) |
|
||||||
|
| Product boundaries | [product.md](product.md) |
|
||||||
|
| As-built design | [architecture.md](architecture.md) |
|
||||||
|
| Development process (this file) | [development.md](development.md) |
|
||||||
|
| Security requirements and accepted risks | [security.md](security.md) |
|
||||||
|
| Roadmap (1.x+) | [roadmap.md](roadmap.md) |
|
||||||
|
| Active design plans | [plans/](plans/) |
|
||||||
|
| Release history | [CHANGELOG.md](../CHANGELOG.md) |
|
||||||
|
|
||||||
|
### User-facing deliverables
|
||||||
|
|
||||||
|
| Artefact | Role |
|
||||||
|
|---|---|
|
||||||
|
| [README.md](../README.md) | Overview, requirements, quick start, docs index, reference deploy, licence |
|
||||||
|
| [guide.md](guide.md) | Proxy, env, DNS, IP warmup, operations, rate limiting, backup, ports, image tag |
|
||||||
|
| [SECURITY.md](../SECURITY.md) | Private reporting channel, supported versions, scope |
|
||||||
|
| [LICENSE](../LICENSE) | AGPL-3.0 full text |
|
||||||
|
| [NOTICE](../NOTICE) | Copyright holder and third-party attributions |
|
||||||
|
| [deploy/docker-compose.yml](../deploy/docker-compose.yml) + proxies | Apache + nginx/Caddy/Traefik under [deploy/](../deploy/) |
|
||||||
|
| [deploy/.env.example](../deploy/.env.example) | Public env template; full reference in [guide.md](guide.md) |
|
||||||
|
| [CHANGELOG.md](../CHANGELOG.md) | Keep a Changelog |
|
||||||
|
|
||||||
|
Out of scope for v1.x: `CONTRIBUTING.md`, man pages, a separate docs site
|
||||||
|
(candidates in [roadmap.md](roadmap.md)).
|
||||||
|
|
||||||
|
### Maintaining documentation
|
||||||
|
|
||||||
|
1. **Step rule:** a new or renamed env key, panel route, or observable mail-path
|
||||||
|
behaviour ships together with [guide.md](guide.md) / `.env.example` and a
|
||||||
|
CHANGELOG entry (see [§ Commits and release build](#commits-and-release-build)).
|
||||||
|
2. **Env regression:** [cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go)
|
||||||
|
fails on an undocumented `loadConfig` or build-script key.
|
||||||
|
3. **New gaps** go into [roadmap.md](roadmap.md) (or the active plan file), not
|
||||||
|
silent drive-by edits.
|
||||||
|
|
||||||
|
### Verifying docs against code
|
||||||
|
|
||||||
|
Every claim in the docs has a **source of truth in the tree**; verify from code
|
||||||
|
to prose.
|
||||||
|
|
||||||
|
| Claim class | Source of truth |
|
||||||
|
|---|---|
|
||||||
|
| Env keys and defaults | `loadConfig` — [cmd/panel/main.go](../cmd/panel/main.go); `${VAR:-…}` in [build/](../build/) |
|
||||||
|
| Mail path | [build/postfix-config.sh](../build/postfix-config.sh) |
|
||||||
|
| Panel routes | [internal/web/web.go](../internal/web/web.go) |
|
||||||
|
| Backup / restore, domain export | [internal/backup/](../internal/backup/), [cmd/selfpost-backup/](../cmd/selfpost-backup/) |
|
||||||
|
| Sessions | [internal/store/sessions.go](../internal/store/sessions.go), [internal/web/auth/session.go](../internal/web/auth/session.go) |
|
||||||
|
| Log rotation, reload | [build/logrotate-mail.conf](../build/logrotate-mail.conf), [build/logrotate-loop.sh](../build/logrotate-loop.sh), [build/postfix-cert-reload.sh](../build/postfix-cert-reload.sh) |
|
||||||
|
| Deploy | [deploy/docker-compose.yml](../deploy/docker-compose.yml), [build/Dockerfile](../build/Dockerfile) |
|
||||||
|
| Operator checklist | [§ User-facing deliverables](#user-facing-deliverables); detail — [guide.md](guide.md) |
|
||||||
|
| Product / out of scope | [product.md](product.md) |
|
||||||
|
| As-built | [architecture.md](architecture.md) |
|
||||||
|
| Mandatory security | [security.md](security.md) |
|
||||||
|
|
||||||
|
Order: list what the code actually does → find it in [guide.md](guide.md) /
|
||||||
|
`architecture.md`. Before every tag, a short pass over this table — not a full
|
||||||
|
prose rewrite.
|
||||||
+694
@@ -0,0 +1,694 @@
|
|||||||
|
# SelfPost operator guide
|
||||||
|
|
||||||
|
Detailed install, configuration, and day-to-day operations. For a short
|
||||||
|
overview and quick start, see [README.md](../README.md).
|
||||||
|
|
||||||
|
This guide has three parts: **[Installation](#installation)** (getting a
|
||||||
|
container running with a working reverse proxy and TLS), **[Instance
|
||||||
|
administration](#instance-administration)** (running and maintaining the
|
||||||
|
SelfPost server itself — status, backups, users, upgrades), and **[Domain
|
||||||
|
administration](#domain-administration)** (day-to-day work on the sending
|
||||||
|
domains hosted on that instance — DNS, deliveries, rate limits, applications).
|
||||||
|
|
||||||
|
## Table of contents
|
||||||
|
|
||||||
|
- [Installation](#installation)
|
||||||
|
- [Ports](#ports)
|
||||||
|
- [Local trial](#local-trial)
|
||||||
|
- [Initial setup](#initial-setup)
|
||||||
|
- [Full deployment](#full-deployment)
|
||||||
|
- [Fixed image tag](#fixed-image-tag)
|
||||||
|
- [Environment variables](#environment-variables)
|
||||||
|
- [Reverse proxy (mandatory)](#reverse-proxy-mandatory)
|
||||||
|
- [Instance administration](#instance-administration)
|
||||||
|
- [Status](#status)
|
||||||
|
- [Mail queue and System log](#mail-queue-and-system-log)
|
||||||
|
- [Settings](#settings)
|
||||||
|
- [Users](#users)
|
||||||
|
- [Sessions](#sessions)
|
||||||
|
- [Upgrading](#upgrading)
|
||||||
|
- [Container health](#container-health)
|
||||||
|
- [Server-level DNS (PTR/rDNS)](#server-level-dns-ptrrdns)
|
||||||
|
- [Rate limiting — level 1 (IP backstop)](#rate-limiting--level-1-ip-backstop)
|
||||||
|
- [Full backup and restore](#full-backup-and-restore)
|
||||||
|
- [Encrypting a backup or export](#encrypting-a-backup-or-export)
|
||||||
|
- [Domain administration](#domain-administration)
|
||||||
|
- [Domains page](#domains-page)
|
||||||
|
- [Domain-level DNS (SPF, DKIM, DMARC)](#domain-level-dns-spf-dkim-dmarc)
|
||||||
|
- [IP warmup](#ip-warmup)
|
||||||
|
- [Rate limiting — level 2 (domain and application)](#rate-limiting--level-2-domain-and-application)
|
||||||
|
- [Deliveries](#deliveries)
|
||||||
|
- [Exporting and importing a single domain](#exporting-and-importing-a-single-domain)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Ports
|
||||||
|
|
||||||
|
`deploy/docker-compose.yml` maps **465** and **587** to the host. Port 465
|
||||||
|
(smtps) is always active. Port **587** is published even when
|
||||||
|
`SUBMISSION_ENABLE=false`; nothing listens until you set it to `true` — harmless,
|
||||||
|
but it can look like an open port in external scans.
|
||||||
|
|
||||||
|
### Local trial
|
||||||
|
|
||||||
|
The [README quick start](../README.md#quick-start) runs a single container
|
||||||
|
with `PANEL_COOKIE_SECURE=false` and port 8080 published on localhost. No
|
||||||
|
reverse proxy, no `./certs` bind mount — Postfix still starts, but the Status
|
||||||
|
page will report missing TLS material until you mount PEM files at
|
||||||
|
`/etc/postfix/tls/fullchain.pem` and `privkey.pem`.
|
||||||
|
|
||||||
|
The one-time setup link is always printed as
|
||||||
|
`https://<SELFPOST_HOSTNAME>/setup/<token>` (and written the same way to
|
||||||
|
`/data/setup-token`). For a local trial that means rewriting the host and
|
||||||
|
scheme to `http://127.0.0.1:8080/setup/<token>` — the path token is what
|
||||||
|
matters; the hostname in the printed URL is not reachable as written.
|
||||||
|
|
||||||
|
**What works:** the full panel — setup, domains, applications, deliveries view,
|
||||||
|
mail queue, system log. **What does not:** reliable outbound delivery to the
|
||||||
|
public internet (no PTR, no real DNS for your domains, port 25 may be blocked
|
||||||
|
on your network, HELO does not match anything receivers trust).
|
||||||
|
|
||||||
|
To exercise SMTP locally as well, generate a throwaway self-signed certificate
|
||||||
|
and mount it before `docker run`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -p /tmp/selfpost-certs
|
||||||
|
openssl req -x509 -newkey rsa:2048 \
|
||||||
|
-keyout /tmp/selfpost-certs/privkey.pem \
|
||||||
|
-out /tmp/selfpost-certs/fullchain.pem \
|
||||||
|
-days 1 -nodes -subj '/CN=mail.local.test'
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `-v /tmp/selfpost-certs:/etc/postfix/tls:ro` to the `docker run` command
|
||||||
|
(and keep `SELFPOST_HOSTNAME=mail.local.test` so it matches the certificate CN).
|
||||||
|
Clients must skip TLS verification — the cert is not from a public CA.
|
||||||
|
|
||||||
|
### Initial setup
|
||||||
|
|
||||||
|
On first start the one-time setup URL is printed in the container log
|
||||||
|
(`docker compose logs -f`) and written to `/data/setup-token` inside the
|
||||||
|
container — `./data/setup-token` on the host, mode `0600` — then deleted when
|
||||||
|
setup completes. The link is `https://<SELFPOST_HOSTNAME>/setup/<token>` (path
|
||||||
|
token, not a query string), valid for ten minutes. Open it to choose the
|
||||||
|
administrator username and password — until then the panel has no login. If
|
||||||
|
this host ships container logs to a central aggregator, prefer reading the
|
||||||
|
file:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose exec selfpost cat /data/setup-token
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full deployment
|
||||||
|
|
||||||
|
Production layout: one `docker-compose.yml`, a `.env`, persistent `./data`, and
|
||||||
|
TLS PEM files at `./certs` (read by Postfix on 465/587). The panel is reached
|
||||||
|
only through a [reverse proxy](#reverse-proxy-mandatory) on 443 — port 8080 is
|
||||||
|
bound to localhost in the default compose file.
|
||||||
|
|
||||||
|
| Artefact | Path |
|
||||||
|
|---|---|
|
||||||
|
| Compose file (fixed image tag) | [deploy/docker-compose.yml](../deploy/docker-compose.yml) |
|
||||||
|
| Environment template | [deploy/.env.example](../deploy/.env.example) |
|
||||||
|
| Apache vhost (recommended) | [deploy/apache/selfpost-vhost.conf](../deploy/apache/selfpost-vhost.conf) |
|
||||||
|
| nginx | [deploy/nginx/](../deploy/nginx/) |
|
||||||
|
| Caddy | [deploy/caddy/](../deploy/caddy/) |
|
||||||
|
| Traefik | [deploy/traefik/](../deploy/traefik/) |
|
||||||
|
|
||||||
|
**1. Fetch the base files.**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -p selfpost/data selfpost/certs && cd selfpost
|
||||||
|
curl -O https://raw.githubusercontent.com/mixeme/selfpost/main/deploy/docker-compose.yml
|
||||||
|
curl -O https://raw.githubusercontent.com/mixeme/selfpost/main/deploy/.env.example
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env` — at minimum set `SELFPOST_HOSTNAME` to your mail hostname (bare
|
||||||
|
FQDN, e.g. `mail.example.com`). It must match the PTR record you request from
|
||||||
|
your provider and the certificate your proxy will obtain. See [Environment
|
||||||
|
variables](#environment-variables) for the full list.
|
||||||
|
|
||||||
|
**2. Reverse proxy and TLS.** Pick and set up one proxy — see [Reverse proxy
|
||||||
|
(mandatory)](#reverse-proxy-mandatory) for the per-proxy commands. The same
|
||||||
|
certificate must end up under `./certs` as `fullchain.pem` and `privkey.pem`
|
||||||
|
so Postfix can serve it on 465 (and 587 if enabled).
|
||||||
|
|
||||||
|
**3. Start SelfPost.** If you used Apache on the host (the recommended
|
||||||
|
option), start only the base compose file from your `selfpost/` directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The nginx/Caddy/Traefik fragments already include `docker compose up -d` —
|
||||||
|
skip this if you ran one of those.
|
||||||
|
|
||||||
|
**Get the setup URL** — open it in a browser to create the admin account (see
|
||||||
|
[Initial setup](#initial-setup)):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose logs selfpost 2>&1 | grep -m1 'http'
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cat ./data/setup-token
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. DNS and sending.** Before sending real mail:
|
||||||
|
|
||||||
|
1. Confirm PTR/rDNS for the server IP points at `SELFPOST_HOSTNAME` (Status
|
||||||
|
page → *Re-check*) — see [Server-level DNS](#server-level-dns-ptrrdns).
|
||||||
|
2. For each domain you add in the panel, publish SPF, DKIM, and DMARC at the
|
||||||
|
same time ([Domain-level DNS](#domain-level-dns-spf-dkim-dmarc)).
|
||||||
|
3. Warm up a new IP gradually ([IP warmup](#ip-warmup)).
|
||||||
|
|
||||||
|
#### Fixed image tag
|
||||||
|
|
||||||
|
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
|
||||||
|
deliberately never `:latest`. The current pin is `1.3.0`. Intermediate
|
||||||
|
CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that
|
||||||
|
image was published. Pinning matters because of the backup version check (see
|
||||||
|
[Full backup and restore](#full-backup-and-restore)): the panel binary's
|
||||||
|
embedded version and the image tag that produced it are the same value by
|
||||||
|
construction (the release CI stamps both from one git tag — see
|
||||||
|
`.github/workflows/release.yml`), so the pin is what makes "restore into the
|
||||||
|
same version" a checkable fact rather than a guess. Upgrade by bumping the tag
|
||||||
|
deliberately, not by riding a moving target — see [Upgrading](#upgrading).
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
Copy [deploy/.env.example](../deploy/.env.example) to `.env` next to your
|
||||||
|
`docker-compose.yml`. The table below lists every variable an operator is
|
||||||
|
expected to set; defaults match the code exactly.
|
||||||
|
|
||||||
|
| Variable | Purpose | Default | Set in |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `SELFPOST_HOSTNAME` | Mail-server identity: Postfix HELO/EHLO, SASL realm, certificate CN/SAN, and the hostname the PTR check expects. Bare FQDN only — no scheme or port. | *(required)* | `.env` |
|
||||||
|
| `SUBMISSION_ENABLE` | When `true`, also listen on port 587 with STARTTLS (RFC 6409 submission) alongside the primary 465/smtps listener. | `false` | `.env` |
|
||||||
|
| `RATE_LIMIT_MESSAGES_PER_IP` | Level-1 backstop: maximum messages one client IP may submit per window (Postfix `smtpd_client_message_rate_limit`). See [Rate limiting — level 1](#rate-limiting--level-1-ip-backstop). | `100` | `.env` |
|
||||||
|
| `RATE_LIMIT_WINDOW_SECONDS` | Level-1 window length in seconds (Postfix `anvil_rate_time_unit`). | `3600` | `.env` |
|
||||||
|
| `SEND_LOG_RETENTION_DAYS` | Days of send-log history kept before the background sweep deletes rows — the main driver of `/data` growth over time. | `90` | `.env` |
|
||||||
|
| `PANEL_SESSION_IDLE_DAYS` | Sliding idle timeout for the panel login session, in days. There is no absolute cap: an admin who keeps coming back stays signed in indefinitely. | `7` | `.env` |
|
||||||
|
| `SELFPOST_DNS_RESOLVERS` | Comma-separated recursive resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly (so they report what the internet sees, not what this host's stub resolver synthesises). | `1.1.1.1:53`, `8.8.8.8:53`, `9.9.9.9:53` when unset | `.env` |
|
||||||
|
| `TRUSTED_PROXY_CIDR` | Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to supply `X-Forwarded-For` for login, setup, and account-change rate-limiting. **Leave unset unless you know the exact address of your reverse proxy.** A wrong value lets a client spoof its rate-limit key by sending a forged `X-Forwarded-For` header — the panel trusts the last hop only when the TCP peer matches one of these CIDRs. Behind the default Apache host-network setup this is typically the Docker bridge gateway, e.g. `172.18.0.1`. | *(empty — XFF ignored)* | `.env` |
|
||||||
|
|
||||||
|
TLS certificate paths (`TLS_CERT_FILE`, `TLS_KEY_FILE`) are fixed in
|
||||||
|
[deploy/docker-compose.yml](../deploy/docker-compose.yml) to match the `./certs`
|
||||||
|
bind mount — configure the mount, not these variables.
|
||||||
|
|
||||||
|
The image also reads a number of internal, non-operator env vars (paths,
|
||||||
|
timeouts, tuning) — not part of this interface; see
|
||||||
|
[architecture.md § Configuration](architecture.md#configuration) if you need
|
||||||
|
them.
|
||||||
|
|
||||||
|
### Reverse proxy (mandatory)
|
||||||
|
|
||||||
|
SelfPost's panel speaks plain HTTP and never terminates TLS itself — a reverse
|
||||||
|
proxy in front of it is not optional. The proxy is also the project's only
|
||||||
|
source of TLS certificates: whatever it obtains via ACME/Let's Encrypt gets
|
||||||
|
bind-mounted **read-only** into the SelfPost container, and Postfix uses those
|
||||||
|
same PEM files for TLS on 465 (and 587, if enabled). If the panel and the mail
|
||||||
|
service share one hostname — the common case — it's genuinely one certificate
|
||||||
|
serving both.
|
||||||
|
|
||||||
|
SelfPost isn't tied to a specific proxy; pick whichever fits your host:
|
||||||
|
|
||||||
|
| Proxy | Where certs live | Fragment |
|
||||||
|
|---|---|---|
|
||||||
|
| **Apache** (default/recommended) | Host disk, via the certbot Apache plugin — PEM files ready to bind-mount, no extraction step. | [deploy/apache/selfpost-vhost.conf](../deploy/apache/selfpost-vhost.conf) |
|
||||||
|
| nginx | Host disk, via a certbot sidecar container — same PEM-ready shape as Apache. | [deploy/nginx/](../deploy/nginx/) |
|
||||||
|
| Caddy | Automatic ACME, zero extra containers — simplest, but its on-disk cert path is versioned internal layout, not a stable API; verify it for the Caddy version you run. | [deploy/caddy/](../deploy/caddy/) |
|
||||||
|
| Traefik | Bundled inside `acme.json` — needs a small extraction script to produce standalone PEM files. | [deploy/traefik/](../deploy/traefik/) |
|
||||||
|
|
||||||
|
Apache is the recommended default because the certbot Apache plugin already
|
||||||
|
writes plain `fullchain.pem`/`privkey.pem` files to a predictable path with no
|
||||||
|
extra moving parts between "certificate issued" and "Postfix can read it."
|
||||||
|
|
||||||
|
**The proxy needs no security configuration of its own.** The panel emits its
|
||||||
|
own `Content-Security-Policy`, `Strict-Transport-Security`, `X-Frame-Options`,
|
||||||
|
`X-Content-Type-Options` and `Referrer-Policy` — deliberately, so the part
|
||||||
|
that's easy to get wrong lives in the service rather than in a config file
|
||||||
|
somebody edits under pressure. There is exactly one thing the proxy must do:
|
||||||
|
**pass the original `Host` header through**. All four fragments above already
|
||||||
|
do (Apache `ProxyPreserveHost On`, nginx `proxy_set_header Host $host`, Caddy
|
||||||
|
and Traefik by default). A proxy that rewrites `Host` instead makes the panel
|
||||||
|
reject every form submission as cross-origin — the log says so explicitly,
|
||||||
|
printing the `Origin` and `Host` it compared.
|
||||||
|
|
||||||
|
In every case the proxy terminates HTTPS for the panel; the resulting
|
||||||
|
certificate must end up under `./certs` as `fullchain.pem` and `privkey.pem`.
|
||||||
|
|
||||||
|
**Apache (recommended, on the host).** Install Apache with `ssl`, `proxy`, and
|
||||||
|
`proxy_http` enabled. Copy
|
||||||
|
[deploy/apache/selfpost-vhost.conf](../deploy/apache/selfpost-vhost.conf) into your
|
||||||
|
vhost directory, replace `mail.example.com` with your hostname, enable the site,
|
||||||
|
then issue a certificate:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo certbot --apache -d mail.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Point `./certs` at the PEM files certbot wrote (symlink is fine):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ln -s /etc/letsencrypt/live/mail.example.com certs
|
||||||
|
```
|
||||||
|
|
||||||
|
**nginx (containerised).** From the `deploy/` directory, merge the nginx
|
||||||
|
fragment and issue the first certificate before nginx can serve HTTPS:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml \
|
||||||
|
run --rm certbot certonly --webroot -w /var/www/certbot \
|
||||||
|
-d mail.example.com --email you@example.com --agree-tos --no-eff-email
|
||||||
|
|
||||||
|
docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit [deploy/nginx/nginx.conf.example](../deploy/nginx/nginx.conf.example) and
|
||||||
|
replace `mail.example.com` first. The fragment bind-mounts certbot's output into
|
||||||
|
both nginx and SelfPost.
|
||||||
|
|
||||||
|
**Caddy (containerised, automatic ACME).** Edit
|
||||||
|
[deploy/caddy/Caddyfile](../deploy/caddy/Caddyfile) and the `<hostname>` placeholders
|
||||||
|
in [deploy/caddy/docker-compose.caddy.yml](../deploy/caddy/docker-compose.caddy.yml),
|
||||||
|
then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose -f docker-compose.yml -f caddy/docker-compose.caddy.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify Caddy's on-disk cert path for your version before relying on the
|
||||||
|
default mount — see the comment at the top of the Caddy compose fragment.
|
||||||
|
|
||||||
|
**Traefik (containerised).** Edit the `Host(...)` label and ACME email in
|
||||||
|
[deploy/traefik/docker-compose.traefik.yml](../deploy/traefik/docker-compose.traefik.yml),
|
||||||
|
start the stack, then extract PEM files for Postfix whenever Traefik issues or
|
||||||
|
renews a certificate:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose -f docker-compose.yml -f traefik/docker-compose.traefik.yml up -d
|
||||||
|
./traefik/extract-cert.sh ./traefik/letsencrypt/acme.json mail.example.com ./traefik/extracted-certs
|
||||||
|
```
|
||||||
|
|
||||||
|
Schedule `extract-cert.sh` (cron or a timer) alongside Traefik's renewals.
|
||||||
|
|
||||||
|
## Instance administration
|
||||||
|
|
||||||
|
After sign-in the panel opens on **Status** — the place to answer "is the
|
||||||
|
service healthy and will mail be accepted?"
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
`/status` shows supervised processes (Postfix, OpenDKIM, panel), TLS
|
||||||
|
certificate validity and expiry, milter socket presence, and a short Postfix
|
||||||
|
queue summary. The **Machine** card adds the resource usage of the host
|
||||||
|
underneath — processor (core and thread counts), memory and swap, and
|
||||||
|
per-interface network throughput and totals — read from the kernel's
|
||||||
|
counters; CPU and throughput are measured between refreshes, so they appear
|
||||||
|
one refresh after the page opens. A fully busy processor or a machine out of
|
||||||
|
memory is a warning here, because both delay or kill the mail path;
|
||||||
|
throughput is only reported. The hostname block compares `SELFPOST_HOSTNAME`
|
||||||
|
against the PTR record the internet publishes for this server's IP
|
||||||
|
(forward-confirmed reverse DNS) — see
|
||||||
|
[Server-level DNS](#server-level-dns-ptrrdns); use *Re-check* after changing
|
||||||
|
DNS. The **Reload configuration** button re-applies OpenDKIM tables and the
|
||||||
|
Postfix sender map from the database — use it if daemons drifted from what
|
||||||
|
the panel shows after manual edits under `/data`.
|
||||||
|
|
||||||
|
### Mail queue and System log
|
||||||
|
|
||||||
|
- **Mail queue** (`/mail-queue`) — live view of messages Postfix is still
|
||||||
|
trying to deliver or deferring.
|
||||||
|
- **System log** (`/system-log`) — tail of `/data/log/mail.log` (Postfix and
|
||||||
|
related daemon lines). The log rotates daily (14 files kept) with a
|
||||||
|
`postfix reload` after each rotation; a background loop checks every six
|
||||||
|
hours. It lives in the data volume, so it survives a container recreate along
|
||||||
|
with the rest of the state — `./data/log/` on the host — but it is *not*
|
||||||
|
included in backups: it is diagnostics, not state.
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
|
||||||
|
`/settings` changes the signed-in user's username and/or password. **Global
|
||||||
|
administrators** also set the panel-wide default DMARC report address (`rua=`)
|
||||||
|
offered when a domain doesn't set its own — see
|
||||||
|
[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc). Application SASL logins
|
||||||
|
are separate and are not changed here.
|
||||||
|
|
||||||
|
### Users
|
||||||
|
|
||||||
|
`/users` (global administrator only) creates, edits, and deletes panel users.
|
||||||
|
There are two roles:
|
||||||
|
|
||||||
|
- **Global administrator** — full access to every page and every domain,
|
||||||
|
including Users, Backup, Status, Mail queue, and System log.
|
||||||
|
- **Domain-admin** — scoped to one or more domains assigned by a global
|
||||||
|
administrator. Sees only those domains' pages, applications, and
|
||||||
|
Deliveries rows; cannot add or delete domains. `/users`, `/backup`,
|
||||||
|
`/status`, `/mail-queue`, `/system-log`, and `POST /reload` are not
|
||||||
|
reachable (404). A domain-admin can *export* the
|
||||||
|
domains assigned to them — see
|
||||||
|
[Exporting and importing a single domain](#exporting-and-importing-a-single-domain).
|
||||||
|
|
||||||
|
The panel refuses to remove or demote the **last** global administrator, so
|
||||||
|
it can never end up with none.
|
||||||
|
|
||||||
|
### Sessions
|
||||||
|
|
||||||
|
A login survives a container restart: sessions live in SQLite, not in
|
||||||
|
memory. Expiry is a sliding idle window (`PANEL_SESSION_IDLE_DAYS`, default
|
||||||
|
seven days) with no absolute lifetime cap — an admin who keeps using the panel
|
||||||
|
stays signed in indefinitely. HTMX polling on the monitoring screens
|
||||||
|
(Deliveries, Mail queue, System log, and the Status health fragment) does
|
||||||
|
**not** count as activity, so an auto-refreshing tab left open will not keep a
|
||||||
|
session alive forever. Changing **your own** password on `/settings` signs out
|
||||||
|
every other session for that user but leaves the current browser signed in.
|
||||||
|
Signing out (`POST /logout`) ends only the current session — other browsers or
|
||||||
|
tabs for the same user keep working until their session rows expire.
|
||||||
|
|
||||||
|
### Upgrading
|
||||||
|
|
||||||
|
Bump the pinned image tag in `docker-compose.yml` to the target release, then
|
||||||
|
`docker compose up -d`. The backup version check requires the running image
|
||||||
|
to match the version that created a full backup — see [Fixed image
|
||||||
|
tag](#fixed-image-tag).
|
||||||
|
|
||||||
|
### Container health
|
||||||
|
|
||||||
|
The image declares a Docker `HEALTHCHECK` that probes `GET /healthz` on port
|
||||||
|
8080 (unauthenticated). It returns `200 ok` when OpenDKIM, the panel, and
|
||||||
|
Postfix are all `RUNNING` under supervisord; otherwise `503 unhealthy`. This
|
||||||
|
catches a dead mail path that would still leave the HTTP server up, but it
|
||||||
|
does **not** verify TLS certificates, DNS records, or end-to-end delivery —
|
||||||
|
use the authenticated [Status](#status) page for that. External monitoring
|
||||||
|
can use the same endpoint through the reverse proxy if you expose it, or poll
|
||||||
|
`docker inspect` health state on the host.
|
||||||
|
|
||||||
|
### Server-level DNS (PTR/rDNS)
|
||||||
|
|
||||||
|
Once, for the machine itself: **PTR/rDNS** for the server's IP, pointing at
|
||||||
|
its mail hostname. Most receiving mail servers weigh this heavily; get it
|
||||||
|
from whoever assigns the IP (hosting provider's panel/support), not from
|
||||||
|
your own DNS zone.
|
||||||
|
|
||||||
|
The [Status](#status) page verifies the server's hostname against this
|
||||||
|
record (forward-confirmed reverse DNS). Results are cached for about one
|
||||||
|
minute; use *Re-check* right after publishing a record.
|
||||||
|
|
||||||
|
Per-domain DNS (SPF, DKIM, DMARC) is a separate scope — see
|
||||||
|
[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc).
|
||||||
|
|
||||||
|
### Rate limiting — level 1 (IP backstop)
|
||||||
|
|
||||||
|
SelfPost applies two independent layers of rate limiting; both can refuse a
|
||||||
|
submission, but only level 2 (domain/application, see
|
||||||
|
[Domain administration](#rate-limiting--level-2-domain-and-application))
|
||||||
|
writes a `rejected` row in the send log. Level-2 ceilings set in the panel
|
||||||
|
cannot exceed level 1 (the panel shows the level-1 values and rejects higher
|
||||||
|
numbers).
|
||||||
|
|
||||||
|
Level 1 is always on, configured via `.env`:
|
||||||
|
|
||||||
|
- `RATE_LIMIT_MESSAGES_PER_IP` → Postfix `smtpd_client_message_rate_limit`
|
||||||
|
- `RATE_LIMIT_WINDOW_SECONDS` → Postfix `anvil_rate_time_unit`
|
||||||
|
|
||||||
|
This is an anvil limit per connecting client IP. It keeps working even if the
|
||||||
|
journal-milter (level 2) is down. There is no per-IP bypass.
|
||||||
|
|
||||||
|
### Full backup and restore
|
||||||
|
|
||||||
|
**Full backup** is a self-contained project archive: `data/` (SQLite, all
|
||||||
|
domains' DKIM keys, all applications' SASL credentials, the Postfix queue,
|
||||||
|
`manifest.json` with the version that created it), plus `docker-compose.yml`,
|
||||||
|
`.env`, and `certs/` from the operator directory next to `./data`. Delivery
|
||||||
|
logs under `data/log/` are excluded. The base compose file mounts the project
|
||||||
|
directory read-only at `/selfpost-deploy` so the panel and CLI can read those
|
||||||
|
deploy files — without that mount, *Full backup* refuses with an error.
|
||||||
|
|
||||||
|
Take a backup from the panel (*Backup* → *Full backup*) or from the host:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker exec <container> selfpost-backup > selfpost-backup.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
**Restore** means unpacking that archive into an **empty project directory**
|
||||||
|
(not into `./data` alone) and starting a container of the **exact same image
|
||||||
|
version** that created it — SelfPost refuses to start otherwise and tells you
|
||||||
|
which tag to use. On the first successful start after restore, `data/manifest.json`
|
||||||
|
from the archive is **deleted** — it guards only that one boot, so a later
|
||||||
|
in-place upgrade is not blocked. On that same first boot the panel also runs
|
||||||
|
one **Resync** — OpenDKIM's tables and Postfix's sender map are re-derived from
|
||||||
|
SQLite and both daemons are reloaded, healing any drift between the extracted
|
||||||
|
files and the database (the Status page's *Reload configuration* button runs
|
||||||
|
the same step on demand). This is why the compose file pins a fixed tag rather
|
||||||
|
than `:latest`: without a known version, there'd be no way to tell which image
|
||||||
|
restoring a given backup actually requires (see [Fixed image
|
||||||
|
tag](#fixed-image-tag)).
|
||||||
|
|
||||||
|
**Restoring in place** (same host — recovering from data loss, or rolling
|
||||||
|
back after a bad change):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1. Stop the instance being replaced
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# 2. Move the current project aside rather than deleting it
|
||||||
|
mv . ../selfpost.before-restore
|
||||||
|
mkdir selfpost && cd selfpost
|
||||||
|
|
||||||
|
# 3. Unpack the backup into the fresh directory
|
||||||
|
tar xzf ../selfpost-backup.tar.gz
|
||||||
|
|
||||||
|
# 4. docker-compose.yml in the archive must pin the exact tag the backup was
|
||||||
|
# made with — check if unsure:
|
||||||
|
tar xzf ../selfpost-backup.tar.gz -O data/manifest.json
|
||||||
|
|
||||||
|
# 5. Start it and watch the boot
|
||||||
|
docker compose up -d
|
||||||
|
docker compose logs -f selfpost
|
||||||
|
```
|
||||||
|
|
||||||
|
A version mismatch at step 5 refuses to start and leaves `/data` untouched —
|
||||||
|
the panel exits with a message naming the tag to use, e.g.:
|
||||||
|
|
||||||
|
```
|
||||||
|
backup: this backup was created by SelfPost 1.2.3 but this image is 1.3.0 — restore into the matching image (selfpost:1.2.3)
|
||||||
|
```
|
||||||
|
|
||||||
|
Fix the tag in `docker-compose.yml`, `docker compose pull && docker compose up
|
||||||
|
-d` again — the manifest is still there because the failed boot never got to
|
||||||
|
delete it.
|
||||||
|
|
||||||
|
**Moving to a different host** is the same flow: create an empty project
|
||||||
|
directory, unpack the backup there, edit `.env` (and `docker-compose.yml` if
|
||||||
|
needed) for the new hostname or proxy, then `docker compose up -d`. The archive
|
||||||
|
carries `certs/` from the old host — re-issue certificates when the hostname or
|
||||||
|
IP changes. Set up the reverse-proxy vhost separately (not in the backup).
|
||||||
|
|
||||||
|
**Restoring an encrypted (`.spbk`) backup** needs a running container to
|
||||||
|
decrypt it first — any container with the `selfpost-backup` CLI works; decryption
|
||||||
|
does not read `/data` and performs no version check. Start one normally
|
||||||
|
(step 5, but on an empty project you have not unpacked yet), then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker exec -i <container> selfpost-backup -decrypt < backup.spbk > selfpost-backup.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop it, wipe the project directory again, and continue from step 2 above with
|
||||||
|
the resulting `.tar.gz` — see [Encrypting a backup or
|
||||||
|
export](#encrypting-a-backup-or-export) for the decrypt command's password
|
||||||
|
options.
|
||||||
|
|
||||||
|
**Archives from older SelfPost versions** (flat layout: `manifest.json` and
|
||||||
|
`selfpost.db` at the archive root, no `data/` prefix, no deploy files) restore
|
||||||
|
with the previous procedure: `tar xzf backup.tar.gz -C ./data` into a project
|
||||||
|
that already has `docker-compose.yml` and `.env`.
|
||||||
|
|
||||||
|
Restoring an archive taken **before** a session row was removed can bring
|
||||||
|
that session back: session rows travel with the backup, and a browser that
|
||||||
|
still holds the matching cookie is signed in again on the next request if the
|
||||||
|
restored row's idle expiry has not passed. `POST /logout` removes only the
|
||||||
|
current session; there is no "logout everywhere". Changing your own password
|
||||||
|
on `/settings` deletes your other sessions, but a global administrator
|
||||||
|
resetting another user's password on `/users` does not invalidate that user's
|
||||||
|
existing sessions.
|
||||||
|
|
||||||
|
**Alternative: archive `./data` while stopped.** If the service can be taken
|
||||||
|
offline, `docker compose down` then `tar czf selfpost-data.tar.gz ./data` on
|
||||||
|
the host is safe — nothing is writing to SQLite. Unlike the panel/CLI backup
|
||||||
|
this sweeps in `./data/log/` too, which is Postfix's raw log and usually the
|
||||||
|
bulk of the archive; add `--exclude=./data/log` if you only want the state.
|
||||||
|
Do **not** tar `./data` while the container is running: the database uses
|
||||||
|
WAL mode and a naive copy can capture an inconsistent snapshot. The
|
||||||
|
panel/CLI backup remains preferable when you cannot afford downtime because
|
||||||
|
it takes a consistent SQLite snapshot via the Backup API on a live
|
||||||
|
container.
|
||||||
|
|
||||||
|
See also [Exporting and importing a single
|
||||||
|
domain](#exporting-and-importing-a-single-domain) — a different, domain-scoped
|
||||||
|
operation that also lives on the *Backup* page (`/backup`).
|
||||||
|
|
||||||
|
Both a full backup and a domain export are **secrets** — they contain the
|
||||||
|
admin password hash (full backup), TLS private keys and `.env` (full backup),
|
||||||
|
or working application credentials (domain export) in the clear or in
|
||||||
|
directly reversible form. Treat them like any other credential material: restrict who can read them, don't email them
|
||||||
|
around — and encrypt them, which SelfPost can do for you.
|
||||||
|
|
||||||
|
#### Encrypting a backup or export
|
||||||
|
|
||||||
|
Both download forms carry an **Encrypt with a password** checkbox. Ticked, the
|
||||||
|
file that comes down is an encrypted envelope instead of the plain archive:
|
||||||
|
|
||||||
|
| Artefact | Plain | Encrypted |
|
||||||
|
|----------|-------|-----------|
|
||||||
|
| Full backup | `.tar.gz` | `.spbk` (**S**elf**P**ost **b**ac**k**up) |
|
||||||
|
| Domain export | `.json` | `.spde` (**S**elf**P**ost **d**omain **e**xport) |
|
||||||
|
|
||||||
|
The suffixes are for the operator only — the server detects an encrypted file
|
||||||
|
by its magic bytes (`SELFPOST1`), not by the extension.
|
||||||
|
|
||||||
|
The key is derived from the password with scrypt and the contents are sealed
|
||||||
|
with AES-256-GCM, in chunks, so a truncated or altered file fails to open rather
|
||||||
|
than restoring quietly. **SelfPost does not store the password** — lose it and
|
||||||
|
the file is unrecoverable, which is the entire point.
|
||||||
|
|
||||||
|
*Import a domain* takes an encrypted export directly: choose a `.spde` file and
|
||||||
|
the password field appears (driven by the file extension in the browser; the
|
||||||
|
server also detects the envelope by its magic bytes). A plain `.json` export
|
||||||
|
needs no password.
|
||||||
|
|
||||||
|
A full backup has to be turned back into a plain archive before it can be
|
||||||
|
unpacked into `/data`, which the CLI does with the same password:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker exec -i <container> selfpost-backup -decrypt < backup.spbk > backup.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI also *writes* encrypted backups for scripted/cron use. The password
|
||||||
|
comes from `SELFPOST_BACKUP_PASSWORD` or `-password-file <path>` (first line),
|
||||||
|
never from a command-line argument, which would be visible in the process list:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker exec -e SELFPOST_BACKUP_PASSWORD="$PW" <container> selfpost-backup > backup.spbk
|
||||||
|
```
|
||||||
|
|
||||||
|
With no password set, the CLI keeps writing the plain `.tar.gz` it always has.
|
||||||
|
|
||||||
|
## Domain administration
|
||||||
|
|
||||||
|
### Domains page
|
||||||
|
|
||||||
|
`/domains` lists sending domains and hosts the add-domain form (**global
|
||||||
|
administrator only**). Domain administrators see only domains assigned to
|
||||||
|
them. Each row shows its DKIM TXT value, SPF/DMARC checks, and SASL
|
||||||
|
applications. Per-domain rate limits (level 2) and trusted-IP application
|
||||||
|
overrides are configured here — see [Rate limiting —
|
||||||
|
level 2](#rate-limiting--level-2-domain-and-application). *Export domain*
|
||||||
|
writes a single-domain archive; *Import a domain* on the Backup page reads
|
||||||
|
one back in (**global administrator only**) — see [Exporting and importing a single
|
||||||
|
domain](#exporting-and-importing-a-single-domain).
|
||||||
|
|
||||||
|
### Domain-level DNS (SPF, DKIM, DMARC)
|
||||||
|
|
||||||
|
For *every* sending domain you add in the panel:
|
||||||
|
|
||||||
|
- **SPF** — a TXT record on the domain authorizing this server to send on its
|
||||||
|
behalf (e.g. `v=spf1 a mx ip4:<server IP> -all`, adjusted to your setup).
|
||||||
|
- **DKIM** — a TXT record with the exact value the panel shows on that
|
||||||
|
domain's page (`domain page → DKIM TXT record`), one selector per domain.
|
||||||
|
- **DMARC** — a `_dmarc` TXT record. The panel suggests `p=none` (monitoring
|
||||||
|
only, safe to publish immediately). On a send-only relay the sending domain
|
||||||
|
often has no inbox, so `rua=` is optional — configure a default report address
|
||||||
|
in *Settings* (see [Settings](#settings)) or per domain when you have a
|
||||||
|
mailbox that receives inbound mail elsewhere. If `rua=` points at another
|
||||||
|
domain, publish `_report._dmarc` on that hub domain too; the panel checks
|
||||||
|
it. Public mail hosts (Gmail, Outlook, …) cannot be used as external
|
||||||
|
report destinations.
|
||||||
|
|
||||||
|
Skipping any of the three records is the single most common reason mail
|
||||||
|
lands in spam even though SelfPost delivered it correctly — DKIM passing
|
||||||
|
doesn't help if SPF/DMARC are absent. **Whenever you add a new domain in the
|
||||||
|
panel, add its DNS records at the same time**, not later.
|
||||||
|
|
||||||
|
Each domain's page shows a *DNS status* card comparing the published DKIM
|
||||||
|
record against the key this server signs with, plus the domain's SPF,
|
||||||
|
DMARC, and (when configured) DMARC report-authorisation records. Results are
|
||||||
|
cached for a few minutes; use *Re-check* right after publishing a record.
|
||||||
|
The SPF check is deliberately shallow — it looks for a mechanism that
|
||||||
|
literally covers this server's address and does not follow `include:` or
|
||||||
|
`redirect=`, so a record that authorizes the server through an include is
|
||||||
|
reported as "cannot tell" rather than as a failure.
|
||||||
|
|
||||||
|
Server-level DNS (the PTR/rDNS record) is a separate, once-per-machine scope
|
||||||
|
— see [Server-level DNS](#server-level-dns-ptrrdns).
|
||||||
|
|
||||||
|
### IP warmup
|
||||||
|
|
||||||
|
A brand-new IP has no sending history, so receiving servers are cautious with
|
||||||
|
it regardless of how correct your DKIM/SPF/DMARC are. Start with low volume to
|
||||||
|
a domain, increase gradually over days/weeks rather than sending everything on
|
||||||
|
day one, and check the IP against major blocklists (Spamhaus and similar)
|
||||||
|
before and during warmup. This is inherent to how mail reputation works on the
|
||||||
|
public internet, not something SelfPost's configuration can shortcut.
|
||||||
|
|
||||||
|
### Rate limiting — level 2 (domain and application)
|
||||||
|
|
||||||
|
Level 2 is optional, configured on each domain's page, and layers on top of
|
||||||
|
the always-on [level-1 IP backstop](#rate-limiting--level-1-ip-backstop).
|
||||||
|
Level-2 ceilings cannot exceed level 1 (the panel shows the level-1 values
|
||||||
|
and rejects higher numbers). When a level-2 ceiling is exceeded, Postfix
|
||||||
|
returns a 4xx and the refusal is recorded in [Deliveries](#deliveries) as
|
||||||
|
`rejected`.
|
||||||
|
|
||||||
|
**Level 2 — domain** — a message ceiling and window for **every** client IP
|
||||||
|
sending as that domain. When unset, only level 1 applies for non-privileged
|
||||||
|
senders.
|
||||||
|
|
||||||
|
**Level 2 — application (trusted IPs)** — optional override on an
|
||||||
|
application: list one or more client IPs and a ceiling **strictly above**
|
||||||
|
the domain limit (still ≤ level 1). Connections from those IPs use the
|
||||||
|
application ceiling and skip the domain check. Other IPs stay under the
|
||||||
|
domain limit (or level 1 alone). An application override without trusted
|
||||||
|
IPs is inactive.
|
||||||
|
|
||||||
|
**Level 2 is best-effort, not a guarantee.** It runs inside the
|
||||||
|
journal-milter and is deliberately fail-open: if the rate-limit lookup hits
|
||||||
|
a store error, or the connecting client's IP is not available to the
|
||||||
|
milter, level 2 is skipped and the message is accepted rather than held up.
|
||||||
|
Level 1 is the backstop that keeps working even when level 2 cannot run.
|
||||||
|
|
||||||
|
### Deliveries
|
||||||
|
|
||||||
|
`/deliveries` is a searchable send log with server-side filters by domain
|
||||||
|
and application. A row identifies its message and nothing more — time,
|
||||||
|
sender, recipient, subject and status `queued` (accepted, not yet
|
||||||
|
delivered), `sent` (handed off successfully), `deferred` (Postfix is
|
||||||
|
retrying), `bounced` (final failure), or `rejected` (refused — for example
|
||||||
|
by a [level-2 rate limit](#rate-limiting--level-2-domain-and-application));
|
||||||
|
*Details* opens that row's own page (`/deliveries/{id}`). That page carries
|
||||||
|
the sending domain, the application it was submitted under, the Postfix
|
||||||
|
queue id and the journal id, beside the message's history — when it was
|
||||||
|
accepted and what Postfix later reported for the recipient — and, under
|
||||||
|
both, the `mail.log` lines for its queue id: the connection to the
|
||||||
|
receiving server, the server's reply, and the status that reply was filed
|
||||||
|
as. Rows outlive `mail.log`, so an older message's lines may have rotated
|
||||||
|
away; the page says so. Retention is controlled by
|
||||||
|
`SEND_LOG_RETENTION_DAYS`.
|
||||||
|
|
||||||
|
### Exporting and importing a single domain
|
||||||
|
|
||||||
|
Domain page → *Export domain* to write the file, *Backup* → *Import a
|
||||||
|
domain* to read it back in. This moves one domain — its DKIM key and its
|
||||||
|
applications' **working** SASL passwords — to a different SelfPost instance
|
||||||
|
without regenerating anything, so DNS (the DKIM TXT record) doesn't need to
|
||||||
|
change. Unlike a full restore (see [Full backup and
|
||||||
|
restore](#full-backup-and-restore)), this works across different
|
||||||
|
hostnames/instances. *Import* is global-administrator only; *export* is
|
||||||
|
available to any user who can access the domain, **including a domain-admin**
|
||||||
|
for a domain assigned to them — so a domain-admin can walk away with that
|
||||||
|
domain's working SASL passwords in the clear. Weigh that when deciding which
|
||||||
|
domains to assign to a domain-admin account.
|
||||||
|
|
||||||
|
A domain export is a secret in the same way a full backup is, and can be
|
||||||
|
encrypted the same way — see [Encrypting a backup or
|
||||||
|
export](#encrypting-a-backup-or-export).
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Plan: dmarc-reports
|
||||||
|
|
||||||
|
**Status:** candidate
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
SelfPost **receives** DMARC aggregate reports on SMTP, parses them inside the
|
||||||
|
image, and **shows summaries in the panel** — pass/fail by source, hints when
|
||||||
|
`tighten p=` is reasonable. No external DMARC SaaS and no IMAP workflow for the
|
||||||
|
operator.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In:**
|
||||||
|
- Inbound SMTP for configured report addresses only (not a general backup-MX).
|
||||||
|
- gzip + XML aggregate parsing → SQLite summaries per sending domain.
|
||||||
|
- Panel page and/or per-domain section: recent reports, third-party senders,
|
||||||
|
delivery health of report ingestion.
|
||||||
|
- Reuse the `dmarc_report_email` setting (moved off the old `admin` table into
|
||||||
|
`settings` by migration `0005`) and `domains.dmarc_rua` for DNS templates;
|
||||||
|
when enabled, suggest a SelfPost-hosted report address.
|
||||||
|
|
||||||
|
**Out:**
|
||||||
|
- Forensic reports (`ruf=`).
|
||||||
|
- Full dashboards, APIs, email alerting.
|
||||||
|
- Mailboxes for people (IMAP/POP3/webmail).
|
||||||
|
|
||||||
|
## Architecture (sketch)
|
||||||
|
|
||||||
|
1. Receiving MTAs → SMTP to SelfPost (hub MX).
|
||||||
|
2. Postfix virtual alias or dedicated listener → panel ingest worker.
|
||||||
|
3. Parse XML → `dmarc_reports` table (domain, reporter, counts, date).
|
||||||
|
4. Panel reads SQLite; links from domain DNS card.
|
||||||
|
|
||||||
|
May share port-25 plumbing with [inbound-relay.md](inbound-relay.md) but must
|
||||||
|
remain a separate, opt-in feature that does not forward mail upstream.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- Operator can point `rua=` at an address SelfPost accepts and see parsed
|
||||||
|
summaries in the panel within one reporting cycle.
|
||||||
|
- With the feature off, outbound-only behaviour is unchanged.
|
||||||
|
- Documented in [guide.md](../guide.md); migrations are backward-compatible.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Attack surface of accepting mail (mitigate: strict recipient allow-list).
|
||||||
|
- Report volume and retention (mitigate: caps + pruning).
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# Plan: domain-stats-auto-ratelimit
|
||||||
|
|
||||||
|
**Status:** candidate
|
||||||
|
**Date:** 2026-08-17
|
||||||
|
**Version:** `1.x` MINOR; migrations must stay compatible with `1.0.0`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Give the operator **30-day sending statistics** per domain and per application
|
||||||
|
(total volume, peak and average rate), and an optional **auto** level-2 rate
|
||||||
|
limit that sets `max_messages` from the average rate (avg × multiplier) over the
|
||||||
|
level-1 window.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In:**
|
||||||
|
|
||||||
|
- Rolling **30-day** stats on each domain page (domain aggregate + per-app rows):
|
||||||
|
message count, peak msg/h, average msg/h.
|
||||||
|
- Level-2 rate limit mode **manual** (today) or **auto** for domain and
|
||||||
|
application scopes.
|
||||||
|
- Auto formula: `max_messages = ceil(avg_hourly × multiplier)`, window =
|
||||||
|
level-1 window (`RATE_LIMIT_WINDOW_SECONDS`); capped at level 1.
|
||||||
|
- Background recalculation (e.g. every 6 h, alongside send-log prune); milter
|
||||||
|
reads stored `max_messages` / `window_seconds` only (no aggregates on the
|
||||||
|
hot path).
|
||||||
|
- Panel UI: stats card, manual/auto toggle, multiplier field, read-only computed
|
||||||
|
limit in auto mode, optional «Recalculate now».
|
||||||
|
- RBAC: domain-admin sees stats and may configure auto/manual for assigned
|
||||||
|
domains only; same authz as existing rate-limit handlers.
|
||||||
|
- Tests, [guide.md](../guide.md), [CHANGELOG.md](../../CHANGELOG.md); security
|
||||||
|
review (Fable) for rate-limit path changes.
|
||||||
|
|
||||||
|
**Out:**
|
||||||
|
|
||||||
|
- Changing level 1 (Postfix env) — auto only fills level 2 under the L1 cap.
|
||||||
|
- Automated IP warmup schedules ([guide.md](../guide.md) § IP warmup stays
|
||||||
|
operator-driven).
|
||||||
|
- Prometheus/Grafana, alerting, APIs.
|
||||||
|
- Counting level-1 refusals or `rejected` rows as sent volume.
|
||||||
|
- Per-client-IP analytics.
|
||||||
|
|
||||||
|
## Data source
|
||||||
|
|
||||||
|
All metrics come from SQLite `send_log`, same rules as
|
||||||
|
[`CountMessages`](../../internal/store/ratelimits.go):
|
||||||
|
|
||||||
|
- One message = one distinct `queue_id` (many recipients = one count).
|
||||||
|
- `status != rejected` (level-2 refusals never queued).
|
||||||
|
- Level-1 refusals are **not** in `send_log` — stats under-count refusals;
|
||||||
|
document in UI copy.
|
||||||
|
|
||||||
|
Retention today is env `SEND_LOG_RETENTION_DAYS` (default 90). Stats use the
|
||||||
|
last **30 days** of rows still present. If retention < 30 days (after
|
||||||
|
[send-log-retention](send-log-retention.md)), the stats window is
|
||||||
|
`min(30, retention)` with a warning.
|
||||||
|
|
||||||
|
### Metrics
|
||||||
|
|
||||||
|
| Metric | Definition |
|
||||||
|
|---|---|
|
||||||
|
| **total** | `COUNT(DISTINCT queue_id)` in the stats window |
|
||||||
|
| **peak rate** | maximum messages in any **hourly** bucket in that window (msg/h) |
|
||||||
|
| **avg rate** | `total / hours_in_window`, where `hours_in_window = min(720, age of domain/app in hours, retention hours)` |
|
||||||
|
|
||||||
|
Keys: `send_log.domain` (domain scope), `send_log.app_login` (application scope).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
milter["journal-milter"] --> sendLog["send_log"]
|
||||||
|
sendLog --> statsQuery["StatsQuery 30d"]
|
||||||
|
statsQuery --> domainPage["domain page stats card"]
|
||||||
|
statsQuery --> autoJob["auto recalc job"]
|
||||||
|
autoJob --> rateLimits["rate_limits"]
|
||||||
|
rateLimits --> milter
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **[`internal/store/stats.go`](../../internal/store/stats.go)** — `SendStats`
|
||||||
|
with `Total`, `PeakPerHour`, `AvgPerHour`; `DomainSendStats(name, since)` /
|
||||||
|
`AppSendStats(login, since)`.
|
||||||
|
2. Hourly buckets: `strftime('%Y-%m-%d %H', created_at)` + `GROUP BY`; subquery
|
||||||
|
for peak; total via distinct `queue_id`.
|
||||||
|
3. Indexes `idx_send_log_domain` and `idx_send_log_created_at` exist; add
|
||||||
|
composite `(domain, created_at)` only if profiling shows need.
|
||||||
|
4. **Auto recalc** — panel goroutine (same interval as send-log prune): for each
|
||||||
|
`rate_limits` row with `mode = auto`, recompute `max_messages`, set
|
||||||
|
`auto_updated_at`. Milter unchanged except reading new columns via existing
|
||||||
|
`RateLimit` lookup.
|
||||||
|
|
||||||
|
### Auto rate limit
|
||||||
|
|
||||||
|
Extend [`RateLimit`](../../internal/store/ratelimits.go):
|
||||||
|
|
||||||
|
```go
|
||||||
|
type RateLimit struct {
|
||||||
|
// existing: Scope, RefID, AllowedIPs, MaxMessages, WindowSeconds
|
||||||
|
Mode string // "manual" | "auto"
|
||||||
|
AutoMultiplier float64 // default 2.5 when Mode == "auto"
|
||||||
|
AutoUpdatedAt time.Time
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Formula:**
|
||||||
|
|
||||||
|
```
|
||||||
|
avg_hourly = total_messages_in_window / hours_in_window
|
||||||
|
max_messages = ceil(avg_hourly * auto_multiplier)
|
||||||
|
window_seconds = L1 window (not editable in auto mode)
|
||||||
|
max_messages = min(max_messages, L1 max)
|
||||||
|
```
|
||||||
|
|
||||||
|
When `total == 0`: auto limit stays **inactive** (same as empty manual limit);
|
||||||
|
UI explains that traffic is required before auto can apply.
|
||||||
|
|
||||||
|
**Application overrides** ([`handlers_ratelimit.go`](../../internal/web/handlers/handlers_ratelimit.go)):
|
||||||
|
|
||||||
|
- Trusted IPs required.
|
||||||
|
- Auto app ceiling **strictly above** domain limit when domain limit is active.
|
||||||
|
- Ceiling ≤ L1.
|
||||||
|
|
||||||
|
**Fail-open:** store errors during recalc must not weaken enforcement of the
|
||||||
|
last successfully written limit; recalc failures are logged only.
|
||||||
|
|
||||||
|
### Migration (`0006_rate_limit_auto.sql`)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE rate_limits ADD COLUMN mode TEXT NOT NULL DEFAULT 'manual'
|
||||||
|
CHECK (mode IN ('manual', 'auto'));
|
||||||
|
ALTER TABLE rate_limits ADD COLUMN auto_multiplier REAL;
|
||||||
|
ALTER TABLE rate_limits ADD COLUMN auto_updated_at TEXT;
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing rows → `manual`.
|
||||||
|
|
||||||
|
### Domain export
|
||||||
|
|
||||||
|
Today rate limits are **not** exported. This plan adds them (including
|
||||||
|
`mode`, `auto_multiplier`) to domain transfer JSON — document as a boundary
|
||||||
|
change in [guide.md](../guide.md) § Export.
|
||||||
|
|
||||||
|
## Panel UI
|
||||||
|
|
||||||
|
- Domain page ([`domain_detail.html`](../../internal/web/view/templates/domain_detail.html)):
|
||||||
|
- **Sending statistics (30 days)** — total, peak msg/h, avg msg/h.
|
||||||
|
- Per-application stats in the app list.
|
||||||
|
- Rate limit: Manual / Auto, multiplier (e.g. 1.5–5.0, default 2.5), read-only
|
||||||
|
computed max/window in auto mode, «Recalculate now».
|
||||||
|
- Optional later: «30d» column on domain list (global admin only).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `internal/store/stats_test.go` — fixtures → total / peak / avg.
|
||||||
|
- `internal/store/ratelimits_test.go` — auto recalc, L1 cap, app > domain.
|
||||||
|
- Handler tests — auto form validation, multiplier bounds.
|
||||||
|
- Milter tests — enforced limit matches last recalculated values.
|
||||||
|
|
||||||
|
`go test` / `go vet` on touched packages.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- Domain and app 30-day stats visible on the domain page; domain-admin scoping
|
||||||
|
enforced.
|
||||||
|
- Manual/auto toggle works for domain and app; auto recalc updates `rate_limits`
|
||||||
|
and milter enforces stored ceilings.
|
||||||
|
- Zero-traffic auto stays inactive with clear UI copy.
|
||||||
|
- [guide.md](../guide.md) and [CHANGELOG.md](../../CHANGELOG.md) updated;
|
||||||
|
security review passed.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Heavy aggregation on large `send_log` tables — mitigate with indexes or
|
||||||
|
nightly rollups (phase 2).
|
||||||
|
- Stats without level-1 visibility — mitigate with operator-facing caveat.
|
||||||
|
- Auto limit too tight after a spike — multiplier is operator-tuned; show peak
|
||||||
|
alongside avg in auto UI.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- [`send-log-retention`](send-log-retention.md) is a separate roadmap item but
|
||||||
|
should land before or in parallel so operators can set retention ≥ 30 days
|
||||||
|
from the panel.
|
||||||
|
|
||||||
|
**Version:** `1.x` MINOR.
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# Plan: inbound-relay (inbound relay)
|
||||||
|
|
||||||
|
**Status:** agreed
|
||||||
|
**Version:** target bump **1.x** MINOR; **`2.x` possible** — to be settled once
|
||||||
|
the implementation lands (do not fix a major in advance).
|
||||||
|
**Order:** the 2026-08-13 full-tree review follow-ups are closed (CHANGELOG
|
||||||
|
`[Unreleased]`), so this is next up as the largest remaining 1.x+ **feature**.
|
||||||
|
The domain-admin role
|
||||||
|
and `internal/web` split have shipped — see [CHANGELOG](../../CHANGELOG.md)
|
||||||
|
`[1.2.0]`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
The ability to accept mail on port 25 for explicitly configured domains and
|
||||||
|
forward it to a given upstream backend (a backup-MX / relay-forwarder role), as
|
||||||
|
a **module disabled by default** that changes neither the behaviour nor the
|
||||||
|
attack surface of the base outbound relay.
|
||||||
|
|
||||||
|
## What it is for (scenarios)
|
||||||
|
|
||||||
|
- **Backup-MX** — accept mail while the domain's primary mail server is
|
||||||
|
temporarily unreachable, and hand it over when it comes back.
|
||||||
|
- **A front for a server without a public IP** — the operator runs their own
|
||||||
|
mail server which, for whatever reason, **cannot accept mail from the
|
||||||
|
internet itself** (no static or public IP, behind NAT, a private address,
|
||||||
|
inbound port 25 blocked, and so on). SelfPost, with a public IP and a correct
|
||||||
|
PTR, acts as the domain's public entry node (the MX points at it) and
|
||||||
|
forwards mail to that internal or otherwise unreachable server.
|
||||||
|
|
||||||
|
## Scope boundary (critical — what this is NOT)
|
||||||
|
|
||||||
|
- **IT IS:** acceptance on 25 for domains from an explicit list, plus
|
||||||
|
forwarding (relay/forward) to an upstream (`relay_domains` +
|
||||||
|
`transport_maps` + `relay_recipient_maps`). Postfix here is a pure forwarder,
|
||||||
|
with no local delivery.
|
||||||
|
- **IT IS NOT (out of scope, [product.md](../product.md)):** local delivery to
|
||||||
|
mailboxes, IMAP/POP3, webmail, Dovecot. No mailboxes at all. SelfPost also
|
||||||
|
**neither implements nor bundles** an anti-spam or anti-virus engine
|
||||||
|
(rspamd/ClamAV) — but, unlike the earlier wording, it does **not** push
|
||||||
|
filtering onto the backend either (see the "Anti-spam" section below): it
|
||||||
|
provides an attachment point for an external filter.
|
||||||
|
|
||||||
|
## Why as an option / plugin
|
||||||
|
|
||||||
|
- Accepting on port 25 changes the threat model (open relay for inbound,
|
||||||
|
backscatter, spam ingress). So it is **off** by default behind the
|
||||||
|
`INBOUND_RELAY_ENABLE=false` env flag; turning it on is a deliberate step by
|
||||||
|
the operator.
|
||||||
|
- Isolation: separate SQLite tables, separate panel handlers and pages, a
|
||||||
|
separate branch of config generation. With the flag off, the inbound
|
||||||
|
listener, the tables and the UI are absent — the base outbound path is
|
||||||
|
byte-for-byte unchanged.
|
||||||
|
|
||||||
|
## What to do
|
||||||
|
|
||||||
|
- The `INBOUND_RELAY_ENABLE` env flag (default false); when `true`, generate
|
||||||
|
the inbound service and its config from panel state the same way the rest of
|
||||||
|
the config is generated (`postfix-config.sh`).
|
||||||
|
- **`master.cf`:** an inbound `smtp inet` on 25 for accepting from the internet
|
||||||
|
(today 25 is used only for outbound delivery). Separate from 465/587: on 25
|
||||||
|
SASL is **not** offered and sending outwards is **not** allowed — inbound
|
||||||
|
only, for `relay_domains`.
|
||||||
|
- **Anti-open-relay for inbound (mandatory):** the inbound smtpd's
|
||||||
|
`smtpd_relay_restrictions` / `smtpd_recipient_restrictions` accept mail
|
||||||
|
**only** for domains in `relay_domains` and **only** for known recipients
|
||||||
|
(`relay_recipient_maps`); everything else gets
|
||||||
|
`reject_unauth_destination` / `reject_unlisted_recipient`. An open relay, or
|
||||||
|
accepting "for anyone", is impossible.
|
||||||
|
- **Backscatter:** knowing the valid recipients is preferable (reject unknown
|
||||||
|
recipient at RCPT stage) so that bounces to non-existent addresses are never
|
||||||
|
generated.
|
||||||
|
- **The panel manages:** the list of inbound domains; for each one the upstream
|
||||||
|
destination (`host:port`, transport), an optional list of valid recipients,
|
||||||
|
and optional TLS to the upstream. Strict validation of domain, host and port
|
||||||
|
(whitelist), injection-safe writing of map files (as with
|
||||||
|
`sender_login_maps` in Phase 4), `os/exec` without a shell
|
||||||
|
([security.md](../security.md)).
|
||||||
|
- **Milters:** OpenDKIM is not needed on the inbound path (we do not sign
|
||||||
|
someone else's inbound mail). The journal-milter can optionally be reused for
|
||||||
|
an inbound journal (extra work), or the inbound path can go without it in the
|
||||||
|
first stage; fail-open behaviour is preserved.
|
||||||
|
- **Rate limit / size:** a coarse per-client-IP limit (`anvil`, as L1) and
|
||||||
|
`message_size_limit` on the inbound smtpd.
|
||||||
|
|
||||||
|
## Anti-spam (important, but optional)
|
||||||
|
|
||||||
|
This is a valuable option, but it is **not mandatory**: some operators will be
|
||||||
|
perfectly served by **blind forwarding without filtering** — when the backend
|
||||||
|
can filter on content itself, when the upstream is trusted, or when the volume
|
||||||
|
and risk are low. So the anti-spam hook is **off** by default (an empty
|
||||||
|
`INBOUND_ANTISPAM_MILTER`), and the inbound relay is fully functional without
|
||||||
|
it.
|
||||||
|
|
||||||
|
What matters is something else: where filtering is technically possible. With a
|
||||||
|
"blind" relay the destination backend sees **SelfPost's** address as the
|
||||||
|
connecting IP, not the original sender's, so everything on the backend that
|
||||||
|
depends on the origin IP breaks (DNSBL and reputation are checked against
|
||||||
|
SelfPost's IP; SPF returns fail, since SelfPost is not in the sending domain's
|
||||||
|
SPF). **The only point where the real client IP is still visible is the inbound
|
||||||
|
hop at SelfPost** — so for those who need filtering, it has to be *attachable
|
||||||
|
right here*, not delegated to a backend that has already lost the information.
|
||||||
|
|
||||||
|
The attachment design:
|
||||||
|
|
||||||
|
- **The anti-spam engine is a separate optional container** (rspamd or
|
||||||
|
similar), which the operator runs **only if this option is wanted** (the same
|
||||||
|
principle as the reverse proxy — a separate container outside the SelfPost
|
||||||
|
image). SelfPost **neither contains nor starts it** — the image and the "one
|
||||||
|
container, three processes" principle are unchanged, and
|
||||||
|
[product.md](../product.md)'s out-of-scope list is not violated (SelfPost
|
||||||
|
does not implement anti-spam).
|
||||||
|
- **SelfPost provides the attachment point:** a milter hook on the inbound
|
||||||
|
smtpd. The engine's address is set via env (for example,
|
||||||
|
`INBOUND_ANTISPAM_MILTER=inet:antispam:11332`, empty → the hook is off) and
|
||||||
|
is added to `smtpd_milters` for the **inbound path only** (not on 465/587).
|
||||||
|
Postfix passes the milter the real client IP, HELO and PTR — the filter sees
|
||||||
|
the true origin. `milter_default_action` for that milter is configurable
|
||||||
|
(fail-open vs tempfail); the default is to be decided during implementation.
|
||||||
|
- **A native backstop with no dependencies:** on that same inbound hop,
|
||||||
|
Postfix's own origin-IP facilities are available — `reject_rbl_client`
|
||||||
|
(DNSBL) and HELO/PTR checks — and they work even without an external
|
||||||
|
container. Plus preserving authentication results for downstream through ARC
|
||||||
|
or `Received`, where part of the filtering does remain on the backend.
|
||||||
|
- **docker-compose:** document an optional anti-spam sidecar fragment (like the
|
||||||
|
alternative reverse-proxy fragments) — the container comes up with the stack
|
||||||
|
only when the option is enabled.
|
||||||
|
- **Persistence:** new tables and map files under `/data` — they land in the
|
||||||
|
full backup automatically (Phase 9). Domain export/import can be extended
|
||||||
|
with the inbound configuration — optional, to be flagged.
|
||||||
|
- **DNS documentation:** an inbound domain needs an `MX` record pointing at the
|
||||||
|
server (unlike outbound, where no MX is required) — to be reflected in the
|
||||||
|
README's DNS section.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
[security.md](../security.md): server-side input validation, escaped writes to
|
||||||
|
config files, `exec` without interpolation, no open relay, protection against
|
||||||
|
backscatter.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
With `INBOUND_RELAY_ENABLE=true` and a configured domain, mail arriving on port
|
||||||
|
25 for that domain is forwarded to the given upstream; mail for unconfigured
|
||||||
|
domains or recipients is rejected (not an open relay, no backscatter); with
|
||||||
|
`INBOUND_ANTISPAM_MILTER` set, inbound mail passes through the external filter
|
||||||
|
with the real origin IP (verified with a sidecar container), and with it empty
|
||||||
|
the hook stays out of the way; with `INBOUND_RELAY_ENABLE=false` the inbound
|
||||||
|
port, tables and UI are absent and the base outbound relay is unchanged;
|
||||||
|
`build`/`vet`/`test`/image green.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- open relay / backscatter — removed by `relay_domains` +
|
||||||
|
`relay_recipient_maps` + `reject_unauth_destination`;
|
||||||
|
- the loss of the origin IP for filtering on the backend when forwarding —
|
||||||
|
removed by the anti-spam milter hook plus native DNSBL on the inbound hop,
|
||||||
|
where the origin IP is still visible;
|
||||||
|
- port 25 accepting mail widens the attack surface (off by default);
|
||||||
|
- semver: if the contract turns out incompatible (ports, backup, behaviour with
|
||||||
|
the flag off) a major `2.x` is possible; the decision comes after the
|
||||||
|
implementation.
|
||||||
|
|
||||||
|
**External deployment dependency:** the optional anti-spam container — outside
|
||||||
|
the SelfPost image, brought up by the operator when the option is enabled.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
A finished outbound path (already implemented). Agreement obtained — see the
|
||||||
|
status above.
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Plan: queue-retries (Postfix retry policy in the panel)
|
||||||
|
|
||||||
|
**Status:** agreed
|
||||||
|
**Date:** 2026-08-13
|
||||||
|
**Version:** patch; no schema, no configuration surface.
|
||||||
|
**Order:** small panel item; does not wait on inbound-relay.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Show the operator how Postfix retries deferred mail: first retry delay, later
|
||||||
|
backoff cap, and how long a message stays in the queue before it bounces. The
|
||||||
|
numbers come from this container's effective Postfix config, not from
|
||||||
|
hard-coded copy.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In:**
|
||||||
|
|
||||||
|
- A static «How delivery retries work» card on `/mail-queue` (global
|
||||||
|
administrator). Not inside the HTMX poll fragment — the snapshot is taken at
|
||||||
|
panel start.
|
||||||
|
- The same human-readable intervals in `/deliveries/{id}` history for
|
||||||
|
`deferred` and `bounced` (domain administrators never see Mail queue).
|
||||||
|
- Operator docs: [guide.md](../guide.md) Mail queue bullet;
|
||||||
|
[architecture.md](../architecture.md) notes the one-shot `postconf -h` at
|
||||||
|
panel start. [CHANGELOG.md](../../CHANGELOG.md) `### Added`.
|
||||||
|
|
||||||
|
**Out:**
|
||||||
|
|
||||||
|
- Changing Postfix retry parameters, or exposing them as panel settings
|
||||||
|
([product.md](../product.md): Postfix is used as-is).
|
||||||
|
- An `attempts` column on `send_log`, or «attempt 3 of N» — Postfix has no
|
||||||
|
attempt budget; it is time-based.
|
||||||
|
- Counting `status=deferred` lines in `mail.log` or reading `postcat`. Each
|
||||||
|
attempt is already on the delivery page's log table; the journal stores only
|
||||||
|
the last status.
|
||||||
|
- Duplicating the card on Status (summary + link to Mail queue already exist).
|
||||||
|
- Re-reading `postconf` on every HTTP request.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Acceptance is still synchronous SMTP. Delivery stays in Postfix's on-disk
|
||||||
|
queue. SelfPost does not enqueue, retry, or deliver.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
smtpAccept["SMTP 250 accepted"] --> postfixQueue["Postfix queue"]
|
||||||
|
postfixQueue -->|"deferred: backoff until queue lifetime"| retry["Retry MX"]
|
||||||
|
retry -->|sent| done["sent"]
|
||||||
|
retry -->|lifetime elapsed or 5xx| bounce["bounced"]
|
||||||
|
postfixQueue --> mailQueuePage["/mail-queue policy card"]
|
||||||
|
postfixQueue --> deliveryPage["/deliveries/id history copy"]
|
||||||
|
```
|
||||||
|
|
||||||
|
[build/postfix-config.sh](../../build/postfix-config.sh) does not set
|
||||||
|
`queue_run_delay`, `minimal_backoff_time`, `maximal_backoff_time`,
|
||||||
|
`maximal_queue_lifetime`, `bounce_queue_lifetime`, or `delay_warning_time`.
|
||||||
|
Debian/Postfix 3.x compiled-in defaults therefore apply unless the operator
|
||||||
|
overrides them (`postconf -e` inside the container).
|
||||||
|
|
||||||
|
### Loading the numbers
|
||||||
|
|
||||||
|
Once, when the HTTP role starts ([cmd/panel/httpserver.go](../../cmd/panel/httpserver.go),
|
||||||
|
after `postfix-config.sh` has run):
|
||||||
|
|
||||||
|
```
|
||||||
|
postconf -h queue_run_delay minimal_backoff_time maximal_backoff_time maximal_queue_lifetime bounce_queue_lifetime delay_warning_time
|
||||||
|
```
|
||||||
|
|
||||||
|
`postconf -h`, not a parse of `/etc/postfix/main.cf`: stock values are not
|
||||||
|
written to the file. `postconf` is the effective config, including a manual
|
||||||
|
override.
|
||||||
|
|
||||||
|
- Fixed argv, no user input — same pattern as
|
||||||
|
[postfix.Queue](../../internal/postfix/queue.go) /
|
||||||
|
[security.md](../security.md).
|
||||||
|
- Cache on `handlers.Config` (via `web.Config`). The HTMX fragment does not
|
||||||
|
call `postconf`.
|
||||||
|
- A live `postconf -e` is visible after the next panel (or container) restart.
|
||||||
|
While the process is up, the panel shows the start-up snapshot.
|
||||||
|
- Parse Postfix time units (`300s`, `5d`, `1h`, a bare number is seconds) in
|
||||||
|
`internal/postfix`. Format human strings (`5 minutes`, `5 days`,
|
||||||
|
`about 1 hour 7 minutes`) in one place so the Mail queue card and
|
||||||
|
`deliveryEvents` cannot drift.
|
||||||
|
- If `postconf` is missing (unit tests on Windows, binary outside the
|
||||||
|
container): log a warning, fall back to Postfix 3.x compiled-in defaults
|
||||||
|
(`300s` / `4000s` / `5d` / `0`), and put a muted note on the card. Tests
|
||||||
|
stub the lookup (as `queueIDs` in the log-tailer) or pass a fixture on
|
||||||
|
`Config`. Do not fail panel start.
|
||||||
|
|
||||||
|
Typical stock values, for orientation only — the UI prints whatever
|
||||||
|
`postconf` returned:
|
||||||
|
|
||||||
|
| Parameter | Stock | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `queue_run_delay` / `minimal_backoff_time` | `300s` | First retry and deferred-queue scan |
|
||||||
|
| `maximal_backoff_time` | `4000s` | Cap on the doubling gap (~1 h 7 min) |
|
||||||
|
| `maximal_queue_lifetime` | `5d` | Then bounce |
|
||||||
|
| `delay_warning_time` | `0` | No delay warning to the sender |
|
||||||
|
|
||||||
|
## Panel copy
|
||||||
|
|
||||||
|
Mail queue card facts: first retry; later retries (doubling, capped);
|
||||||
|
kept in queue; then bounced. Short prose: there is no fixed attempt count; a
|
||||||
|
`deferred` message stays in this listing until it is delivered or the queue
|
||||||
|
lifetime runs out.
|
||||||
|
|
||||||
|
`deliveryEvents(row, policy)`:
|
||||||
|
|
||||||
|
- `deferred`: retries, first after X, then with increasing gaps up to Y, for
|
||||||
|
up to Z.
|
||||||
|
- `bounced`: or Postfix gave up after Z in the queue.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Duration parser: `5d`, `300s`, `4000s`, `1h`, `0`, bare number.
|
||||||
|
- `/mail-queue` handler: card shows the fixture policy's human strings, not a
|
||||||
|
live `postconf`.
|
||||||
|
- Delivery page / `deliveryEvents`: `deferred` and `bounced` contain those
|
||||||
|
strings ([handlers_monitor_test.go](../../internal/web/handlers/handlers_monitor_test.go)).
|
||||||
|
- [templates_test.go](../../internal/web/view/templates_test.go): pass the new
|
||||||
|
fields if rendering `mail_queue` requires them.
|
||||||
|
|
||||||
|
`go test` / `go vet` on the touched packages.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- `/mail-queue` states this Postfix's first retry, backoff cap, and queue
|
||||||
|
lifetime.
|
||||||
|
- A `deferred` / `bounced` delivery page uses the same intervals.
|
||||||
|
- A manual `postconf -e maximal_queue_lifetime=2d` followed by a panel restart
|
||||||
|
changes what the panel prints.
|
||||||
|
- Guide and architecture describe the snapshot; CHANGELOG has an Added entry.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Showing compiled-in fallbacks when `postconf` failed would mislead if the
|
||||||
|
operator had overridden them — mitigate with the muted note on the card.
|
||||||
|
- Inventing a max-attempt count would be false; the copy must stay time-based.
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Plan: send-log-retention
|
||||||
|
|
||||||
|
**Status:** candidate
|
||||||
|
**Date:** 2026-08-17
|
||||||
|
**Version:** `1.x` MINOR; no schema migration required (uses existing `settings` table).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let the **global administrator** change how long delivery journal rows
|
||||||
|
(`send_log`, `/deliveries`) are kept, from the panel — without editing `.env`.
|
||||||
|
|
||||||
|
## Context (as-built)
|
||||||
|
|
||||||
|
Retention **already exists**, but only via environment:
|
||||||
|
|
||||||
|
- `SEND_LOG_RETENTION_DAYS` (default **90**) in `.env` / Compose.
|
||||||
|
- [`cmd/panel/main.go`](../../cmd/panel/main.go) passes it to
|
||||||
|
[`logtail.Run`](../../internal/logtail/logtail.go).
|
||||||
|
- [`retentionLoop`](../../internal/logtail/logtail.go) prunes via
|
||||||
|
[`DeleteSendLogBefore`](../../internal/store/sendlog.go) every **6 hours**.
|
||||||
|
- No panel control; [`handlers_monitor.go`](../../internal/web/handlers/handlers_monitor.go)
|
||||||
|
hardcodes «ninety days» in copy.
|
||||||
|
- Migration `0001_init.sql` describes `settings` as the place for «retention
|
||||||
|
overrides», but no UI writes that key yet.
|
||||||
|
|
||||||
|
This plan moves the **effective** retention into SQLite `settings`, with env as
|
||||||
|
bootstrap only.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In:**
|
||||||
|
|
||||||
|
- Settings card on `/settings` (global administrator only): **Send log
|
||||||
|
retention (days)**.
|
||||||
|
- Key `send_log_retention_days` in [`settings`](../../internal/store/settings.go).
|
||||||
|
- Validation: integer range **7–365** (exact bounds fixed at implementation).
|
||||||
|
- On first use: if setting missing, seed from env
|
||||||
|
(`SEND_LOG_RETENTION_DAYS`, default 90) at panel start or first save.
|
||||||
|
- Log-tailer reads the setting **each prune cycle** (no container restart).
|
||||||
|
- Delivery pages and guide copy show the **current** retention, not a hardcoded
|
||||||
|
90.
|
||||||
|
- Tests; [guide.md](../guide.md); [CHANGELOG.md](../../CHANGELOG.md).
|
||||||
|
|
||||||
|
**Out:**
|
||||||
|
|
||||||
|
- Per-domain retention (instance-wide only).
|
||||||
|
- `mail.log` rotation (logrotate, 14 daily files — unchanged).
|
||||||
|
- Immediate prune on save when lowering retention (next 6 h cycle is enough;
|
||||||
|
optional «Prune now» not in v1).
|
||||||
|
- Domain-admin access to this setting.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
settingsPage["/settings form"] --> sqlite["settings.send_log_retention_days"]
|
||||||
|
env["SEND_LOG_RETENTION_DAYS bootstrap"] --> sqlite
|
||||||
|
sqlite --> retentionLoop["logtail retentionLoop"]
|
||||||
|
retentionLoop --> prune["DeleteSendLogBefore"]
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Read path** — `GetSendLogRetentionDays()`: settings value if valid, else env
|
||||||
|
default.
|
||||||
|
2. **Write path** — POST `/settings` (global admin): validate, `SetSetting`,
|
||||||
|
flash confirmation.
|
||||||
|
3. **Prune path** — change [`logtail.retentionLoop`](../../internal/logtail/logtail.go)
|
||||||
|
to accept `func() int` or `RetentionReader` that queries settings each cycle
|
||||||
|
(same 6 h ticker).
|
||||||
|
4. **Copy** — inject retention days into delivery list/detail templates and
|
||||||
|
remove hardcoded «ninety days».
|
||||||
|
|
||||||
|
`SEND_LOG_RETENTION_DAYS` remains documented in [guide.md](../guide.md) as the
|
||||||
|
**initial default** until changed in Settings.
|
||||||
|
|
||||||
|
## Relation to domain-stats-auto-ratelimit
|
||||||
|
|
||||||
|
[domain-stats-auto-ratelimit](domain-stats-auto-ratelimit.md) uses a **30-day**
|
||||||
|
stats window. Requires effective retention ≥ 30 for full accuracy. When
|
||||||
|
retention < 30:
|
||||||
|
|
||||||
|
- Stats UI shows a warning and uses `min(30, retention)` as the window, or
|
||||||
|
- Settings validation warns when saving a value below 30 while stats/auto are
|
||||||
|
enabled (pick one at implementation; document in guide).
|
||||||
|
|
||||||
|
Recommended roadmap order: **send-log-retention** before or parallel with
|
||||||
|
domain-stats-auto-ratelimit.
|
||||||
|
|
||||||
|
## Panel UI
|
||||||
|
|
||||||
|
New card on [`settings.html`](../../internal/web/view/templates/settings.html)
|
||||||
|
(global admin block, near rate limits or under a «Deliveries» heading):
|
||||||
|
|
||||||
|
- Number input: retention days (7–365).
|
||||||
|
- Muted copy: rows older than this are deleted from `/deliveries`; main driver
|
||||||
|
of `/data` growth; does not affect `mail.log` rotation.
|
||||||
|
|
||||||
|
Domain administrators keep the narrow credentials-only settings page.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Save/load setting; reject out-of-range values.
|
||||||
|
- `retentionLoop` uses updated value without process restart (mock reader).
|
||||||
|
- Bootstrap: empty settings → env default used for prune.
|
||||||
|
- Template/delivery copy reflects configured days.
|
||||||
|
|
||||||
|
`go test` / `go vet` on touched packages.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- Global admin can set retention on `/settings`; value persists in SQLite.
|
||||||
|
- Prune uses the panel value on the next cycle; env remains bootstrap default.
|
||||||
|
- Guide documents panel vs env; CHANGELOG entry added.
|
||||||
|
- Hardcoded «ninety days» removed from delivery UI.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Operator lowers retention while bookmarking old delivery URLs — existing
|
||||||
|
behaviour; copy already notes pruned rows are gone.
|
||||||
|
- Settings change without restart — must be tested so log-tailer never keeps a
|
||||||
|
stale int from panel start only.
|
||||||
|
|
||||||
|
**Version:** `1.x` MINOR.
|
||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
# SelfPost — product boundaries
|
||||||
|
|
||||||
|
**What this file is.** Stable product definition for SelfPost v1.0: purpose,
|
||||||
|
deployment assumptions, explicit out-of-scope items, and the multi-domain
|
||||||
|
model. User-facing overview in [README.md](../README.md); install and operations
|
||||||
|
in [guide.md](guide.md); as-built technical detail in
|
||||||
|
[architecture.md](architecture.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
SelfPost is a self-hosted **outbound SMTP relay** with a web control panel,
|
||||||
|
shipped as a single Docker image. It sends mail directly to the internet from
|
||||||
|
your own IP with per-domain DKIM signing.
|
||||||
|
|
||||||
|
**Primary workflow:** configure the relay once (domains, DKIM, SASL
|
||||||
|
applications), then point scripts and apps at the SMTP endpoint. SelfPost
|
||||||
|
delivers as the configured sending domain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment context (fixed assumptions)
|
||||||
|
|
||||||
|
These constraints are intentional; changing them requires an explicit product
|
||||||
|
decision:
|
||||||
|
|
||||||
|
1. **VPS or home server** — one image for both. Raspberry Pi and similar boards
|
||||||
|
are not a current target but not ruled out forever.
|
||||||
|
2. **Send from your own IP (DIY)** — no intermediate relay provider. Requires
|
||||||
|
outbound port 25, static IP, and configurable PTR/rDNS from the operator.
|
||||||
|
3. **Single container** — Postfix, OpenDKIM, and the panel run under one
|
||||||
|
`supervisord` inside one image.
|
||||||
|
4. **Panel is internet-facing** — mandatory security requirements in
|
||||||
|
[security.md](security.md).
|
||||||
|
|
||||||
|
**Infrastructure the operator provides (not SelfPost features):** unblocked
|
||||||
|
outbound TCP 25, static IP, PTR/rDNS, acceptable IP reputation. SelfPost does
|
||||||
|
not detect, bypass, or compensate for missing prerequisites; mail simply fails
|
||||||
|
to deliver when they are absent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Explicitly excluded to prevent scope creep:
|
||||||
|
|
||||||
|
- Inbound mail (IMAP/POP3, mailboxes, delivery to user inboxes)
|
||||||
|
- Webmail
|
||||||
|
- Organisations / tenancy beyond global + domain-admin roles; managing
|
||||||
|
**multiple sending domains** under one global administrator is in scope (see
|
||||||
|
below)
|
||||||
|
- Inbound antispam/antivirus (rspamd, ClamAV, etc.)
|
||||||
|
- A custom MTA — Postfix is used as-is
|
||||||
|
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only
|
||||||
|
|
||||||
|
The **domain-admin** role ships in the current line (global administrator plus
|
||||||
|
domain administrators with assigned domains). The optional **inbound relay** is
|
||||||
|
the main agreed **1.x+** extension still on the
|
||||||
|
[roadmap](roadmap.md) — it targets a 1.x MINOR bump by default; a 2.x major
|
||||||
|
remains possible pending implementation. Items marked *candidate* in the
|
||||||
|
roadmap require explicit approval before coding (including **send-log retention
|
||||||
|
in Settings**, **30-day send statistics**, and **auto level-2 rate limits** —
|
||||||
|
see linked plans there).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Multi-domain model
|
||||||
|
|
||||||
|
SelfPost is a **multi-domain outbound relay**. Two linked entities:
|
||||||
|
|
||||||
|
### Sending domain
|
||||||
|
|
||||||
|
Example: `example.com`. Has its own DKIM key and selector.
|
||||||
|
|
||||||
|
### Application (account)
|
||||||
|
|
||||||
|
A SASL login/password bound to **one** domain. A domain may have several
|
||||||
|
applications (e.g. newsletter vs alerts), each with its own credentials, but
|
||||||
|
each may send **only from its own domain**, never from another.
|
||||||
|
|
||||||
|
### From-address mode (per application)
|
||||||
|
|
||||||
|
Set when creating or editing an application:
|
||||||
|
|
||||||
|
1. **Any address in the domain** — `*@example.com` (wildcard in
|
||||||
|
`smtpd_sender_login_maps`).
|
||||||
|
2. **Explicit address list** — only listed From addresses within the domain;
|
||||||
|
anything else is rejected even if it belongs to the same domain.
|
||||||
|
|
||||||
|
In both modes the From address must belong to the application's domain.
|
||||||
|
|
||||||
|
### What the panel manages per domain
|
||||||
|
|
||||||
|
- DKIM key + selector (shared by all applications on that domain)
|
||||||
|
- One or more applications (SASL credentials + From mode + optional rate limits)
|
||||||
|
- DNS guidance (DKIM TXT; reminders for SPF/DMARC)
|
||||||
|
|
||||||
|
Adding a domain does **not** create an application automatically.
|
||||||
|
|
||||||
|
### Lifecycle
|
||||||
|
|
||||||
|
- **Add domain** — record, DKIM key, DNS instructions; no application yet.
|
||||||
|
- **Add application** — SASL pair (password shown once), From mode, map entries,
|
||||||
|
`postfix reload`.
|
||||||
|
- **Delete domain** — removes DKIM key and **all** its applications.
|
||||||
|
- **Delete application** — removes only that app's SASL and map entries.
|
||||||
|
|
||||||
|
This is not multi-tenancy; it is one owner (or a small team with global and
|
||||||
|
domain-scoped roles) operating several sending domains with independent
|
||||||
|
application credentials.
|
||||||
+237
@@ -0,0 +1,237 @@
|
|||||||
|
# Roadmap: open work (1.x+)
|
||||||
|
|
||||||
|
**Status:** a working tracker of extensions to the v1.0 boundary, each taken up
|
||||||
|
only after explicit agreement ([product.md](product.md),
|
||||||
|
[.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)). Detailed
|
||||||
|
design lives in [plans/](plans/). Items marked `candidate` need an OK before
|
||||||
|
any code is written.
|
||||||
|
|
||||||
|
**Reading this from outside the project:** nothing here is a commitment or a
|
||||||
|
release promise. There are no dates, the order is a recommendation rather than
|
||||||
|
a schedule, and an item can be dropped or reshaped once its plan is written.
|
||||||
|
What the project *will not* do is a separate question, answered in
|
||||||
|
[product.md](product.md) — an item's absence from this file does not mean it is
|
||||||
|
planned but unlisted.
|
||||||
|
|
||||||
|
**Versioning:** SemVer MINOR in the **1.x+** line by default (`1.1.0`…), as long
|
||||||
|
as defaults and migrations stay compatible with `1.0.0`. A major `2.x` only for
|
||||||
|
an explicit break. One such break, when 2.x is cut for any reason, is
|
||||||
|
[schema-squash](#schema-squash) — replacing the 1.x SQLite migration chain
|
||||||
|
with a baseline. That item does not by itself justify a major.
|
||||||
|
|
||||||
|
**Process:** [development.md](development.md). The history of closed phases is
|
||||||
|
in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Index
|
||||||
|
|
||||||
|
| ID | Topic | Status | Plan |
|
||||||
|
|---|---|---|---|
|
||||||
|
| queue-retries | Postfix retry policy in the panel (queue lifetime, backoff) | **agreed** | [plans/queue-retries.md](plans/queue-retries.md) |
|
||||||
|
| inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | [plans/inbound-relay.md](plans/inbound-relay.md) |
|
||||||
|
| send-log-retention | Send-log retention days in panel Settings | candidate | [plans/send-log-retention.md](plans/send-log-retention.md) |
|
||||||
|
| domain-stats-auto-ratelimit | 30-day send stats + auto level-2 rate limit | candidate | [plans/domain-stats-auto-ratelimit.md](plans/domain-stats-auto-ratelimit.md) |
|
||||||
|
| contributing | `CONTRIBUTING.md` | candidate | — |
|
||||||
|
| dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | [plans/dmarc-reports.md](plans/dmarc-reports.md) |
|
||||||
|
| panel-docs | In-panel operator documentation | candidate | — |
|
||||||
|
| schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — |
|
||||||
|
|
||||||
|
**Recommended order** (not binding): **queue-retries** is a small panel item
|
||||||
|
that can land first or in parallel; the next feature is **inbound-relay**.
|
||||||
|
**send-log-retention** can land before or beside **domain-stats-auto-ratelimit**
|
||||||
|
(panel retention ≥ 30 days helps the stats window). The 2026-08-13 full-tree
|
||||||
|
review follow-ups (send-log authorization,
|
||||||
|
fail-closed paths, docs, GUI, tests, licence) are closed — history in
|
||||||
|
[CHANGELOG.md](../CHANGELOG.md) `[Unreleased]` and git. Candidates need
|
||||||
|
explicit agreement before they join the queue.
|
||||||
|
|
||||||
|
After a context reset, pick an item marked `agreed` or `in progress`, then work
|
||||||
|
the checklist in its linked plan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## queue-retries
|
||||||
|
|
||||||
|
**Goal:** show on Mail queue and on a delivery's history how this Postfix
|
||||||
|
retries deferred mail — first delay, backoff cap, queue lifetime — reading
|
||||||
|
the effective config (`postconf -h`) once at panel start so a manual
|
||||||
|
override is visible.
|
||||||
|
|
||||||
|
**Boundary:** explanation only. Postfix stays as-is; no attempt counter, no
|
||||||
|
panel knobs for queue lifetime, no schema change. Domain administrators see
|
||||||
|
the intervals on `/deliveries/{id}` (they cannot open Mail queue).
|
||||||
|
|
||||||
|
**Done when:** see the criteria in
|
||||||
|
[plans/queue-retries.md](plans/queue-retries.md).
|
||||||
|
|
||||||
|
**Dependencies / risks:** `postconf` unavailable outside the container
|
||||||
|
(fallback + muted note). Copy must stay time-based — Postfix has no max
|
||||||
|
attempt count.
|
||||||
|
**Version:** patch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## inbound-relay
|
||||||
|
|
||||||
|
**Goal:** optional acceptance of mail on port 25 for explicitly configured
|
||||||
|
domains, forwarded to an upstream (backup-MX / relay-forwarder). Off by default
|
||||||
|
(`INBOUND_RELAY_ENABLE=false`); without the flag the outbound path is
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
**Boundary:** an extension of v1.0 — [product.md](product.md) excludes inbound
|
||||||
|
mail and mailboxes. This is relay/forward, not IMAP/POP3/webmail; an anti-spam
|
||||||
|
engine stays outside the image, only the attachment point is provided.
|
||||||
|
|
||||||
|
**Done when:** see the criteria in
|
||||||
|
[plans/inbound-relay.md](plans/inbound-relay.md).
|
||||||
|
|
||||||
|
**Dependencies / risks:** a finished outbound path; open relay and backscatter;
|
||||||
|
a wider attack surface (port 25 accepting mail).
|
||||||
|
**Version:** target bump `1.x`; `2.x` possible — to be settled once the
|
||||||
|
implementation lands.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## send-log-retention
|
||||||
|
|
||||||
|
**Goal:** global administrator sets how many days of delivery journal rows
|
||||||
|
(`send_log`, `/deliveries`) are kept, from `/settings` — stored in SQLite
|
||||||
|
`settings`, with `SEND_LOG_RETENTION_DAYS` as bootstrap default only.
|
||||||
|
|
||||||
|
**Boundary:** instance-wide retention; not per-domain. Does not change
|
||||||
|
`mail.log` rotation. Today retention is env-only (default 90 days); the panel
|
||||||
|
has no control.
|
||||||
|
|
||||||
|
**Done when:** see [plans/send-log-retention.md](plans/send-log-retention.md).
|
||||||
|
|
||||||
|
**Dependencies / risks:** log-tailer must re-read settings each prune cycle;
|
||||||
|
delivery UI must stop hardcoding «ninety days».
|
||||||
|
**Version:** `1.x` MINOR; `candidate` until explicitly agreed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## domain-stats-auto-ratelimit
|
||||||
|
|
||||||
|
**Goal:** 30-day sending statistics per domain and application (total, peak and
|
||||||
|
average msg/h) on the domain page, plus optional **auto** level-2 rate limits
|
||||||
|
computed as `ceil(avg_hourly × multiplier)` over the level-1 window.
|
||||||
|
|
||||||
|
**Boundary:** extension of v1.0 manual L2 limits; does not change Postfix
|
||||||
|
level 1. Stats exclude level-1 refusals (not in send log). Auto with zero
|
||||||
|
traffic stays inactive.
|
||||||
|
|
||||||
|
**Done when:** see
|
||||||
|
[plans/domain-stats-auto-ratelimit.md](plans/domain-stats-auto-ratelimit.md).
|
||||||
|
|
||||||
|
**Dependencies / risks:** send log data and preferably
|
||||||
|
[send-log-retention](#send-log-retention) ≥ 30 days; aggregate query cost on
|
||||||
|
large logs; rate-limit path needs security review.
|
||||||
|
**Version:** `1.x` MINOR; `candidate` until explicitly agreed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## contributing
|
||||||
|
|
||||||
|
**Goal:** `CONTRIBUTING.md` in the root — the dev loop, the checks to run
|
||||||
|
before a PR, the commit protocol; [development.md](development.md) links to it
|
||||||
|
rather than repeating it.
|
||||||
|
|
||||||
|
**Boundary:** process documentation; worth writing once there is an external
|
||||||
|
flow of PRs.
|
||||||
|
|
||||||
|
**Done when:** the file is in the root and development.md does not duplicate
|
||||||
|
it.
|
||||||
|
|
||||||
|
**Dependencies / risks:** with a single developer and no PRs, this is low
|
||||||
|
priority.
|
||||||
|
**Version:** no bearing on semver.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## dmarc-reports
|
||||||
|
|
||||||
|
**Goal:** SelfPost receives DMARC aggregate reports (RFC 7489) on SMTP,
|
||||||
|
parses the gzip/XML payloads, and shows pass/fail summaries in the panel — so
|
||||||
|
the operator does not need an external DMARC service or a separate mailbox
|
||||||
|
workflow.
|
||||||
|
|
||||||
|
**Boundary:** an extension of v1.0 — not IMAP/webmail and not a general
|
||||||
|
inbound relay. A dedicated inbound path for report messages only; forensic
|
||||||
|
reports (`ruf=`) out of scope for v1.
|
||||||
|
|
||||||
|
**Done when:** see [plans/dmarc-reports.md](plans/dmarc-reports.md).
|
||||||
|
|
||||||
|
**Dependencies / risks:** inbound SMTP in the image (may share infrastructure
|
||||||
|
with [inbound-relay](plans/inbound-relay.md) but must not require backup-MX);
|
||||||
|
storage and retention of parsed summaries; the `dmarc_report_email` setting
|
||||||
|
(migration `0005` moved it off the old `admin` table into `settings`) and
|
||||||
|
`domains.dmarc_rua` added in the DMARC template work must stay the source of
|
||||||
|
truth for `rua=` in DNS guidance.
|
||||||
|
|
||||||
|
**Order:** after the DMARC `rua=` settings ship; may follow or overlap with
|
||||||
|
inbound-relay depending on how port 25 acceptance is structured.
|
||||||
|
|
||||||
|
**Version:** `1.x` MINOR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## panel-docs
|
||||||
|
|
||||||
|
**Goal:** built-in operator documentation in the panel — short pages (or a
|
||||||
|
help drawer) that explain what each Status check and other controls mean,
|
||||||
|
without sending the operator out to `docs/guide.md`.
|
||||||
|
|
||||||
|
**Boundary:** in-panel help only; not a second copy of the full operator guide.
|
||||||
|
Seed content includes the Status blurbs removed from the cards in favour of a
|
||||||
|
denser layout — Machine (kernel counters / rate window), TLS certificate
|
||||||
|
(port 465, reverse-proxy mount), Hostname / reverse DNS (forward-confirmed
|
||||||
|
reverse DNS, PTR at the hosting provider), and similar notes for other panel
|
||||||
|
surfaces as they lose inline commentary.
|
||||||
|
|
||||||
|
**Done when:** an operator can open help from the panel for those topics; the
|
||||||
|
removed Status blurbs are preserved there (or equivalent); no requirement to
|
||||||
|
read the git tree for day-to-day meaning of a card.
|
||||||
|
|
||||||
|
**Dependencies / risks:** copy ownership and translation; keeping help in sync
|
||||||
|
when checks change; not bloating every page with a second column of prose.
|
||||||
|
|
||||||
|
**Version:** `1.x` MINOR; `candidate` until explicitly agreed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## schema-squash
|
||||||
|
|
||||||
|
**Goal:** when 2.x is cut, stop shipping the 1.x migration files
|
||||||
|
(`0001_init.sql` … `0005_panel_users.sql`) in the binary and replace them with
|
||||||
|
one baseline that is the schema as of `user_version = 5`. Fresh 2.x data
|
||||||
|
directories no longer create-then-drop the historical `admin` table.
|
||||||
|
|
||||||
|
**Boundary:** 1.x keeps the full chain so a 1.0.0 data directory still boots.
|
||||||
|
Do not delete, rename, or reorder those files while MINOR compatibility with
|
||||||
|
`1.0.0` holds. `migrate()` maps **file order** to `PRAGMA user_version` (`target
|
||||||
|
= i + 1`); dropping a file in 1.x would skip or mis-apply steps on existing
|
||||||
|
databases. Git history keeps the old files either way; only the embedded set
|
||||||
|
in the 2.x image changes.
|
||||||
|
|
||||||
|
**Upgrade gate (required with the squash):**
|
||||||
|
|
||||||
|
| `user_version` | 2.x behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `0` (empty file) | Apply the baseline; set `user_version` to the new chain’s head |
|
||||||
|
| `>= 5` (fully migrated 1.x) | Skip; schema is already the baseline |
|
||||||
|
| `1`…`4` (mid-chain 1.x) | **Refuse to start** — boot the last 1.x once, then 2.x |
|
||||||
|
|
||||||
|
Restore remains a separate lock: the backup manifest version must match the
|
||||||
|
running binary ([architecture.md](architecture.md) § Persistence). It does not
|
||||||
|
replace this gate.
|
||||||
|
|
||||||
|
**Done when:** 2.x embeds a single baseline (plus any 2.x-only migrations after
|
||||||
|
it); the gate above is tested; the operator guide says a 2.x image will not
|
||||||
|
open an unfinished 1.x database.
|
||||||
|
|
||||||
|
**Dependencies / risks:** a decided 2.x cut (another breaking change, or an
|
||||||
|
explicit major). Squashing five short files is not a reason to cut 2.x on its
|
||||||
|
own. A missed gate leaves a `user_version = 3` database silently stuck.
|
||||||
|
**Version:** `2.x` major only; not a 1.x item.
|
||||||
|
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
# Security
|
||||||
|
|
||||||
|
**What is here.** (1) **Mandatory requirements** — the checklist v1.0 has to
|
||||||
|
meet; the full v1.0 audit passed. The pre-release review (plan § D, 2026-08-06)
|
||||||
|
covered the whole diff from the v1.0 audit (Phase 11) to HEAD and the checklist
|
||||||
|
in full: no exploitable findings; one defence-in-depth change — `--` before the
|
||||||
|
login in the `saslpasswd2` argv
|
||||||
|
([internal/app/sasl.go](../internal/app/sasl.go)). The 2026-08-14 review
|
||||||
|
(code-review plan § P7, Fable; reviewer ≠ author) covered the P0–P1 diff of the
|
||||||
|
2026-08-13 full-tree review against this document — send-log authorization for
|
||||||
|
domain administrators, the atomic level-2 admit (`tryAdmit`), fail-closed
|
||||||
|
session creation, and application-delete ordering: no findings, and nothing
|
||||||
|
needed adding to the accepted risks. (2) **Accepted risks** —
|
||||||
|
deliberate departures beyond the mandatory, recorded so the decision is not
|
||||||
|
lost.
|
||||||
|
|
||||||
|
Hardening beyond the mandatory (security headers, origin checking, `__Host-`
|
||||||
|
cookie with duplicate detection — Phase 14) is done; the history is in
|
||||||
|
[CHANGELOG.md](../CHANGELOG.md) and `git log`.
|
||||||
|
|
||||||
|
Product boundaries: [product.md](product.md). As-built design:
|
||||||
|
[architecture.md](architecture.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mandatory requirements
|
||||||
|
|
||||||
|
The panel is exposed to the internet — the items below are **not optional**.
|
||||||
|
|
||||||
|
### First-run administrator setup
|
||||||
|
|
||||||
|
- A one-time secret link `/setup/<token>`, **not** an env variable holding a
|
||||||
|
ready-made password hash.
|
||||||
|
- Token ≥128 bits (`crypto/rand`); mirrored to `/data/setup-token`.
|
||||||
|
- Token lifetime — **10 minutes**; after expiry, or after a restart with setup
|
||||||
|
unfinished, it is regenerated and logged again.
|
||||||
|
- Rate limiting on `/setup/<token>` per IP, separate from login.
|
||||||
|
- Token comparison is **constant-time** (`subtle.ConstantTimeCompare`).
|
||||||
|
- Failed attempts do **not** invalidate the token early (protects setup from
|
||||||
|
being DoS-ed).
|
||||||
|
- Once the administrator exists the token is void forever, `/setup/*` → 404.
|
||||||
|
- The administrator password is bcrypt in SQLite only; no plaintext and no
|
||||||
|
MD5.
|
||||||
|
- `PANEL_USERNAME` / `PANEL_PASSWORD_HASH` in env are **not used**.
|
||||||
|
|
||||||
|
### Application SASL passwords
|
||||||
|
|
||||||
|
- The panel **generates** the password on creation or reissue and shows it
|
||||||
|
**once**.
|
||||||
|
- In `sasldb2` it is stored in the form SASL requires (not plaintext held by the
|
||||||
|
panel); a lost password can only be reissued.
|
||||||
|
|
||||||
|
### Input and configuration
|
||||||
|
|
||||||
|
- Server-side validation of addresses and domains (character whitelist);
|
||||||
|
client-side validation does not count as protection.
|
||||||
|
- In address-list mode every address is checked to belong to the application's
|
||||||
|
domain before it is written.
|
||||||
|
- `postfix reload` and any `exec` run **without** shell interpolation of user
|
||||||
|
input; arguments are passed as separate elements.
|
||||||
|
- Writes to config files are escaped (no injection of Postfix directives).
|
||||||
|
|
||||||
|
### Authentication and sessions
|
||||||
|
|
||||||
|
- Rate limiting on login (per IP, with lockout or delay).
|
||||||
|
- Sessions: cryptographically random token; cookie `HttpOnly`, `Secure`,
|
||||||
|
`SameSite`.
|
||||||
|
- Sessions live in SQLite (SHA-256 of the token, not the token itself); sliding
|
||||||
|
idle timeout (`PANEL_SESSION_IDLE_DAYS`).
|
||||||
|
|
||||||
|
### Output and process
|
||||||
|
|
||||||
|
- Rendering goes through `html/template` with auto-escaping (queue, log,
|
||||||
|
journal, themes).
|
||||||
|
- The panel process is **not root** (`user=panel` in supervisord); path access
|
||||||
|
is granted through the `selfpost` group with minimal permissions.
|
||||||
|
|
||||||
|
### Mail path (security-relevant)
|
||||||
|
|
||||||
|
- **Not an open relay** — SASL only; `reject_unauth_destination`;
|
||||||
|
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
|
||||||
|
- TLS is mandatory before credentials are transmitted (465 wrapper / 587
|
||||||
|
`encrypt`).
|
||||||
|
- `TRUSTED_PROXY_CIDR` — only explicitly trusted proxies may supply
|
||||||
|
`X-Forwarded-For` for login rate limiting; empty means XFF is ignored.
|
||||||
|
|
||||||
|
### Backup and domain export
|
||||||
|
|
||||||
|
- Both files are secrets: a full backup carries DKIM keys, `sasldb2`, the
|
||||||
|
administrator's password hash, `docker-compose.yml`, `.env`, and the TLS
|
||||||
|
private key from `certs/` when present; a domain export carries the DKIM key
|
||||||
|
and **working** application passwords in the clear (otherwise a transfer
|
||||||
|
without recreating credentials would be impossible).
|
||||||
|
- Both downloads can be encrypted with a password (a checkbox on the form):
|
||||||
|
scrypt (N=2¹⁵, r=8, p=1) → AES-256-GCM, streamed in 64 KiB chunks, each
|
||||||
|
authenticated with the header, the chunk number, and an end-of-stream flag —
|
||||||
|
a truncated or substituted file fails to open instead of silently restoring a
|
||||||
|
partial "tail". Format and wrapper:
|
||||||
|
[internal/secretfile](../internal/secretfile/secretfile.go).
|
||||||
|
- Extensions: `.spbk` (**S**elf**P**ost **b**ac**k**up — full backup), `.spde`
|
||||||
|
(**S**elf**P**ost **d**omain **e**xport — domain export); unencrypted files
|
||||||
|
stay `.tar.gz` / `.json`. Domain import detects encryption by the file's magic
|
||||||
|
bytes, not by extension.
|
||||||
|
- The password is never stored: without it the file cannot be recovered. In the
|
||||||
|
CLI the password comes only from `SELFPOST_BACKUP_PASSWORD` or
|
||||||
|
`-password-file`, never as an argument (the process list is readable by any
|
||||||
|
process in the container).
|
||||||
|
- Minimum password length matches the administrator password (12): the file
|
||||||
|
sits offline and can be attacked without a time limit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accepted risks
|
||||||
|
|
||||||
|
An accepted risk is a decision with a condition for revisiting it, not a
|
||||||
|
deferred item from the roadmap.
|
||||||
|
|
||||||
|
- **A `POST` with neither `Sec-Fetch-Site` nor `Origin` is allowed through.**
|
||||||
|
A client that sends neither — a genuinely old browser, or a webview with a
|
||||||
|
frozen engine — stays vulnerable to CSRF from any site. Accepted
|
||||||
|
deliberately: every panel user (global or domain-admin) is an operator who
|
||||||
|
picks their own browser, not an untrusted party the panel needs to defend
|
||||||
|
against, and a strict mode would not "protect" such a client, it would
|
||||||
|
simply break the panel in it. Tightening is one line in `originAllowed`
|
||||||
|
([internal/web/security.go](../internal/web/security.go)): return `false`
|
||||||
|
instead of `true` in the "neither header present" branch.
|
||||||
|
- **Session-bound CSRF tokens are not implemented.** The origin check closes the
|
||||||
|
neighbouring-subdomain case but depends on browser behaviour; a token does
|
||||||
|
not. The price is a hidden field in roughly two dozen forms. The trigger to
|
||||||
|
revisit is a requirement for protection that holds regardless of the browser,
|
||||||
|
or a domain-admin population the global administrator does not fully trust
|
||||||
|
(see the ADR below). A token would not save the panel from XSS inside it
|
||||||
|
either: code executing in the panel's origin sends the request itself —
|
||||||
|
against that, `html/template` auto-escaping and CSP do the work, which is why
|
||||||
|
templates must contain no inline scripts and no inline styles.
|
||||||
|
- **Destructive-action confirmation (`data-confirm`) is JavaScript-only.**
|
||||||
|
Delete, regenerate-password, and clear-rate-limit forms carry a
|
||||||
|
`data-confirm` prompt handled entirely in
|
||||||
|
[panel.js](../internal/web/view/static/panel.js); with JavaScript disabled
|
||||||
|
or blocked the form submits immediately, exactly as it did before the
|
||||||
|
prompts existed. Accepted deliberately: the prompt is a mis-click guard,
|
||||||
|
not an authorization boundary — the same origin check and session/RBAC
|
||||||
|
gate every one of these `POST`s whether or not JavaScript ran. Progressive
|
||||||
|
enhancement means the panel must work with JavaScript off; a
|
||||||
|
server-rendered confirmation step would need a second page (or a `?confirm=1`
|
||||||
|
round trip) for every one of these forms, which is what
|
||||||
|
[`user_delete.html`](../internal/web/view/templates/user_delete.html) and
|
||||||
|
`domain_delete.html` already do for the two highest-blast-radius deletes.
|
||||||
|
- **Encrypting backups and exports is an option, not the default.** With the
|
||||||
|
checkbox cleared the file downloads in the clear, as in 1.0. Otherwise an
|
||||||
|
operator with nowhere to keep a password would lose the ability to take a
|
||||||
|
backup at all, and a permanently undecryptable archive is worse than an
|
||||||
|
unencrypted one: SelfPost does not store the password. The trigger to make
|
||||||
|
encryption mandatory is a second administrator (at which point "who
|
||||||
|
downloaded it" stops being one person).
|
||||||
|
- **A journal row left without delivery lines is closed as `bounced` rather
|
||||||
|
than left as it is.** The "forever `queued`" risk is gone: `mail.log` moved to
|
||||||
|
`/data/log/` and survives container recreation, and the log tailer keeps its
|
||||||
|
read position (`logtail_state`, migration `0003`), so the tail is read after a
|
||||||
|
start. What remains are rows whose delivery lines are lost for good (the log
|
||||||
|
rotated past 14 files while the panel was down, or was deleted): the
|
||||||
|
reconciliation against `postqueue -p` sees the message is not in the queue and
|
||||||
|
after a 2-minute grace marks it `bounced`. If the message did in fact go out,
|
||||||
|
the status is a false negative. Accepted deliberately: a delivery the panel
|
||||||
|
cannot confirm must not be shown as confirmed, and a permanent `queued` is
|
||||||
|
indistinguishable from "in flight right now". Reconciliation does not run
|
||||||
|
until the tailer has read the log to the end, and touches nothing if
|
||||||
|
`postqueue` is unreadable. See [architecture.md](architecture.md) § Log
|
||||||
|
tailer.
|
||||||
|
- **Access to `mail.log` from the unprivileged panel.** The `/data/log`
|
||||||
|
directory is `2750 postfix:selfpost` and the file is `0640`: `postlogd` (user
|
||||||
|
`postfix`) writes, the panel reads through the shared `selfpost` group, and
|
||||||
|
the file is inaccessible to others. The log holds envelope addresses and
|
||||||
|
client IPs, but neither message bodies nor headers; it is excluded from
|
||||||
|
backups (`log/` is skipped) so that a dump stays state rather than
|
||||||
|
diagnostics.
|
||||||
|
|
||||||
|
## ADR: CSRF via origin checking, without tokens
|
||||||
|
|
||||||
|
**Context.** The panel is forms (`POST`) with a cookie session — the classic
|
||||||
|
CSRF surface. What is needed is a way to tell a request from the panel's own
|
||||||
|
page apart from one initiated by a third-party site in a logged-in user's
|
||||||
|
browser. The panel is multi-user since 1.2.0 (a global administrator plus
|
||||||
|
zero or more domain-admin users, each scoped to their assigned domains), but
|
||||||
|
that is an authorization boundary (who can see or change what), not a change
|
||||||
|
to the CSRF threat: the attacker in scope here is still an external site
|
||||||
|
riding a legitimate user's cookie, not one panel user attacking another
|
||||||
|
through the browser.
|
||||||
|
|
||||||
|
**Decision.** `originAllowed` in
|
||||||
|
[internal/web/security.go](../internal/web/security.go) checks `Sec-Fetch-Site`
|
||||||
|
(when the browser sends it) or `Origin` (fallback) against the panel's host; a
|
||||||
|
request carrying neither header is **allowed through** rather than rejected.
|
||||||
|
There are no session-bound tokens embedded in forms. The check applies the same
|
||||||
|
way regardless of the requesting user's role.
|
||||||
|
|
||||||
|
**Why not tokens.** Cross-user CSRF is not the threat model here: a
|
||||||
|
domain-admin's browser sending a request still needs that domain-admin's own
|
||||||
|
cookie, so a token would not add a boundary between roles that the
|
||||||
|
authorization checks (`Principal.CanAccessDomain`,
|
||||||
|
[internal/web/auth/principal.go](../internal/web/auth/principal.go); route
|
||||||
|
gating in [internal/web/handlers/authz.go](../internal/web/handlers/authz.go))
|
||||||
|
don't already enforce. The remaining case is an external site making a
|
||||||
|
logged-in user's browser send a request, which the origin check covers without
|
||||||
|
touching a single template.
|
||||||
|
A token would need a hidden field in roughly two dozen forms and
|
||||||
|
synchronisation with every new form, and it would still not protect against
|
||||||
|
XSS inside the panel — code executing in the panel's origin reads the token
|
||||||
|
and sends the request itself. XSS is handled by `html/template` auto-escaping
|
||||||
|
and CSP, so that is a separate line of defence, not a CSRF token.
|
||||||
|
|
||||||
|
**Trade-off.** A client that sends neither `Sec-Fetch-Site` nor `Origin` (a
|
||||||
|
genuinely old browser, or a webview with a frozen engine) stays vulnerable — see
|
||||||
|
"Accepted risks" above. This is a deliberate choice not to break the panel in
|
||||||
|
such a client, at the price of a narrow residual surface.
|
||||||
|
|
||||||
|
**Revisit if:** a requirement appears for protection that does not depend on
|
||||||
|
browser behaviour, or domain-admin accounts stop being trusted operators (for
|
||||||
|
example, if a future release lets a global administrator invite domain-admins
|
||||||
|
whose browsers/devices are not vetted) — at that point cross-role request
|
||||||
|
forgery inside the panel would need its own analysis, separate from the
|
||||||
|
external-site case this ADR covers.
|
||||||
|
|
||||||
|
## How this list grows
|
||||||
|
|
||||||
|
The pre-release vulnerability review (history — CHANGELOG `[0.5.0]` Security)
|
||||||
|
closes every finding in one of two ways: a fix before the tag, or an entry here
|
||||||
|
with its rationale and its condition for revisiting, like the items above.
|
||||||
|
There is no third option ("we looked at it and moved on").
|
||||||
@@ -1,429 +0,0 @@
|
|||||||
# Техническое задание: SelfPost
|
|
||||||
|
|
||||||
**Версия:** 1.0
|
|
||||||
**Тип проекта:** self-hosted SMTP relay с веб-панелью управления
|
|
||||||
**Формат поставки:** один Docker-образ
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Цель и назначение
|
|
||||||
|
|
||||||
SelfPost — это self-hosted SMTP-релей для личного использования, предназначенный для **исходящей отправки** почты из скриптов, приложений и систем уведомлений. Релей отправляет почту напрямую в интернет с собственного IP, с подписью DKIM, и управляется через веб-панель.
|
|
||||||
|
|
||||||
**Ключевой сценарий использования:** пользователь настраивает релей один раз (домен, DKIM, разрешённые отправители), после чего его приложения/скрипты отправляют почту через SMTP-endpoint релея, а релей доставляет её получателям от имени настроенного домена.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Контекст и ограничения
|
|
||||||
|
|
||||||
Эти ограничения — результат предварительного анализа и **не подлежат пересмотру** без явного согласования:
|
|
||||||
|
|
||||||
1. **Развёртывание — VPS или домашний сервер** (одноплатники вроде Raspberry Pi пока вне рассмотрения, но не исключены в будущем). Оба варианта равноправны — образ один и тот же. Инфраструктурные предпосылки для отправки с собственного IP считаются обеспеченными оператором (см. заметку ниже).
|
|
||||||
2. **Отправка с собственного IP** (DIY), без промежуточного relay-провайдера. Требует, чтобы VPS-провайдер разблокировал порт 25 (обычно по тикету) и позволял настроить PTR/rDNS.
|
|
||||||
3. **Один контейнер.** Postfix, OpenDKIM и панель управления работают внутри одного образа под управлением process-supervisor. Это осознанное решение в пользу простоты развёртывания.
|
|
||||||
4. **Панель управления публично доступна** из интернета. Это накладывает обязательные требования к безопасности (раздел 7).
|
|
||||||
|
|
||||||
> **Предпосылки инфраструктуры (вне зоны ответственности проекта).** SelfPost исходит из того, что площадка (VPS или домашний сервер) уже обеспечивает условия для отправки с собственного IP: разблокированный исходящий порт 25, статический IP, настраиваемый PTR/rDNS и приемлемая репутация IP. Обеспечение этих условий — задача оператора при развёртывании, а не функция SelfPost. Проект не пытается их детектировать, обходить или компенсировать; при их отсутствии почта просто не будет доставляться, и это ожидаемо.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Что НЕ входит в объём (Out of Scope)
|
|
||||||
|
|
||||||
Явно исключено, чтобы не было scope creep:
|
|
||||||
|
|
||||||
- ❌ Приём входящей почты (IMAP/POP3, mailbox'ы, доставка в ящики)
|
|
||||||
- ❌ Веб-почта (webmail)
|
|
||||||
- ❌ Мультитенантность (организации, несколько **пользователей панели**, роли) — панель рассчитана на **одного** администратора. Управление несколькими отправляющими доменами — это НЕ мультитенантность и входит в объём (см. раздел 4.1).
|
|
||||||
- ❌ Антиспам/антивирус для входящей почты (rspamd, ClamAV)
|
|
||||||
- ❌ Реализация собственного SMTP-сервера или MTA — используется готовый Postfix
|
|
||||||
- ❌ Dovecot и любой полноценный mail-стек ради SASL — аутентификация делается на лёгком Cyrus SASL (`sasldb2`), см. раздел 5.1
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Архитектура
|
|
||||||
|
|
||||||
Единый Docker-образ на базе **Debian slim** (например, `debian:bookworm-slim`). Выбор зафиксирован в пользу простоты поддержки: glibc даёт более предсказуемое поведение DNS-резолвера при постоянных MX-lookup'ах (в отличие от musl в Alpine), а подавляющее большинство документации и примеров конфигов Postfix/OpenDKIM ориентировано на Debian/apt — что снижает риск ошибок, особенно при генерации кода агентом. Экономия размера на Alpine здесь непринципиальна (образ всё равно тянет Python ради supervisord, а деплой идёт на VPS/сервер, не на ресурсно-ограниченный одноплатник). Внутри — три процесса под управлением `supervisord`:
|
|
||||||
|
|
||||||
```
|
|
||||||
┌───────────────────────────────────────────────┐
|
|
||||||
│ Docker-контейнер SelfPost │
|
|
||||||
│ │
|
|
||||||
│ supervisord (PID 1) │
|
|
||||||
│ ├── postfix (start-fg) │
|
|
||||||
│ ├── opendkim (foreground) │
|
|
||||||
│ └── panel (Go-бинарник, один процесс, │
|
|
||||||
│ совмещает несколько ролей: │
|
|
||||||
│ • HTTP-сервер панели :8080 │
|
|
||||||
│ • journal-milter (приём :internal │
|
|
||||||
│ From/To/Subject/SASL-user на этапе │
|
|
||||||
│ DATA, см. 7.3) │
|
|
||||||
│ • log-tailer — горутина, следит за │
|
|
||||||
│ mail.log и обновляет статус доставки) │
|
|
||||||
│ │
|
|
||||||
│ Общая файловая система: │
|
|
||||||
│ /etc/postfix/... ← панель пишет │
|
|
||||||
│ /etc/opendkim/keys ← DKIM-ключи │
|
|
||||||
│ /data/selfpost.db ← SQLite: домены, │
|
|
||||||
│ приложения, администратор, журнал │
|
|
||||||
│ отправки (см. 7.3, 9) │
|
|
||||||
│ │
|
|
||||||
│ Порты: │
|
|
||||||
│ 465 (smtps, implicit TLS — основной, только │
|
|
||||||
│ SASL-аутентификация) │
|
|
||||||
│ 587 (submission, STARTTLS — опционально, │
|
|
||||||
│ добавляется по необходимости, раздел 5) │
|
|
||||||
│ 25 (исходящий в интернет) │
|
|
||||||
│ 8080 (панель, за reverse-proxy) │
|
|
||||||
└───────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**Взаимодействие панели и Postfix:** панель пишет конфиг-файлы напрямую в локальную ФС (`/etc/postfix/...`) и вызывает `postfix reload` как sibling-процесс. Поскольку всё в одном контейнере, inotify-вотчер и shared volumes между контейнерами **не нужны**.
|
|
||||||
|
|
||||||
**Milter-цепочка Postfix теперь состоит из двух шагов:** OpenDKIM (подпись) и journal-milter (логирование для журнала отправки, раздел 7.3) — оба сконфигурированы в `smtpd_milters`/`non_smtpd_milters`. Это остаётся в рамках «одного контейнера, трёх процессов под supervisord»: journal-milter — не новый процесс, а дополнительная роль внутри уже существующего panel-бинарника.
|
|
||||||
|
|
||||||
**Порядок старта — обязателен, чтобы исключить ошибки соединения при холодном старте контейнера.** `priority=` в supervisord задаёт только порядок отправки команд на запуск, а не готовность сокетов — сама по себе она не гарантирует, что milter-сокеты уже слушают к моменту, когда Postfix попытается к ним подключиться. Поэтому:
|
|
||||||
1. `priority`: OpenDKIM стартует первым, затем panel (включая journal-milter listener).
|
|
||||||
2. **Postfix оборачивается стартовым скриптом**, который блокируется в цикле опроса (например, `test -S <unix-сокет>` для unix-сокетов OpenDKIM/journal-milter, с интервалом и таймаутом — например, до 30 секунд), и запускает `postfix start-fg` только после того, как **оба** milter-сокета подтверждённо готовы.
|
|
||||||
3. **Если готовность не наступает в пределах таймаута** — скрипт завершается с ошибкой (не запускает Postfix «на всякий случай»), чтобы supervisord/Docker увидели явный сбой старта в логах, а не тихо продолжили с сервисом, который будет отклонять почту из-за недоступного milter'а.
|
|
||||||
4. Это решает только **проблему холодного старта** (первые секунды жизни контейнера) и не отменяет уже принятое поведение для **runtime**-сбоев после успешного старта: если OpenDKIM или journal-milter упадёт во время работы (не при старте), продолжает действовать fail-open, описанный в разделах 7.3/7.4 — это два разных сценария с разной правильной реакцией: на старте лучше подождать и не запускать Postfix «в дырявом» состоянии, а во время работы — не ронять уже идущий приём почты из-за вспомогательного компонента.
|
|
||||||
|
|
||||||
**Требование к supervisord:** если любой из трёх процессов падает и не может быть перезапущен, контейнер должен завершаться (а не оставаться «живым» с мёртвым Postfix), чтобы Docker с `restart: unless-stopped` корректно его перезапустил.
|
|
||||||
|
|
||||||
### 4.1. Доменная модель (ядро продукта)
|
|
||||||
|
|
||||||
SelfPost — **мультидоменный** релей. Две связанные сущности:
|
|
||||||
|
|
||||||
- **Отправляющий домен** — например, `example.com`. Имеет свой DKIM-ключ/селектор.
|
|
||||||
- **Приложение (учётная запись)** — SASL-пара логин/пароль, привязанная к конкретному домену. **У одного домена может быть несколько приложений** (например, `newsletter@example.com` и `alerts@example.com`, или просто «прод-сервер» и «staging-сервер» под один и тот же домен) — каждое со своим отдельным логином/паролем, но все они имеют право слать **только от имени того домена, к которому привязаны**, никогда от имени другого домена.
|
|
||||||
|
|
||||||
**Режим адресов отправки — настройка на уровне приложения**, не домена (разные приложения одного домена могут выбирать разный режим независимо). При создании или редактировании приложения выбирается один из двух вариантов:
|
|
||||||
|
|
||||||
1. **Любой адрес домена** — приложению разрешено указывать в From любой адрес в пределах своего домена (`*@example.com`). Удобно, когда одно приложение шлёт от разных адресов (например, `noreply@`, `alerts@`, `billing@`) без необходимости заводить под каждый отдельное приложение.
|
|
||||||
2. **Список конкретных адресов** — приложению разрешены только явно перечисленные From-адреса в пределах своего домена (например, только `alerts@example.com`). Более строгий вариант; попытка отправить с адреса не из списка отклоняется, даже если адрес принадлежит тому же домену.
|
|
||||||
|
|
||||||
В обоих случаях адрес обязан принадлежать домену приложения — выйти за пределы своего домена нельзя ни в одном режиме.
|
|
||||||
|
|
||||||
Для каждого добавленного домена система управляет:
|
|
||||||
|
|
||||||
1. **DKIM-ключ и селектор** — свои для каждого домена (см. раздел 6). Общие для всех приложений этого домена.
|
|
||||||
2. **Одно или несколько приложений** — у каждого своя SASL-пара логин/пароль и свой режим адресов (см. раздел 5.1). Приложения одного домена независимы: удаление/перевыпуск/смена режима одного не затрагивает другие.
|
|
||||||
3. **Разрешённые From-адреса** — определяются режимом каждого приложения (см. выше). В любом случае приложение не может отправлять от имени чужого домена — попытка залогиниться кредами приложения из домена A и отправить с адреса домена B отклоняется (см. привязку в разделе 5.1).
|
|
||||||
|
|
||||||
**Жизненный цикл домена (что делает панель при добавлении):**
|
|
||||||
- создаёт запись домена;
|
|
||||||
- генерирует DKIM-ключ + селектор для домена;
|
|
||||||
- показывает пользователю всё, что нужно внести в DNS для этого домена (DKIM TXT, а также напоминание про SPF/DMARC — см. раздел 10);
|
|
||||||
- **не создаёт приложение автоматически** — добавление первого (и последующих) приложений для домена делается отдельным действием, см. ниже.
|
|
||||||
|
|
||||||
**Жизненный цикл приложения (что делает панель при добавлении приложения к домену):**
|
|
||||||
- генерирует SASL-пару логин/пароль (пароль показывается **один раз**, см. 7.2);
|
|
||||||
- принимает выбор режима адресов: «любой адрес домена» либо «список конкретных адресов» (если выбран список — принимает сами адреса, с валидацией, что каждый принадлежит домену приложения);
|
|
||||||
- регистрирует привязку в `smtpd_sender_login_maps` соответственно режиму — либо wildcard-запись `@example.com → логин`, либо отдельная запись на каждый разрешённый адрес `конкретный-адрес@example.com → логин` (несколько логинов и/или несколько записей могут вести к одному домену — это штатный случай, а не исключение);
|
|
||||||
- применяет изменения (`postfix reload`).
|
|
||||||
|
|
||||||
**Изменение режима существующего приложения** — доступно как отдельное действие (переключение «любой адрес» ↔ «список», редактирование списка адресов), с пересборкой соответствующих записей в `smtpd_sender_login_maps` и `postfix reload`.
|
|
||||||
|
|
||||||
**При удалении домена** — удаляются его DKIM-ключ **и все приложения**, привязанные к этому домену.
|
|
||||||
**При удалении приложения** — удаляются только его SASL-креды и все его записи в карте привязки; домен и остальные приложения не затрагиваются.
|
|
||||||
|
|
||||||
Эта модель — не мультитенантность (панелью по-прежнему управляет один администратор); это управление несколькими отправляющими доменами одного владельца, каждый из которых может обслуживать несколько приложений с независимо настроенными правами на адреса отправки.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Компонент: Postfix
|
|
||||||
|
|
||||||
Конфигурация Postfix как исходящего релея:
|
|
||||||
|
|
||||||
1. **Приём почты — порт 465 (`smtps`, implicit TLS) как основной.** TLS устанавливается сразу при подключении (wrapper mode, `smtpd_tls_wrappermode = yes` в `master.cf` для сервиса `smtps`), в отличие от STARTTLS — клиент не отправляет ни байта в открытом виде до установления TLS-сессии. **Только для аутентифицированных клиентов (SASL, см. 5.1).** `mynetworks` не используется как способ авторизации отправки — пускают креды, а не сеть. **Категорически недопустима конфигурация open relay.**
|
|
||||||
- **Порт 587 (submission, STARTTLS) — не входит в базовую поставку, добавляется по необходимости.** Многие SMTP-библиотеки в приложениях по умолчанию ожидают именно 587 — при необходимости совместимости сервис `submission` в `master.cf` включается тем же способом, что и `smtps` (аналогичная SASL/milter/rate-limit конфигурация, единственное отличие — механизм установления TLS, см. 5.1 п.2). Это осознанно не включено в основной сценарий, чтобы не открывать лишнюю поверхность без необходимости.
|
|
||||||
2. **Отправка** — напрямую в интернет (без `relayhost`), с MX-lookup и TLS (`smtp_tls_security_level = may` минимум).
|
|
||||||
3. **DKIM** — подключён как milter к OpenDKIM (через unix-socket или inet-socket внутри контейнера). Подпись — per-domain (раздел 6).
|
|
||||||
4. **Ограничения** — `smtpd_recipient_restrictions` запрещает релей для неаутентифицированных (`permit_sasl_authenticated`, `reject_unauth_destination`). Обязательна привязка отправителя к логину (раздел 5.1).
|
|
||||||
5. **Базовый rate-limiting по client IP** — встроенный механизм Postfix (демон `anvil`): `smtpd_client_message_rate_limit` + `anvil_rate_time_unit` ограничивают число сообщений с одного client IP за окно времени. Работает на уровне приёма соединения, **независимо от journal-milter** — это базовый backstop, который продолжает действовать даже при сбое milter'а (см. раздел 7.4). Значения — конфигурируемые (раздел 8), по умолчанию консервативные, особенно уместные на этапе прогрева IP (раздел 10). Применяется одинаково к обоим портам приёма (465 и, если включён, 587).
|
|
||||||
6. Домены, приложения (креды) и привязки — управляются панелью (файлы конфигурации, которые панель редактирует, с последующим `postfix reload`).
|
|
||||||
|
|
||||||
Базовый образ-ориентир для понимания подхода: `boky/postfix` (env-driven relay), но здесь конфигурация собирается под DIY-отправку с SASL-аутентификацией и мультидоменом, а не под relay через провайдера.
|
|
||||||
|
|
||||||
### 5.1. SASL-аутентификация и привязка приложений к домену
|
|
||||||
|
|
||||||
1. **Бэкенд SASL — Cyrus SASL с локальной базой `sasldb2`** (лёгкий, без Dovecot и без полноценного mail-стека). Панель создаёт/удаляет учётные записи приложений (эквивалент `saslpasswd2`), Postfix проверяет их через `smtpd_sasl_auth_enable = yes`.
|
|
||||||
2. **TLS обязателен для аутентификации на обоих портах, но механизм различается:**
|
|
||||||
- На **465 (`smtps`)** — TLS уже установлен к моменту аутентификации по определению wrapper mode, отдельная настройка не требуется сверх `smtpd_tls_wrappermode = yes`.
|
|
||||||
- На **587 (`submission`, если включён)** — `smtpd_tls_auth_only = yes`, чтобы креды не могли быть отправлены до STARTTLS по незашифрованному каналу.
|
|
||||||
В обоих случаях результат одинаков: креды никогда не передаются в открытом виде.
|
|
||||||
3. **Привязка логина к разрешённым адресам (критично).** Настраивается `smtpd_sender_login_maps` + `reject_sender_login_mismatch` в `smtpd_sender_restrictions`. Без этого любой валидный аккаунт сможет слать от имени любого домена — это дыра, а не опция. Формат записи в карте зависит от режима адресов, выбранного для приложения (см. раздел 4.1):
|
|
||||||
- **режим «любой адрес домена»** — wildcard-запись `@example.com логин` (Postfix поддерживает доменные wildcard-записи в `sender_login_maps`);
|
|
||||||
- **режим «список конкретных адресов»** — отдельная запись на каждый разрешённый адрес: `alerts@example.com логин`, `noreply@example.com логин` и т.д.
|
|
||||||
Обе формы одновременно допустимы в карте для разных приложений/доменов. Панель поддерживает карту в актуальном состоянии при добавлении/удалении/изменении доменов и приложений, генерируя нужный тип записи в зависимости от режима.
|
|
||||||
4. **Многие-ко-одному.** У одного домена может быть несколько приложений (логинов), каждое со своим независимым режимом адресов; ни одно приложение не может получить право слать от имени домена, к которому оно не привязано.
|
|
||||||
|
|
||||||
### 5.2. TLS-сертификаты для Postfix
|
|
||||||
|
|
||||||
**SelfPost всегда работает за reverse-proxy, и именно reverse-proxy отвечает за выпуск, хранение и автообновление TLS-сертификатов** (ACME/Let's Encrypt). Проект **не** содержит собственного ACME-клиента и не выпускает сертификаты сам — он только потребляет готовые.
|
|
||||||
|
|
||||||
Модель простая:
|
|
||||||
|
|
||||||
1. **Источник** — reverse-proxy (Caddy/Traefik) кладёт PEM-файлы (цепочка + приватный ключ) для почтового hostname в директорию на хосте, смонтированную в контейнер SelfPost **bind mount'ом, только на чтение** (та же схема, что и для остального персистентного состояния — раздел 9). Пути к файлам задаются переменными окружения (см. раздел 8).
|
|
||||||
2. **Потребление** — Postfix настроен читать эти файлы (`smtpd_tls_cert_file` / `smtpd_tls_key_file`) для TLS на 465 (`smtps`, основной), 587 (`submission`, если включён) и opportunistic TLS на порту 25. Один и тот же сертификат обслуживает оба порта приёма.
|
|
||||||
3. **Соответствие hostname** — CN/SAN сертификата должен совпадать с HELO-hostname (`SELFPOST_HOSTNAME`) и PTR/rDNS. Проще всего использовать общий hostname для панели и почты, тогда это один и тот же сертификат от reverse-proxy.
|
|
||||||
4. **Обновление** — когда reverse-proxy обновляет сертификат, файлы в смонтированной директории меняются, и Postfix нужно перечитать их через `postfix reload`. Чтобы деплой оставался простым, применяется **периодический `postfix reload` (например, раз в сутки)** — этого достаточно, т.к. сертификаты обновляются раз в ~2-3 месяца, а суточная задержка применения некритична. Реализовать как отдельную периодическую задачу под supervisord (или cron внутри контейнера). Inotify-вотчер на файл сертификата допустим как альтернатива, но периодический reload проще и надёжнее.
|
|
||||||
|
|
||||||
Персистентность сертификатов (хранение, ACME-account key) — **ответственность reverse-proxy**, не SelfPost (см. раздел 9). Контейнер SelfPost хранит сертификаты только как read-only mount и не заботится об их выживании при рестарте.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Компонент: OpenDKIM
|
|
||||||
|
|
||||||
DKIM-подпись — **строго per-domain**. Каждый отправляющий домен имеет собственную пару ключей и селектор.
|
|
||||||
|
|
||||||
1. **Генерация при добавлении домена** — панель создаёт для нового домена пару DKIM-ключей и селектор. Все ключи должны **переживать перезапуск контейнера** (см. раздел 9).
|
|
||||||
2. **Подпись** — исходящая почта каждого домена подписывается его собственным ключом. OpenDKIM настраивается с `KeyTable` + `SigningTable`, которые панель поддерживает в актуальном состоянии (запись на каждый домен), с последующим reload OpenDKIM.
|
|
||||||
3. **Показ DNS-записи per-domain** — панель показывает публичную часть ключа в формате DNS TXT-записи **для каждого домена отдельно**, чтобы пользователь внёс её в DNS соответствующего домена.
|
|
||||||
4. **Селектор** — на домен; может быть общим значением по умолчанию (например, `selfpost`) для всех доменов, т.к. селекторы живут в пространстве имён каждого домена и не конфликтуют. Значение по умолчанию — конфигурируемое.
|
|
||||||
5. **При удалении домена** — его ключ и записи в `KeyTable`/`SigningTable` удаляются, OpenDKIM перечитывается.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Компонент: Панель управления
|
|
||||||
|
|
||||||
### 7.1. Технологический стек
|
|
||||||
|
|
||||||
- **Язык: Go.** Обоснование: статический бинарник (минимум рантайм-зависимостей в образе), компилятор ловит ошибки до развёртывания (важно, т.к. код пишет ИИ-агент), простая упаковка.
|
|
||||||
- **Зависимости — минимальные.** Приоритет стандартной библиотеки: `net/http`, `html/template`, `os/exec`. Внешние зависимости допускаются только для того, что не покрыто stdlib (например, bcrypt из `golang.org/x/crypto`). Каждая внешняя зависимость должна быть обоснована.
|
|
||||||
- HTTP-сервер слушает `:8080` внутри контейнера (HTTPS терминируется reverse-proxy, см. раздел 10).
|
|
||||||
|
|
||||||
**Front-end:**
|
|
||||||
|
|
||||||
- **Server-rendered** через `html/template`. Никакого SPA, JS-фреймворка (React/Vue/Svelte) или build-шага (webpack/vite/npm). Весь UI отдаётся тем же Go-бинарником — единая точка деплоя, ноль `node_modules`. Это требование, а не рекомендация: оно прямо вытекает из приоритета простоты поддержки.
|
|
||||||
- **HTMX** — единственная фронтенд-зависимость, подключается одним статическим `<script>` (вендорится в бинарник/образ, без npm). Используется для:
|
|
||||||
- частичных обновлений на страницах-настройках (добавить/удалить отправителя без полной перезагрузки);
|
|
||||||
- **автообновления экранов мониторинга** (очередь, лог) через периодический polling — `hx-trigger="every Ns"` подтягивает свежий фрагмент раз в несколько секунд (интервал сделать конфигурируемым или разумно зашитым, порядка 2–5 сек).
|
|
||||||
- **Мониторинг реализуется через HTMX-polling, НЕ через SSE/WebSocket.** Осознанный выбор в пользу максимальной простоты: обновление раз в несколько секунд достаточно для этой задачи, а polling не требует stream-инфраструктуры, управления соединениями и усложнения кода. Мгновенность не требуется.
|
|
||||||
- Эндпоинты, отдающие фрагменты для polling (например, `/queue/fragment`, `/log/fragment`), возвращают готовый HTML-кусок для замены блока на странице, а не JSON.
|
|
||||||
|
|
||||||
### 7.2. Функциональные требования
|
|
||||||
|
|
||||||
Панель предоставляет **ограниченный** набор операций:
|
|
||||||
|
|
||||||
1. **Аутентификация** — вход администратора по логину/паролю (раздел 7.6).
|
|
||||||
2. **Список доменов** — показать все настроенные отправляющие домены, их статус (есть ли DKIM-ключ, показана ли DNS-запись) и количество привязанных приложений.
|
|
||||||
3. **Добавить домен** — по имени домена панель (см. раздел 4.1):
|
|
||||||
- создаёт запись домена;
|
|
||||||
- генерирует DKIM-ключ + селектор;
|
|
||||||
- применяет изменения (reload OpenDKIM);
|
|
||||||
- показывает DKIM TXT-запись для DNS.
|
|
||||||
Добавление домена **не создаёт приложение** — это отдельное действие (п. 5).
|
|
||||||
4. **Удалить домен** — удаляет DKIM-ключ домена **и все привязанные к нему приложения** (SASL-креды и привязки), применяет изменения. Панель должна явно предупредить о каскадном удалении приложений перед подтверждением.
|
|
||||||
5. **Добавить приложение к домену** — внутри карточки домена, за одну операцию:
|
|
||||||
- генерирует SASL-пару логин/пароль;
|
|
||||||
- принимает выбор **режима адресов**: «любой адрес домена» либо «список конкретных адресов» (при выборе списка — ввод одного или нескольких адресов, каждый валидируется на принадлежность домену приложения, см. 7.6);
|
|
||||||
- регистрирует соответствующую(ие) запись(и) в `smtpd_sender_login_maps` (wildcard или по адресам — раздел 5.1);
|
|
||||||
- применяет изменения (`postfix reload`);
|
|
||||||
- **показывает сгенерированный пароль ОДИН РАЗ** (см. 7.6, п. про пароли).
|
|
||||||
6. **Список приложений домена** — показать все приложения (логины), привязанные к конкретному домену, вместе с их текущим режимом адресов (для режима «список» — сами адреса).
|
|
||||||
7. **Редактировать режим адресов приложения** — переключить «любой адрес» ↔ «список», либо изменить сам список адресов для существующего приложения; пересобирает соответствующие записи в `smtpd_sender_login_maps` и применяет изменения.
|
|
||||||
8. **Удалить приложение** — удаляет SASL-креды и все его записи в карте привязки; домен и остальные приложения не затрагиваются.
|
|
||||||
9. **Перевыпустить пароль приложения** — сгенерировать новую SASL-пару для существующего приложения (старый пароль инвалидируется, режим адресов сохраняется), показать новый пароль один раз.
|
|
||||||
10. **Просмотр DKIM DNS-записи** — для любого домена показать его TXT-запись для копирования в DNS (доступно в любой момент — это не секрет).
|
|
||||||
11. **Просмотр очереди** — вывод `mailq` / `postqueue -p` в читаемом виде (сколько писем в очереди, статусы). Экран **автообновляется** через HTMX-polling раз в несколько секунд (см. 7.1).
|
|
||||||
12. **Reload** — кнопка, применяющая изменения конфига вручную (`postfix reload` / reload OpenDKIM), если нужно.
|
|
||||||
13. **Просмотр хвоста почтового лога** для диагностики — последние N строк, с автообновлением через polling.
|
|
||||||
14. **Журнал отправки писем** — таблица отправленных писем с фильтрами по домену и приложению (подробности — раздел 7.3).
|
|
||||||
15. **Настройка лимитов отправки** — для домена и/или приложения задать привязанные IP и лимит писем за окно времени (подробности — раздел 7.4).
|
|
||||||
16. **Скачать полную резервную копию** — кнопка формирует и отдаёт архив всего персистентного состояния (подробности — раздел 7.5.А).
|
|
||||||
17. **Экспортировать домен** / **Импортировать домен** — перенос одного домена на другой экземпляр SelfPost, включая рабочие пароли приложений (перевыпуск не требуется); файл экспорта содержит секреты и должен обрабатываться как чувствительный (подробности — раздел 7.5.Б).
|
|
||||||
|
|
||||||
### 7.3. Журнал отправки (Send Log)
|
|
||||||
|
|
||||||
Отдельный от «просмотра очереди»/«просмотра лога» экран: не сырой `mailq`/hвост файла, а структурированная, фильтруемая история отправленных писем.
|
|
||||||
|
|
||||||
**Что фиксируется в журнале для каждого письма:**
|
|
||||||
- время приёма;
|
|
||||||
- домен и приложение (SASL-логин), от имени которого отправлено;
|
|
||||||
- From-адрес и To-адрес(а);
|
|
||||||
- Subject (тема);
|
|
||||||
- статус: в очереди / отправлено / отклонено получателем / отложено (bounced/deferred), с обновлением по мере продвижения по очереди Postfix;
|
|
||||||
- queue-id Postfix (для сопоставления с сырыми логами при более глубокой диагностике).
|
|
||||||
|
|
||||||
**Технический механизм получения данных:**
|
|
||||||
|
|
||||||
1. **Тема письма не попадает в стандартные логи Postfix** (`mail.log` содержит envelope-данные и статус доставки, но не заголовки письма). Чтобы получить Subject, From, To и SASL-логин на этапе приёма, panel-бинарник реализует **лёгкий milter** (например, через библиотеку `github.com/emersion/go-milter` или аналог), подключённый в `smtpd_milters` Postfix вместе с OpenDKIM. На этапе приёма письма (EOH/заголовки) milter читает нужные поля и создаёт запись в журнале со статусом «в очереди», привязанную к queue-id.
|
|
||||||
2. **Финальный статус доставки** panel получает отдельно: горутина внутри того же бинарника хвостит `mail.log` (аналогично экрану из п. 13), парсит строки со статусами `sent`/`bounced`/`deferred` по queue-id и обновляет соответствующую запись журнала.
|
|
||||||
3. Если письмо направлено нескольким получателям, Postfix может завершать доставку по каждому получателю с разным статусом и в разное время — журнал хранит **отдельную запись на пару (queue-id, получатель)**, чтобы точно отражать различающиеся статусы; в UI такие записи можно опционально визуально группировать по queue-id, но это не обязательно для первой версии.
|
|
||||||
|
|
||||||
> **⚠️ Известный риск реализации: milter на Go — менее протоптанный путь, чем остальной проект.** В отличие от конфигурации Postfix/OpenDKIM (где документации и готовых примеров множество, что снижает риск ошибок агента), библиотек реализации milter-протокола на Go немного, и по ним заметно меньше примеров и обсуждений edge-case'ов. Конкретные точки риска:
|
|
||||||
> - **Протокол milter завязан на конкретные версии** (v2/v6 и т.п.) и не всегда единообразно документирован в доступных Go-библиотеках — есть шанс несовместимости с версией Postfix в образе.
|
|
||||||
> - **Обработка ошибок/таймаутов milter'а влияет на приём почты напрямую.** Если journal-milter зависнет или упадёт некорректно, Postfix (в зависимости от `milter_default_action`) может либо пропускать письма без логирования, либо, что хуже, начать отклонять всю входящую почту. Это самый чувствительный узел: баг здесь не просто ломает журнал, а потенциально ломает сам релей.
|
|
||||||
> - **Меньше протестированных Go-примеров** milter-серверов, читающих именно Subject/From/To на этапе EOH, — агенту придётся писать эту часть с меньшей опорой на устоявшиеся паттерны, чем остальной проект.
|
|
||||||
>
|
|
||||||
> **Требование по снижению риска:** `milter_default_action` для journal-milter должен быть настроен так, чтобы **сбой journal-milter не блокировал приём и отправку почты** (fail-open для этого конкретного milter’а — в отличие от OpenDKIM, где отказ в подписи можно и нужно трактовать строже). Журнал — вспомогательная функция мониторинга; релей не должен переставать работать из-за него. Этот компонент нуждается в более внимательном тестировании (в т.ч. поведения при падении/таймауте) перед тем, как считаться готовым.
|
|
||||||
|
|
||||||
**Хранение:** таблица в той же SQLite (см. раздел 9), не файл лога — чтобы поддерживались фильтры и запросы без парсинга на лету.
|
|
||||||
|
|
||||||
**Retention (обязательно предусмотреть, иначе журнал растёт бесконечно):** конфигурируемый срок хранения записей (например, `SEND_LOG_RETENTION_DAYS`, по умолчанию 90 дней) с периодической фоновой очисткой устаревших записей. Значение по умолчанию и наличие настройки — обязательны; исполнитель может уточнить конкретную реализацию очистки (периодическая задача в той же горутине, что и log-tailer).
|
|
||||||
|
|
||||||
**UI:**
|
|
||||||
- Таблица: время, домен, приложение, From, To, Subject, статус.
|
|
||||||
- **Фильтры — обязательны:** по домену (выпадающий список из существующих доменов) и по приложению (список приложений, опционально зависящий от выбранного домена). Фильтры применяются на сервере (запрос к SQLite с `WHERE`), не на клиенте.
|
|
||||||
- Пагинация или ограничение количества строк на экран (журнал может быть большим).
|
|
||||||
- Автообновление свежих записей — через HTMX-polling, тот же подход, что у очереди и лога (раздел 7.1), не обязательно, но желательно для консистентности UX.
|
|
||||||
|
|
||||||
**Приватность и безопасность — важное замечание:** журнал хранит адреса получателей и темы писем — это метаданные переписки, потенциально чувствительные. Он доступен только через панель (за логином/паролем администратора, раздел 7.6) — отдельного уровня доступа для журнала не предусмотрено, весь объём защищён общей аутентификацией панели. Тема и адреса должны экранироваться при рендере (раздел 7.6, п.7) как и остальной вывод из логов.
|
|
||||||
|
|
||||||
### 7.4. Rate limiting (лимиты отправки)
|
|
||||||
|
|
||||||
Двухуровневая защита от «взбесившегося» приложения или скомпрометированных кред — цель — оградить репутацию IP, общего для всех доменов проекта.
|
|
||||||
|
|
||||||
**Уровень 1 — базовый, нативный Postfix, независимый от milter (backstop).** Описан в разделе 5, п. 5: `smtpd_client_message_rate_limit`/`anvil_rate_time_unit`, ключ — client IP. Работает всегда, включая ситуацию, когда journal-milter недоступен. Это гарантирует, что даже при полном отказе дифференцированного уровня 2 грубая защита от резкого всплеска отправки с одного IP не исчезает.
|
|
||||||
|
|
||||||
**Уровень 2 — дифференцированные лимиты «на домен» и «на приложение», настраиваемые в панели.** Реализованы в journal-milter (том же компоненте, что и журнал отправки, раздел 7.3), поскольку milter уже разбирает каждое соединение и имеет доступ к client IP на этапе приёма.
|
|
||||||
|
|
||||||
- **Ключ лимита — client IP, а не SASL-логин.** Причина: один логин/пароль потенциально может использоваться несколькими физическими отправителями (например, кластер серверов с общими кредами) — SASL-идентичность не гарантированно соответствует одному источнику, а IP — более надёжный практический прокси для «кто на самом деле шлёт».
|
|
||||||
- **Настройка:** при добавлении/редактировании домена и приложения (раздел 7.2) админ может указать один или несколько ожидаемых IP-адресов и лимит (N писем за скользящее окно) — отдельно на уровне домена (суммарно по всем его приложениям и IP) и отдельно на уровне приложения (для его конкретных IP). Оба необязательны — если не заданы, работает только базовый уровень 1.
|
|
||||||
- **Проверка:** при приёме письма milter сверяет client IP с зарегистрированными для соответствующего домена/приложения и запрашивает текущий счётчик из SQLite (переиспользует данные журнала отправки); при превышении — отклоняет письмо (временный отказ 4xx — семантически корректно для rate-limit, ожидается, что отправитель повторит попытку позже) и **опционально фиксирует это как отдельную запись в журнале со статусом «отклонено по лимиту»** — для видимости в UI.
|
|
||||||
- **Fail-open допустим на этом уровне.** Поскольку уровень 1 не зависит от milter и продолжает работать самостоятельно, при сбое/недоступности journal-milter уровень 2 может безопасно отключаться (fail-open, тот же принцип, что и для журнала, раздел 7.3) — теряется точность «по домену/приложению», но грубая защита от IP не пропадает.
|
|
||||||
|
|
||||||
> **⚠️ Оговорка: дифференцированный уровень требует предсказуемого IP приложения.** Если приложение шлёт из окружения с динамическими/меняющимися IP (например, serverless с ротацией адресов), привязка «IP → домен/приложение» не может быть настроена содержательно — для таких приложений реальной защитой остаётся только базовый уровень 1 (глобальный, без разбивки по домену/приложению). Панель должна явно допускать оставить IP-привязку пустой (тогда дифференцированный лимит для этого приложения просто не применяется), а не требовать её обязательно.
|
|
||||||
|
|
||||||
> **⚠️ Известный нюанс: IPv6 ослабляет точность IP-ключа.** Весь дизайн уровней 1 и 2 держится на «IP как надёжный прокси для одного отправителя» — для IPv4 это разумное допущение, но для IPv6 не совсем так: провайдеры часто выдают целый префикс (обычно /64) одному клиенту, и адрес в его пределах может меняться чаще, чем у IPv4-адреса. Формально это может ослаблять точность обоих уровней лимитов именно в IPv6-сетях. Осознанно фиксируется как известное ограничение и оставляется как есть — не требует изменения дизайна на данном этапе.
|
|
||||||
|
|
||||||
### 7.5. Резервное копирование и миграция
|
|
||||||
|
|
||||||
Два разных сценария с разной механикой — не путать друг с другом ни в реализации, ни в UI.
|
|
||||||
|
|
||||||
**А. Полный бэкап сервера (миграция на новую машину целиком).**
|
|
||||||
|
|
||||||
- **Что входит в архив:** консолидированное персистентное состояние — SQLite (`selfpost.db`: домены, приложения, администратор, журнал, настройки лимитов), DKIM-ключи всех доменов, база SASL (`sasldb2`), и **манифест с версией SelfPost**, которой создан бэкап (см. ниже). **Рекомендация исполнителю:** организовать все три под единым корневым путём (например, всё под `/data/`), чтобы бэкап буквально сводился к архивации одной директории, а не сборке путей из разных мест контейнера — это прямое следствие цели «переезд прост как архив».
|
|
||||||
- **Что НЕ входит:** TLS-сертификаты (ответственность reverse-proxy, раздел 5.2) и очередь Postfix (`/var/spool/postfix`, транзитные недоставленные письма — не переносятся; это осознанный компромисс ради простоты, а не недосмотр).
|
|
||||||
- **Версионирование бэкапа — обязательно.** Архив содержит файл-манифест (например, `manifest.json`) с версией SelfPost, зашитой в бинарник на этапе сборки (Go build-time ldflags, `-X main.version=...`, согласуется с тегом Docker-образа). Восстановление **должно происходить в тот же самый Docker-образ той же версии**, которым был создан бэкап — это устраняет риск несовместимости схемы SQLite, путей DKIM-ключей или формата `sasldb2` между версиями, а не полагается на то, что миграции схемы «как-нибудь сработают» задним числом.
|
|
||||||
- **Проверка при восстановлении:** при старте контейнера с распакованным бэкапом (или на отдельном явном шаге restore) panel сверяет версию из манифеста с версией собственного бинарника. При несовпадении — **отказ от запуска/восстановления с понятным сообщением**, каким именно тегом образа нужно воспользоваться (например: «бэкап создан версией 1.3.0, запущена версия 1.5.2 — используйте `selfpost:1.3.0` для восстановления»), а не тихая попытка продолжить с риском повреждения состояния.
|
|
||||||
- Эта версия **не про автомиграцию на лету между версиями** — если пользователь хочет перейти на более новую версию, это делается отдельно (обычное обновление образа на живом инстансе, вне контура backup/restore), а не как часть восстановления бэкапа.
|
|
||||||
- **Следствие для деплоя:** `docker-compose.yml` должен использовать **фиксированный тег версии образа**, не `:latest` — иначе невозможно достоверно определить, какой версией был создан бэкап, и вся эта защита теряет смысл. Отметить это явно в разделе 10 и README.
|
|
||||||
- **Создание бэкапа — двумя равнозначными способами:**
|
|
||||||
1. Кнопка в панели «Скачать резервную копию» — аутентифицированное действие администратора, формирует архив на лету и отдаёт на скачивание.
|
|
||||||
2. Эквивалентная CLI-утилита внутри контейнера (например, `selfpost-backup`, вызываемая через `docker exec`) — для скриптовых/cron-бэкапов без захода в веб-интерфейс.
|
|
||||||
- **Восстановление на новом сервере:** поднять пустой контейнер SelfPost **той же версии образа**, что указана в манифесте бэкапа, с теми же путями bind mount, распаковать архив в них **до первого старта** (либо тем же шагом, что и обычная инициализация — специального «режима восстановления» не требуется), затем запустить контейнер как обычно. Panel/Postfix/OpenDKIM конфигурация перегенерируется из восстановленного SQLite-состояния тем же механизмом, что и при каждом обычном старте (не отдельная ветка кода для restore) — не нужно заново проходить secret-link, заново заводить домены или получать новые DKIM-ключи.
|
|
||||||
- **Прямое следствие для DNS:** поскольку DKIM-ключи переносятся побитово, **DKIM TXT-записи в DNS не нужно менять** после переезда — только A/PTR-записи на новый IP сервера. Это существенно упрощает миграцию по сравнению с «начать с нуля».
|
|
||||||
- **Безопасность архива:** архив содержит крайне чувствительные данные — приватные DKIM-ключи, хэш пароля администратора, хэши SASL-кредов. Скачивание через панель уже защищено аутентификацией (раздел 7.6), но сам файл после скачивания нужно хранить и передавать как секрет (не по HTTP, удалять после успешного восстановления) — отметить это в документации.
|
|
||||||
|
|
||||||
**Б. Экспорт/импорт отдельного домена (перенос одного домена между двумя независимо работающими экземплярами SelfPost).**
|
|
||||||
|
|
||||||
- **Экспорт домена** формирует файл с: именем домена, DKIM-ключом и селектором, режимом адресов, списком приложений (логинами и их режимом/списком адресов) и **соответствующими записями `sasldb2` для приложений этого домена**. Технически это возможно, потому что `sasldb2` (в отличие от bcrypt-хэша пароля администратора, раздел 7.6) хранит секрет в форме, допускающей challenge-response механизмы (CRAM-MD5/DIGEST-MD5) — это обратимая/эквивалентная паролю форма, а не строгий необратимый хэш, и её записи можно выборочно переносить между экземплярами так же, как это уже происходит с файлом `sasldb2` целиком при полном бэкапе (раздел 7.5.А). Не путать эту сущность с bcrypt-хэшем администратора — они устроены принципиально по-разному, и только последний действительно невосстановим.
|
|
||||||
- **Импорт на другом экземпляре** восстанавливает домен, DKIM-ключ (DNS-запись остаётся той же — менять не нужно) и приложения **с рабочими паролями без перевыпуска** — по тому же принципу, что и полный бэкап.
|
|
||||||
- **Безопасность экспортного файла — как у полного бэкапа.** Поскольку файл теперь содержит секреты приложений (записи `sasldb2`) и приватный DKIM-ключ домена, он **настолько же чувствителен**, как архив полного бэкапа (раздел 7.5.А), и должен передаваться/храниться так же — не по HTTP, удаляться после использования, не рассматриваться как «просто конфиг».
|
|
||||||
|
|
||||||
### 7.6. Нефункциональные требования — БЕЗОПАСНОСТЬ (обязательно)
|
|
||||||
|
|
||||||
Поскольку панель публична, следующее — **не опционально**:
|
|
||||||
|
|
||||||
1. **Первичная инициализация администратора — через одноразовую секретную ссылку**, не через env-переменную с готовым хэшем:
|
|
||||||
- При первом запуске (в персистентном состоянии ещё нет ни одного администратора) панель генерирует криптографически случайный токен и выводит ссылку вида `https://<host>/setup/<token>` в лог/stdout контейнера. Токен дополнительно пишется в файл в смонтированной директории (например, `/data/setup-token`) — на случай, если удобнее прочитать файл, чем логи.
|
|
||||||
- **Энтропия токена — не менее 128 бит** (например, 16+ случайных байт из `crypto/rand`, представленные в hex/base64url). Это единственное, что делает подбор математически неосуществимым в принципе — комбинаторное пространство ~3.4×10^38 вариантов; остальные меры (короткое окно, rate limit) — defense-in-depth поверх этого, а не замена ему.
|
|
||||||
- **Срок жизни токена — 10 минут** (сокращено с изначально предложенного часа). Если контейнер стартовал, а установка не завершена за это время, токен истекает; при следующем обращении к `/setup` (после истечения) или при рестарте без завершённой настройки панель перегенерирует токен и заново выводит его в лог.
|
|
||||||
- **Rate limiting на маршрут `/setup/<token>`** — обязателен, отдельно от общего rate limiting на логин (п. 5): ограниченное число попыток обращения в единицу времени по IP (например, несколько в минуту), с отклонением/задержкой сверх лимита. При заданной энтропии токена это не является единственной защитой, но снижает шум в логах и защищает от тривиального автоматического перебора.
|
|
||||||
- **Сравнение токена — константное по времени** (`crypto/subtle.ConstantTimeCompare` или аналог), чтобы исключить timing-атаку, которая могла бы подсказывать правильные префиксы токена по разнице во времени ответа.
|
|
||||||
- **Неудачные попытки НЕ инвалидируют и не перегенерируют токен досрочно.** Это осознанное решение: если бы ошибочные попытки заставляли токен перевыпускаться, атакующий получил бы возможность DoS'ить легитимную настройку, постоянно обнуляя токен раньше, чем администратор успеет им воспользоваться. Раз энтропия уже делает подбор неосуществимым, довешивать авто-инвалидацию на неудачу — риск без пользы.
|
|
||||||
- Переход по ссылке открывает одноразовую форму создания администратора (логин + пароль).
|
|
||||||
- После успешного создания администратора токен **инвалидируется навсегда** (флаг в персистентном состоянии), маршрут `/setup/*` перестаёт быть доступен (404).
|
|
||||||
- Пароль администратора, введённый на этой форме, хранится только в виде bcrypt-хэша (или argon2) в персистентном состоянии. Никакого plaintext, MD5, SHA1-без-соли.
|
|
||||||
- `PANEL_USERNAME`/`PANEL_PASSWORD_HASH` как env-переменные **не используются** для основного сценария — см. обновлённый раздел 8.
|
|
||||||
- **SASL-пароли приложений** — отдельная сущность (не путать с паролем администратора панели). Панель **генерирует** сильный случайный пароль сама при создании/перевыпуске приложения, показывает его пользователю **ровно один раз**, и НЕ хранит его в открытом виде для повторного показа (в `sasldb2` он лежит в хэшированном виде, как того требует механизм SASL). Если пароль утерян — только перевыпуск (п. 8 раздела 7.2).
|
|
||||||
2. **Валидация ввода на стороне сервера** (не только в UI). Для email/доменов — строгий whitelist допустимых символов (буквы, цифры, `.`, `-`, `@`). Клиентская валидация не считается защитой. Для режима «список конкретных адресов» (раздел 4.1) — отдельная обязательная проверка: **каждый вводимый адрес должен строго принадлежать домену того приложения, к которому он добавляется** (совпадение части после `@` с доменом); адрес из чужого домена отклоняется до записи в конфиг, а не полагается на то, что это отловит `smtpd_sender_login_maps` уже во время доставки.
|
|
||||||
3. **`postfix reload` и любые вызовы `os/exec`** — БЕЗ интерполяции пользовательского ввода в команду. Аргументы передаются как отдельные элементы (`exec.Command("postfix", "reload")`), никогда через shell-строку. Пользовательский ввод не должен попадать в аргументы команд вообще; он идёт только в конфиг-файлы (после валидации).
|
|
||||||
4. **Запись в конфиг-файлы** — с экранированием/санитизацией, чтобы инъекция спецсимволов (перенос строки и т.п.) не могла добавить произвольную директиву в конфиг Postfix.
|
|
||||||
5. **Rate limiting** на эндпоинт логина (защита от brute-force). Простая реализация (счётчик попыток по IP с временной блокировкой) достаточна.
|
|
||||||
6. **Сессии** — токен криптографически случайный, cookie с флагами `HttpOnly`, `Secure`, `SameSite`.
|
|
||||||
7. **Экранирование вывода** — данные из очереди/логов (тема письма, адреса) рендерятся через `html/template` с автоэкранированием (защита от XSS).
|
|
||||||
8. **Процесс панели не должен работать от root** (в supervisord запускать под непривилегированным пользователем, с доступом только к нужным путям через группу/права).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Конфигурация (переменные окружения)
|
|
||||||
|
|
||||||
Минимальный набор env-переменных для настройки (исполнитель может расширить):
|
|
||||||
|
|
||||||
- `SELFPOST_HOSTNAME` — hostname самого сервера (должен совпадать с PTR/rDNS и с CN/SAN TLS-сертификата). Это hostname сервера, **не** отправляющий домен — домены добавляются через панель динамически.
|
|
||||||
- `DKIM_SELECTOR_DEFAULT` — селектор DKIM по умолчанию для новых доменов (например, `selfpost`)
|
|
||||||
- `TLS_CERT_FILE` — путь к PEM-файлу сертификата (цепочка), поставляемому reverse-proxy через read-only bind mount
|
|
||||||
- `TLS_KEY_FILE` — путь к PEM-файлу приватного ключа, поставляемому reverse-proxy
|
|
||||||
- `SEND_LOG_RETENTION_DAYS` — срок хранения записей журнала отправки (раздел 7.3), по умолчанию 90
|
|
||||||
- `RATE_LIMIT_MESSAGES_PER_IP` — базовый лимит сообщений с одного client IP за окно (уровень 1, раздел 5 п.5 и 7.4), консервативное значение по умолчанию
|
|
||||||
- `RATE_LIMIT_WINDOW_SECONDS` — окно времени для базового лимита (`anvil_rate_time_unit`), по умолчанию 3600 (час)
|
|
||||||
|
|
||||||
**Администратор панели не задаётся через env-переменные.** Создаётся один раз через одноразовую secret-ссылку при первом запуске (см. раздел 7.6, п. 1). Это сделано осознанно: пароль/хэш в env-переменных виден через `docker inspect`, оркестраторы и логи окружения — secret-link избегает этой поверхности и не требует от пользователя вручную считать bcrypt-хэш до старта.
|
|
||||||
|
|
||||||
Отправляющие домены, их SASL-креды, DKIM-ключи и привязки — тоже **не** в env-переменных, а в персистентном состоянии, управляемом панелью (см. разделы 4.1 и 9).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Персистентность (bind mount на хосте)
|
|
||||||
|
|
||||||
Должны переживать перезапуск/пересоздание контейнера:
|
|
||||||
|
|
||||||
- **DKIM-ключи всех доменов** (`/etc/opendkim/keys` + `KeyTable`/`SigningTable`) — критично, иначе при рестарте подписи перестанут совпадать с DNS.
|
|
||||||
- **SASL-база** (`sasldb2`) — учётные записи всех приложений всех доменов; без неё после рестарта приложения не смогут аутентифицироваться.
|
|
||||||
- **Конфиги Postfix**, изменяемые панелью: список доменов, карта привязки `smtpd_sender_login_maps` (логины приложений → домены) и т.п.
|
|
||||||
- **База состояния панели — SQLite** (единый файл, например `/data/selfpost.db`), содержит: реестр доменов и приложений (домены, привязанные приложения, режим адресов, селекторы, метаданные), учётную запись администратора (логин + bcrypt-хэш), флаг «первичная настройка завершена» / текущий setup-токен, **журнал отправки писем** (раздел 7.3) с retention-политикой, и **настройки дифференцированных лимитов отправки** — привязанные IP и лимиты на домен/приложение (раздел 7.4). Формат зафиксирован как SQLite (не «на усмотрение исполнителя», так как журнал и лимиты требуют фильтруемых запросов). Должна переживать рестарт — иначе при каждом перезапуске контейнера пришлось бы заново создавать администратора, терялась бы история отправки и настройки лимитов.
|
|
||||||
- **Очередь Postfix** (`/var/spool/postfix`) — чтобы недоставленные письма не терялись при рестарте.
|
|
||||||
|
|
||||||
**TLS-сертификаты в этот список не входят** — они поставляются reverse-proxy через read-only bind mount, и их хранение/выживание при рестарте — ответственность reverse-proxy (см. раздел 5.2).
|
|
||||||
|
|
||||||
**Ротация `mail.log` — обязательна.** В отличие от структурированного журнала отправки в SQLite (раздел 7.3), у которого есть retention-политика (`SEND_LOG_RETENTION_DAYS`), сырой лог Postfix (`mail.log`, который читает log-tailer для панели, раздел 7.2 п.13) ничем не ограничен по умолчанию и будет расти неограниченно на протяжении месяцев/лет работы — на небольшом диске (раздел 10) это реальный риск исчерпания места, в отличие от остального состояния, которое ограничено by design. Настроить `logrotate` внутри контейнера (ежедневная/еженедельная ротация, ограниченное число хранимых файлов, например 7–14) как часть образа.
|
|
||||||
|
|
||||||
**Механизм — bind mount на хосте, не именованный Docker volume.** Все перечисленные выше пути монтируются из директории на файловой системе хоста (например, `./data` рядом с `docker-compose.yml`, или зафиксированный абсолютный путь вроде `/opt/selfpost/data`) в консолидированный корень внутри контейнера (тот же `/data`, что уже рекомендован в разделе 7.5.А). Причина — та же цель простоты бэкапа и миграции: с именованным volume для доступа к данным нужно либо идти через `docker volume inspect`/`docker cp`, либо временно монтировать volume в служебный контейнер; с bind mount данные — это просто директория на диске, видимая и доступная напрямую средствами хоста (`tar`, `rsync`, `scp`) без обращения к Docker вообще. `docker-compose.yml` должен использовать синтаксис bind mount (`./data:/data`), а не секцию `volumes:` с именованным томом.
|
|
||||||
|
|
||||||
**Оговорка про прямое копирование данных хостовым `tar` (в обход панели):** риск неконсистентного снимка SQLite (WAL-режим, незавершённая запись) существует, только если копировать директорию **во время работы контейнера** — тогда возможна гонка между записью и чтением файла. Если контейнер на момент копирования **остановлен**, наивный `tar` полностью безопасен и эквивалентен встроенному механизму — писать в SQLite в этот момент физически некому. Встроенный бэкап через кнопку панели/CLI-утилиту (раздел 7.5.А) остаётся предпочтительным способом именно потому, что не требует останавливать сервис — он использует корректный снимок SQLite (`VACUUM INTO`/Backup API) и безопасен на живом контейнере. В документации отразить оба варианта: «бэкап на лету — через панель/CLI» и «прямой `tar` директории — безопасен, если сервис перед этим остановлен».
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Развёртывание
|
|
||||||
|
|
||||||
1. Поставка — `Dockerfile` + `docker-compose.yml` + документация.
|
|
||||||
2. **Reverse-proxy обязателен** и является единым источником TLS-сертификатов. Он:
|
|
||||||
- терминирует HTTPS для панели (HTTPS **не** реализуется в коде панели);
|
|
||||||
- выпускает и автообновляет сертификаты через ACME/Let's Encrypt;
|
|
||||||
- поставляет PEM-файлы сертификата в контейнер SelfPost через **bind mount с хоста** (read-only для SelfPost, тот же принцип, что и для остального состояния — раздел 9), откуда их читает Postfix для TLS на портах 465/25 (и 587, если включён) — см. раздел 5.2.
|
|
||||||
3. **Проект не привязан к конкретному reverse-proxy.** Документация должна давать примеры интеграции для нескольких распространённых вариантов, а не навязывать один. Основной (по умолчанию) — **Apache**; остальные — как альтернативные фрагменты:
|
|
||||||
- **Apache (httpd)** — *основной сценарий, готовый `docker-compose.yml` из коробки.* Терминация HTTPS для панели через `mod_ssl` + `mod_proxy`/`mod_proxy_http`. Сертификаты — через `certbot` (Apache-плагин) либо встроенный `mod_md`. При использовании certbot PEM-файлы лежат готовыми в `/etc/letsencrypt/live/<hostname>/` на хосте и монтируются в SelfPost bind mount'ом напрямую (read-only) — прозрачный путь для потребления Postfix'ом, без промежуточного извлечения. Для `mod_md` показать, как отдать сертификат в PEM в смонтированную директорию.
|
|
||||||
- **nginx** (+ certbot/acme.sh) — PEM-файлы также лежат на диске хоста готовыми, монтируются напрямую bind mount'ом. Близкий по прозрачности к Apache+certbot.
|
|
||||||
- **Caddy** — простейшая автоматика ACME. Пишет сертификаты как PEM в своём data-каталоге на хосте; смонтировать его bind mount'ом read-only и указать пути в `TLS_CERT_FILE`/`TLS_KEY_FILE`. Нюанс: путь включает внутреннюю раскладку хранилища Caddy (с именем ACME-CA) — исполнителю **проверить актуальный путь хранения в текущей версии Caddy**.
|
|
||||||
- **Traefik** — сертификаты в `acme.json`, потребуется шаг извлечения PEM.
|
|
||||||
|
|
||||||
Для каждого варианта показать: связку в `docker-compose.yml` (или фрагмент конфига), какая директория хоста монтируется в SelfPost bind mount'ом и по каким путям (`TLS_CERT_FILE`/`TLS_KEY_FILE`). Отличия форматов хранения сертификатов у разных прокси — ключевой практический момент, который документация обязана прояснить.
|
|
||||||
4. При использовании общего hostname для панели и почты это **один сертификат**, обслуживающий оба тракта — самый простой вариант, его стоит показать как основной сценарий в каждом примере.
|
|
||||||
5. **Рекомендуемый дефолт — Apache** (готовый `docker-compose.yml`, заводящийся «из коробки»); остальные варианты — как альтернативные фрагменты. Обоснование: целевая площадка пользователя уже использует Apache, а связка Apache+certbot даёт готовые PEM-файлы без промежуточных шагов извлечения — минимум движущихся частей в потреблении сертификата Postfix'ом.
|
|
||||||
6. В docker-compose для контейнера панели/приложения заложить hardening: непривилегированный запуск, при возможности `cap_drop`, ограничение доступной ФС.
|
|
||||||
7. Документация должна включать раздел **«Требования к площадке»** — краткий чеклист инфраструктурных предпосылок (разблокированный порт 25, статический IP, PTR/rDNS), которые оператор обеспечивает до развёртывания. Без подробного разбора ограничений — только чеклист «что должно быть готово».
|
|
||||||
8. Документация должна включать раздел **«Настройка DNS»**, явно разделяя записи уровня сервера и уровня домена:
|
|
||||||
- **Уровень сервера (один раз):** PTR/rDNS для IP сервера.
|
|
||||||
- **Уровень домена (для КАЖДОГО добавленного отправляющего домена):** SPF-запись, указывающая на этот сервер; DKIM TXT-запись (берётся из панели, своя на каждый домен); DMARC-запись. Без корректных per-domain записей почта соответствующего домена будет попадать в спам. Подчеркнуть, что при добавлении нового домена в панели пользователь обязан внести его DNS-записи.
|
|
||||||
9. Документация должна включать раздел **«Прогрев IP»** — предупреждение, что свежий IP требует постепенного наращивания объёма отправки и проверки блоклистов (Spamhaus и т.п.).
|
|
||||||
10. **`docker-compose.yml` должен использовать фиксированный тег версии образа** (например, `selfpost:1.3.0`), не `:latest`. Это прямое следствие требования версионирования бэкапов (раздел 7.5.А) — без явного тега невозможно достоверно определить, какой версией был создан конкретный бэкап, и проверка совместимости при восстановлении теряет смысл.
|
|
||||||
11. Документация должна указывать **ориентировочные минимальные требования к машине**: 1 vCPU, ~512МБ–1ГБ RAM (при простое стек занимает ориентировочно 100–150МБ, с запасом под нагрузку), 8–10ГБ диска — с оговоркой, что диск растёт в первую очередь за счёт журнала отправки (ограничен `SEND_LOG_RETENTION_DAYS`) и ротируемого `mail.log` (раздел 9), а не самого приложения. Отдельно упомянуть рекомендацию настроить небольшой swap на машинах с малым объёмом RAM — дешёвая страховка на случай одновременного всплеска (бэкап + фильтрация журнала + несколько TLS-хендшейков одновременно).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Deliverables (что должно быть на выходе)
|
|
||||||
|
|
||||||
1. `Dockerfile` — сборка единого образа (Debian slim). Версия SelfPost зашивается в бинарник панели на этапе сборки (`go build -ldflags "-X main.version=..."`) и должна совпадать с тегом самого Docker-образа — используется для проверки совместимости при восстановлении бэкапа (раздел 7.5.А).
|
|
||||||
2. `supervisord.conf` — конфигурация процессов (Postfix, OpenDKIM, панель) с корректным `priority=` и стартовым скриптом-обёрткой для Postfix, ожидающим готовности milter-сокетов перед запуском (раздел 4).
|
|
||||||
3. Конфигурационные шаблоны Postfix (включая SASL, `smtpd_sender_login_maps`, milter-цепочку `smtpd_milters` с OpenDKIM + journal-milter) и OpenDKIM (`KeyTable`/`SigningTable`).
|
|
||||||
4. Исходный код панели на Go (структурированный проект), с вендоренным HTMX. Включает: HTTP-сервер панели, journal-milter (приём From/To/Subject/SASL-user), log-tailer (обновление статусов доставки), rate-limit проверку, работу с SQLite, логику полного бэкапа/восстановления и экспорта/импорта домена (раздел 7.5).
|
|
||||||
5. `docker-compose.yml` — основной с Apache как reverse-proxy; альтернативные фрагменты для nginx/Caddy/Traefik.
|
|
||||||
6. **CLI-утилита резервного копирования** (например, `selfpost-backup`) внутри образа, вызываемая через `docker exec`, — эквивалент кнопки бэкапа в панели, для скриптовых/cron-сценариев (раздел 7.5.А).
|
|
||||||
7. `README.md` — установка, требования к площадке, per-domain настройка DNS, прогрев IP, эксплуатация, **процедура полного бэкапа/восстановления и процедура экспорта/импорта домена** (с явным указанием, что оба типа файлов содержат секреты и требуют бережного обращения, как пароль). Также ссылка на репозиторий: **Codeberg — основной, GitHub — зеркало** (настраивается push-зеркалированием средствами Codeberg, без CI/скриптов со стороны проекта).
|
|
||||||
8. Первичная инициализация — реализована как secret-link при первом запуске (раздел 7.6, п. 1), отдельного скрипта для задания пароля администратора не требуется. DKIM-ключи генерируются панелью per-domain при добавлении домена, а не на этом шаге.
|
|
||||||
9. **`LICENSE` — AGPL-3.0.** Выбор осознанный: в отличие от GPL, AGPL закрывает «SaaS-лазейку» — обязывает раскрывать исходники изменённой версии, если её разворачивают как сервис, доступный через сеть, а не только при распространении копии кода. Файл лицензии — полный текст AGPL-3.0, без сокращений. В `README.md` — явное упоминание лицензии и её смысла в двух-трёх предложениях.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Инструкции исполняющему агенту
|
|
||||||
|
|
||||||
1. **Не делать git-коммитов без явной инструкции в промпте.** Изменения вносятся в рабочее дерево; коммит — только по прямому указанию.
|
|
||||||
2. После написания Go-кода — обязательно выполнять `go build` и `go vet`; исправлять все ошибки компиляции и предупреждения до завершения задачи. При наличии тестов — `go test`.
|
|
||||||
3. Проверять, что образ собирается (`docker build`) и контейнер стартует, прежде чем считать задачу выполненной.
|
|
||||||
4. Двигаться итеративно: сначала минимальный работающий скелет (сборка образа, запуск трёх процессов, пустая панель с логином), затем наращивать функциональность.
|
|
||||||
5. Требования безопасности из раздела 7.6 — не откладывать «на потом», закладывать сразу при написании соответствующих эндпоинтов.
|
|
||||||
6. Любое отклонение от ограничений раздела 2 или добавление сущностей из раздела 3 (Out of Scope) — согласовывать, не реализовывать по своей инициативе.
|
|
||||||
7. **Перед тем как приступать к реализации, агент должен предложить конкретный план (список задач/этапов) на основе этого ТЗ** и дать возможность свериться/скорректировать план до начала написания кода — не начинать кодить сразу по первому сообщению без явного подтверждения плана.
|
|
||||||
8. **Лицензии внешних Go-зависимостей** (в частности, библиотеки для milter-протокола, раздел 7.3) — проверять перед добавлением. Postfix/OpenDKIM/Cyrus SASL/supervisord запускаются отдельными процессами и не ограничивают лицензию проекта (раздел 11, п.9, AGPL-3.0) независимо от своих лицензий. Библиотеки, которые компилируются непосредственно в Go-бинарник панели, должны быть permissive (MIT/BSD/Apache-2.0) либо GPL-семейства (GPL/LGPL — совместимы с AGPL-3.0 по построению); избегать зависимостей с иными, не проверенными на совместимость лицензиями.
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
module github.com/mixeme/selfpost
|
||||||
|
|
||||||
|
go 1.26
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/emersion/go-milter v0.4.1
|
||||||
|
golang.org/x/crypto v0.54.0
|
||||||
|
modernc.org/sqlite v1.53.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/emersion/go-message v0.18.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
|
modernc.org/libc v1.73.4 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/emersion/go-message v0.18.1 h1:tfTxIoXFSFRwWaZsgnqS1DSZuGpYGzSmCZD8SK3QA2E=
|
||||||
|
github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||||
|
github.com/emersion/go-milter v0.4.1 h1:gLs9QD0zEHF8omgEw8M+aGz6iwBNpWLAcwgSur0ra4M=
|
||||||
|
github.com/emersion/go-milter v0.4.1/go.mod h1:erCQVl0mH4SX9jEvwe+wyndit0rQtmvMLH86V6NGtkI=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||||
|
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
|
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||||
|
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
|
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
||||||
|
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
||||||
|
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||||
|
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||||
|
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// passwordBytes is the number of random bytes behind a generated application
|
||||||
|
// password. 24 bytes = 192 bits of entropy, well above any brute-force concern
|
||||||
|
// for a SASL credential the panel shows exactly once (security.md).
|
||||||
|
const passwordBytes = 24
|
||||||
|
|
||||||
|
// generatePassword returns a strong, URL-safe random password for an
|
||||||
|
// application's SASL account. The panel generates it, shows it once and never
|
||||||
|
// stores the plaintext (security.md); sasldb2 keeps only the hashed form.
|
||||||
|
//
|
||||||
|
// base64url output keeps the password to a safe ASCII alphabet with no shell or
|
||||||
|
// SMTP-special characters, so it survives being typed into client configuration
|
||||||
|
// and passed to saslpasswd2 over stdin unchanged.
|
||||||
|
func generatePassword() (string, error) {
|
||||||
|
buf := make([]byte, passwordBytes)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", fmt.Errorf("generate password: %w", err)
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SASLDB manages the Cyrus SASL account database (sasldb2) the panel maintains
|
||||||
|
// for application credentials (architecture.md § Mail path). The panel is the
|
||||||
|
// only writer; Postfix reads it to authenticate SMTP clients. Accounts are
|
||||||
|
// created and removed with the standard saslpasswd2 tool.
|
||||||
|
type SASLDB struct {
|
||||||
|
path string // sasldb2 file, under /data so it survives restarts
|
||||||
|
realm string // SASL realm, so lookups match what Postfix's SASL uses
|
||||||
|
|
||||||
|
// run executes saslpasswd2. It is a field so tests can substitute a fake;
|
||||||
|
// the default shells out to the real binary via runSaslpasswd2.
|
||||||
|
run func(args []string, stdin []byte) error
|
||||||
|
|
||||||
|
// dump reads the raw sasldb2 as db_dump key/value pairs (Berkeley DB). It is
|
||||||
|
// a field so tests can substitute a fake; the default runs db_dump.
|
||||||
|
dump func(path string) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSASLDB builds a manager for the sasldb2 at path with the given realm. The
|
||||||
|
// realm should match SELFPOST_HOSTNAME so the account identity lines up with
|
||||||
|
// Postfix's SASL configuration.
|
||||||
|
func NewSASLDB(path, realm string) *SASLDB {
|
||||||
|
return &SASLDB{path: path, realm: realm, run: runSaslpasswd2, dump: dumpSASLDB}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set creates or updates an application's SASL account with the given password
|
||||||
|
// (architecture.md § Mail path). Used both at creation and when a password is
|
||||||
|
// regenerated; saslpasswd2 overwrites an existing entry in place.
|
||||||
|
//
|
||||||
|
// The password is passed to saslpasswd2 on stdin (never as an argument, so it
|
||||||
|
// cannot leak through the process table or logs). The login is passed as a
|
||||||
|
// separate argv element after being whitelisted by validateLogin — it never
|
||||||
|
// goes through a shell and is never interpolated into a command string
|
||||||
|
// (security.md).
|
||||||
|
func (s *SASLDB) Set(login, password string) error {
|
||||||
|
if err := validateLogin(login); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// -p: read the passphrase from stdin (pipe mode, no tty prompt).
|
||||||
|
// -c: create the account / set the password.
|
||||||
|
// -f: operate on our sasldb2 rather than the system default path.
|
||||||
|
// -u: the realm the account lives under.
|
||||||
|
// --: end of options, so a login can never be parsed as a flag (the
|
||||||
|
// whitelist already forbids nothing that getopt would eat, but a login
|
||||||
|
// starting with '-' is legal there — this keeps it an operand).
|
||||||
|
args := []string{"-p", "-c", "-f", s.path, "-u", s.realm, "--", login}
|
||||||
|
if err := s.run(args, []byte(password)); err != nil {
|
||||||
|
return fmt.Errorf("saslpasswd2 set %q: %w", login, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes an application's SASL account (product.md). A missing account
|
||||||
|
// is not treated as an error, so deletion is idempotent and safe to retry.
|
||||||
|
func (s *SASLDB) Delete(login string) error {
|
||||||
|
if err := validateLogin(login); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// -d: delete the account. "--" as in Set: the login is always an operand.
|
||||||
|
args := []string{"-d", "-f", s.path, "-u", s.realm, "--", login}
|
||||||
|
if err := s.run(args, nil); err != nil {
|
||||||
|
return fmt.Errorf("saslpasswd2 delete %q: %w", login, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrSecretNotFound is returned by Secret when the sasldb2 has no password entry
|
||||||
|
// for the login under this realm.
|
||||||
|
var ErrSecretNotFound = fmt.Errorf("sasl secret not found")
|
||||||
|
|
||||||
|
// Secret returns an application's stored password so it can be carried in a
|
||||||
|
// domain export and re-created verbatim on another instance (architecture.md §
|
||||||
|
// Persistence). This is possible because sasldb2 keeps the SASL secret in a
|
||||||
|
// password-equivalent form (the plaintext userPassword property, to serve
|
||||||
|
// challenge-response mechanisms) — unlike the admin's one-way bcrypt hash
|
||||||
|
// (security.md). The value is realm-independent, so the importer can re-key it
|
||||||
|
// under its own realm.
|
||||||
|
//
|
||||||
|
// It reads the database with db_dump (Berkeley DB), passing only our own file
|
||||||
|
// path as a fixed argument (no shell, no user input — security.md), and
|
||||||
|
// returns ErrSecretNotFound if the login has no entry.
|
||||||
|
func (s *SASLDB) Secret(login string) (string, error) {
|
||||||
|
if err := validateLogin(login); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
out, err := s.dump(s.path)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read sasldb2 for %q: %w", login, err)
|
||||||
|
}
|
||||||
|
secret, ok, err := parseSASLSecret(out, login, s.realm)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("parse sasldb2 for %q: %w", login, err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("login %q: %w", login, ErrSecretNotFound)
|
||||||
|
}
|
||||||
|
return secret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSASLSecret scans db_dump's byte-value output for the userPassword entry
|
||||||
|
// keyed by (login, realm). sasldb2 keys are NUL-separated tuples
|
||||||
|
// "<login>\0<realm>\0<property>"; the matching value is the stored password.
|
||||||
|
func parseSASLSecret(dump []byte, login, realm string) (string, bool, error) {
|
||||||
|
sc := bufio.NewScanner(bytes.NewReader(dump))
|
||||||
|
// sasldb2 records are tiny, but raise the line cap so a long hex line is
|
||||||
|
// never silently truncated.
|
||||||
|
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
|
||||||
|
inData := false
|
||||||
|
var keyBytes []byte
|
||||||
|
haveKey := false
|
||||||
|
for sc.Scan() {
|
||||||
|
line := sc.Text()
|
||||||
|
if !inData {
|
||||||
|
if line == "HEADER=END" {
|
||||||
|
inData = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line == "DATA=END" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Each data line is a single leading space followed by hex.
|
||||||
|
hexStr := strings.TrimPrefix(line, " ")
|
||||||
|
raw, err := hex.DecodeString(hexStr)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, fmt.Errorf("bad db_dump hex line: %w", err)
|
||||||
|
}
|
||||||
|
if !haveKey {
|
||||||
|
keyBytes = raw
|
||||||
|
haveKey = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// raw is the value for keyBytes.
|
||||||
|
haveKey = false
|
||||||
|
parts := bytes.Split(keyBytes, []byte{0})
|
||||||
|
if len(parts) != 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if string(parts[0]) == login && string(parts[1]) == realm && string(parts[2]) == "userPassword" {
|
||||||
|
return string(raw), true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := sc.Err(); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSaslpasswd2 executes the real saslpasswd2 with the given arguments and
|
||||||
|
// stdin. Arguments are passed as a fixed argv (no shell), so no user input is
|
||||||
|
// ever interpreted as a command (security.md).
|
||||||
|
func runSaslpasswd2(args []string, stdin []byte) error {
|
||||||
|
cmd := exec.Command("saslpasswd2", args...)
|
||||||
|
if stdin != nil {
|
||||||
|
cmd.Stdin = bytes.NewReader(stdin)
|
||||||
|
}
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dumpSASLDB runs db_dump to export the sasldb2 as key/value hex pairs. The path
|
||||||
|
// is our own sasldb2 file (never user input) and is passed as a fixed argument
|
||||||
|
// with no shell (security.md).
|
||||||
|
func dumpSASLDB(path string) ([]byte, error) {
|
||||||
|
cmd := exec.Command("db_dump", path)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("db_dump: %w: %s", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeRun struct {
|
||||||
|
args []string
|
||||||
|
stdin string
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeSASL() (*SASLDB, *fakeRun) {
|
||||||
|
fr := &fakeRun{}
|
||||||
|
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
||||||
|
s.run = func(args []string, stdin []byte) error {
|
||||||
|
fr.calls++
|
||||||
|
fr.args = args
|
||||||
|
fr.stdin = string(stdin)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s, fr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSASLSetPassesPasswordOnStdinNotArgv(t *testing.T) {
|
||||||
|
s, fr := newFakeSASL()
|
||||||
|
const secret = "s3cr3t-p4ss"
|
||||||
|
if err := s.Set("alerts", secret); err != nil {
|
||||||
|
t.Fatalf("Set: %v", err)
|
||||||
|
}
|
||||||
|
if fr.stdin != secret {
|
||||||
|
t.Errorf("password not passed on stdin: got %q", fr.stdin)
|
||||||
|
}
|
||||||
|
joined := strings.Join(fr.args, " ")
|
||||||
|
if strings.Contains(joined, secret) {
|
||||||
|
t.Errorf("password leaked into argv: %q", joined)
|
||||||
|
}
|
||||||
|
// Expected fixed flags and the login as its own trailing argument, behind
|
||||||
|
// "--" so it can never be parsed as an option.
|
||||||
|
want := []string{"-p", "-c", "-f", "/data/sasl/sasldb2", "-u", "mail.example.com", "--", "alerts"}
|
||||||
|
if len(fr.args) != len(want) {
|
||||||
|
t.Fatalf("args = %v, want %v", fr.args, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if fr.args[i] != want[i] {
|
||||||
|
t.Fatalf("args = %v, want %v", fr.args, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSASLDeleteArgs(t *testing.T) {
|
||||||
|
s, fr := newFakeSASL()
|
||||||
|
if err := s.Delete("alerts"); err != nil {
|
||||||
|
t.Fatalf("Delete: %v", err)
|
||||||
|
}
|
||||||
|
want := []string{"-d", "-f", "/data/sasl/sasldb2", "-u", "mail.example.com", "--", "alerts"}
|
||||||
|
if strings.Join(fr.args, " ") != strings.Join(want, " ") {
|
||||||
|
t.Errorf("delete args = %v, want %v", fr.args, want)
|
||||||
|
}
|
||||||
|
if fr.stdin != "" {
|
||||||
|
t.Errorf("delete should not send stdin, got %q", fr.stdin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeDump builds a db_dump byte-value document from key/value byte pairs, the
|
||||||
|
// same shape `db_dump <sasldb2>` emits.
|
||||||
|
func makeDump(pairs [][2][]byte) []byte {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("VERSION=3\nformat=bytevalue\ntype=hash\nHEADER=END\n")
|
||||||
|
for _, p := range pairs {
|
||||||
|
fmt.Fprintf(&b, " %s\n", hex.EncodeToString(p[0]))
|
||||||
|
fmt.Fprintf(&b, " %s\n", hex.EncodeToString(p[1]))
|
||||||
|
}
|
||||||
|
b.WriteString("DATA=END\n")
|
||||||
|
return []byte(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func saslKey(login, realm, prop string) []byte {
|
||||||
|
return []byte(login + "\x00" + realm + "\x00" + prop)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecretExtractsPassword(t *testing.T) {
|
||||||
|
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
||||||
|
s.dump = func(path string) ([]byte, error) {
|
||||||
|
if path != "/data/sasl/sasldb2" {
|
||||||
|
t.Errorf("dump path = %q", path)
|
||||||
|
}
|
||||||
|
return makeDump([][2][]byte{
|
||||||
|
{saslKey("other", "mail.example.com", "userPassword"), []byte("otherpw")},
|
||||||
|
{saslKey("alerts", "mail.example.com", "userPassword"), []byte("hunter2-pass")},
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
got, err := s.Secret("alerts")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Secret: %v", err)
|
||||||
|
}
|
||||||
|
if got != "hunter2-pass" {
|
||||||
|
t.Errorf("Secret = %q, want %q", got, "hunter2-pass")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecretRealmMismatchNotFound(t *testing.T) {
|
||||||
|
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
||||||
|
s.dump = func(string) ([]byte, error) {
|
||||||
|
// Same login but a different realm must not match.
|
||||||
|
return makeDump([][2][]byte{
|
||||||
|
{saslKey("alerts", "other.host", "userPassword"), []byte("hunter2")},
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
if _, err := s.Secret("alerts"); !errors.Is(err, ErrSecretNotFound) {
|
||||||
|
t.Errorf("Secret err = %v, want ErrSecretNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecretMissingLoginNotFound(t *testing.T) {
|
||||||
|
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
||||||
|
s.dump = func(string) ([]byte, error) {
|
||||||
|
return makeDump(nil), nil
|
||||||
|
}
|
||||||
|
if _, err := s.Secret("ghost"); !errors.Is(err, ErrSecretNotFound) {
|
||||||
|
t.Errorf("Secret err = %v, want ErrSecretNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecretRejectsInvalidLoginBeforeDump(t *testing.T) {
|
||||||
|
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
||||||
|
called := false
|
||||||
|
s.dump = func(string) ([]byte, error) { called = true; return nil, nil }
|
||||||
|
if _, err := s.Secret("bad login"); err == nil {
|
||||||
|
t.Error("Secret accepted invalid login")
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Error("db_dump invoked for an invalid login")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSASLRejectsInvalidLoginBeforeExec(t *testing.T) {
|
||||||
|
s, fr := newFakeSASL()
|
||||||
|
if err := s.Set("bad login", "pw"); err == nil {
|
||||||
|
t.Error("Set accepted invalid login")
|
||||||
|
}
|
||||||
|
if err := s.Delete("bad@login"); err == nil {
|
||||||
|
t.Error("Delete accepted invalid login")
|
||||||
|
}
|
||||||
|
if fr.calls != 0 {
|
||||||
|
t.Errorf("saslpasswd2 invoked %d times for invalid logins, want 0", fr.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
// Package app owns application accounts (product.md § Multi-domain model): the
|
||||||
|
// SASL credentials in sasldb2, the per-application sender address mode, and
|
||||||
|
// the smtpd_sender_login_maps bindings that tie each login to the addresses it
|
||||||
|
// may send from. It keeps those three stores — the SQLite registry, sasldb2
|
||||||
|
// and the Postfix map — in agreement and drives the Postfix reload.
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mixeme/selfpost/internal/postfix"
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SenderMaps is the slice of the Postfix manager the application service needs:
|
||||||
|
// rebuilding the sender_login_maps from the current bindings and reloading.
|
||||||
|
// *postfix.Postfix satisfies it; tests substitute a fake.
|
||||||
|
type SenderMaps interface {
|
||||||
|
RebuildSenderLoginMaps(bindings []postfix.Binding) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service coordinates application state across SQLite, sasldb2 and the Postfix
|
||||||
|
// sender_login_maps. Web handlers validate raw input first; the Service performs
|
||||||
|
// the domain-ownership checks that must not be skipped (security.md) and keeps
|
||||||
|
// the stores consistent.
|
||||||
|
type Service struct {
|
||||||
|
store *store.Store
|
||||||
|
sasl *SASLDB
|
||||||
|
pf SenderMaps
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService builds the application service over the shared store, the sasldb2
|
||||||
|
// manager and the Postfix manager.
|
||||||
|
func NewService(st *store.Store, sasl *SASLDB, pf SenderMaps) *Service {
|
||||||
|
return &Service{store: st, sasl: sasl, pf: pf}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns a domain's applications with their address lists (product.md).
|
||||||
|
func (s *Service) List(domainID int64) ([]store.Application, error) {
|
||||||
|
return s.store.ListApplicationsByDomain(domainID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns one application by id (store.ErrApplicationNotFound if absent).
|
||||||
|
func (s *Service) Get(id int64) (store.Application, error) {
|
||||||
|
return s.store.GetApplication(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create adds an application to a domain: it validates the login and (in list
|
||||||
|
// mode) that every address belongs to the domain (security.md), generates a
|
||||||
|
// strong password, writes the SASL account and rebuilds the sender map (spec
|
||||||
|
// 7.2.5). The generated password is returned so the caller can show it exactly
|
||||||
|
// once (security.md) — it is never persisted in plaintext.
|
||||||
|
//
|
||||||
|
// The registry row is written first so its UNIQUE constraint is the sole arbiter
|
||||||
|
// of a duplicate login (avoiding a check-then-act race and, crucially, avoiding
|
||||||
|
// clobbering an existing account's password in sasldb2). If the SASL write or
|
||||||
|
// the map rebuild fails, everything is rolled back so we never leave an
|
||||||
|
// application the panel cannot fully account for.
|
||||||
|
func (s *Service) Create(domainID int64, login, mode string, rawAddresses []string) (store.Application, string, error) {
|
||||||
|
addresses, err := s.validateForDomain(domainID, login, mode, rawAddresses)
|
||||||
|
if err != nil {
|
||||||
|
return store.Application{}, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
password, err := generatePassword()
|
||||||
|
if err != nil {
|
||||||
|
return store.Application{}, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
a, err := s.store.AddApplication(domainID, login, mode, addresses)
|
||||||
|
if err != nil {
|
||||||
|
return store.Application{}, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.sasl.Set(login, password); err != nil {
|
||||||
|
s.rollbackCreate(a.ID, "") // login has no SASL account yet; nothing to unset
|
||||||
|
return store.Application{}, "", err
|
||||||
|
}
|
||||||
|
if err := s.Resync(); err != nil {
|
||||||
|
s.rollbackCreate(a.ID, login)
|
||||||
|
return store.Application{}, "", err
|
||||||
|
}
|
||||||
|
return a, password, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rollbackCreate best-effort undoes a partially created application after a
|
||||||
|
// downstream failure: it removes the SASL account (if one was written) and the
|
||||||
|
// registry row. Errors here are subordinate to the original failure the caller
|
||||||
|
// returns.
|
||||||
|
func (s *Service) rollbackCreate(id int64, login string) {
|
||||||
|
if login != "" {
|
||||||
|
_ = s.sasl.Delete(login)
|
||||||
|
}
|
||||||
|
_, _ = s.store.DeleteApplication(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportApplication re-creates an application from a domain-export file
|
||||||
|
// (architecture.md § Persistence): it validates the login and (in list mode)
|
||||||
|
// that every address belongs to the domain, inserts the registry row and
|
||||||
|
// writes the SASL account with the imported password verbatim, re-keyed under
|
||||||
|
// this instance's realm so the credential keeps working without regeneration.
|
||||||
|
// It deliberately does not rebuild the sender map — the caller (domain import)
|
||||||
|
// does that once after all applications are in — and returns
|
||||||
|
// store.ErrLoginExists if the login collides.
|
||||||
|
func (s *Service) ImportApplication(domainID int64, login, mode string, rawAddresses []string, password string) error {
|
||||||
|
addresses, err := s.validateForDomain(domainID, login, mode, rawAddresses)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateImportedPassword(password); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a, err := s.store.AddApplication(domainID, login, mode, addresses)
|
||||||
|
if err != nil {
|
||||||
|
return err // ErrLoginExists surfaces to the caller as a friendly message
|
||||||
|
}
|
||||||
|
if err := s.sasl.Set(login, password); err != nil {
|
||||||
|
_, _ = s.store.DeleteApplication(a.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secret returns an application's stored password for a domain export
|
||||||
|
// (architecture.md § Persistence). See SASLDB.Secret for why this is possible
|
||||||
|
// and safe.
|
||||||
|
func (s *Service) Secret(login string) (string, error) {
|
||||||
|
return s.sasl.Secret(login)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMode switches an application's address mode / list and rebuilds the
|
||||||
|
// sender map (product.md). The login and password are untouched. Addresses are
|
||||||
|
// re-validated against the application's domain.
|
||||||
|
func (s *Service) UpdateMode(id int64, mode string, rawAddresses []string) error {
|
||||||
|
a, err := s.store.GetApplication(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
addresses, err := s.validateForDomain(a.DomainID, a.Login, mode, rawAddresses)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.store.UpdateApplicationMode(id, mode, addresses); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.Resync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegeneratePassword issues a fresh password for an existing application (spec
|
||||||
|
// 7.2.9). The old password is invalidated by overwriting the SASL account; the
|
||||||
|
// address mode and bindings are unchanged, so no map rebuild is needed. The new
|
||||||
|
// password is returned to be shown once.
|
||||||
|
func (s *Service) RegeneratePassword(id int64) (string, error) {
|
||||||
|
a, err := s.store.GetApplication(id)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
password, err := generatePassword()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := s.sasl.Set(a.Login, password); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return password, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes an application: its SASL account, its registry row (and address
|
||||||
|
// rows via cascade) and its sender-map bindings, then reloads Postfix (spec
|
||||||
|
// 7.2.8). The domain and other applications are untouched.
|
||||||
|
//
|
||||||
|
// The order matches domain deletion: the SASL account goes first, while the
|
||||||
|
// login is still in the registry. Dropping the row first would, on a
|
||||||
|
// saslpasswd2 failure, leave an account that can still authenticate to Postfix
|
||||||
|
// but that the panel no longer knows about — an orphan no operator can see or
|
||||||
|
// remove. Failing before the row is deleted is recoverable: the application is
|
||||||
|
// still listed and the delete can be retried.
|
||||||
|
func (s *Service) Delete(id int64) error {
|
||||||
|
a, err := s.store.GetApplication(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.sasl.Delete(a.Login); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.store.DeleteApplication(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Drop the application's level-2 limit, if any (guide § Rate limiting);
|
||||||
|
// rate_limits has no cascade of its own.
|
||||||
|
if err := s.store.DeleteRateLimit(store.RateLimitScopeApp, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.Resync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimit returns the application-level differentiated rate limit (guide §
|
||||||
|
// Rate limiting), and whether one is configured, for the application's edit
|
||||||
|
// form.
|
||||||
|
func (s *Service) RateLimit(appID int64) (store.RateLimit, bool, error) {
|
||||||
|
return s.store.GetRateLimit(store.RateLimitScopeApp, appID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveRateLimit stores the application-level trusted-IP override (guide § Rate
|
||||||
|
// limiting). The caller has validated the IPs and numbers (security.md); the
|
||||||
|
// milter reads the row live, so no reload is needed.
|
||||||
|
func (s *Service) SaveRateLimit(appID int64, ips []string, maxMessages, windowSeconds int) error {
|
||||||
|
return s.store.SetRateLimit(store.RateLimit{
|
||||||
|
Scope: store.RateLimitScopeApp,
|
||||||
|
RefID: appID,
|
||||||
|
AllowedIPs: ips,
|
||||||
|
MaxMessages: maxMessages,
|
||||||
|
WindowSeconds: windowSeconds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearRateLimit removes the application-level rate limit (guide § Rate
|
||||||
|
// limiting).
|
||||||
|
func (s *Service) ClearRateLimit(appID int64) error {
|
||||||
|
return s.store.DeleteRateLimit(store.RateLimitScopeApp, appID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeDomainSASL removes the SASL accounts of every application bound to a
|
||||||
|
// domain. It must be called before the domain's registry rows are cascade-
|
||||||
|
// deleted, while the logins are still known (product.md). The registry rows and
|
||||||
|
// the sender map are handled by the domain deletion path; this only clears
|
||||||
|
// sasldb2, which has no cascade of its own.
|
||||||
|
func (s *Service) PurgeDomainSASL(domainID int64) error {
|
||||||
|
logins, err := s.store.ListLoginsByDomain(domainID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, login := range logins {
|
||||||
|
if err := s.sasl.Delete(login); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resync rebuilds smtpd_sender_login_maps from the full set of application
|
||||||
|
// bindings and reloads Postfix (architecture.md § Mail path). It is the single
|
||||||
|
// idempotent apply path shared by create/edit/delete and is also reachable
|
||||||
|
// from the manual reload button; it doubles as recovery if the map ever drifts
|
||||||
|
// from the database.
|
||||||
|
func (s *Service) Resync() error {
|
||||||
|
bindings, err := s.store.ListBindings()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pfBindings := make([]postfix.Binding, 0, len(bindings))
|
||||||
|
for _, b := range bindings {
|
||||||
|
pfBindings = append(pfBindings, postfix.Binding{Address: b.Address, Login: b.Login})
|
||||||
|
}
|
||||||
|
return s.pf.RebuildSenderLoginMaps(pfBindings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateForDomain resolves the domain, validates the login and address mode,
|
||||||
|
// and — in list mode — validates that every address belongs to the domain
|
||||||
|
// (security.md). It returns the cleaned address list, which is empty in wildcard
|
||||||
|
// mode. Resolving the domain here also confirms it exists before any write.
|
||||||
|
func (s *Service) validateForDomain(domainID int64, login, mode string, rawAddresses []string) ([]string, error) {
|
||||||
|
d, err := s.store.GetDomain(domainID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := validateLogin(login); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := validateAddressMode(mode); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if mode == store.AddressModeWildcard {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return parseAddresses(rawAddresses, d.Name)
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/postfix"
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeMaps records the last set of bindings passed to a rebuild and can be told
|
||||||
|
// to fail, so we can exercise the rollback paths.
|
||||||
|
type fakeMaps struct {
|
||||||
|
last []postfix.Binding
|
||||||
|
calls int
|
||||||
|
failNext bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeMaps) RebuildSenderLoginMaps(b []postfix.Binding) error {
|
||||||
|
f.calls++
|
||||||
|
if f.failNext {
|
||||||
|
f.failNext = false
|
||||||
|
return errors.New("boom")
|
||||||
|
}
|
||||||
|
f.last = b
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saslRecorder is a fake sasldb2 backend recording set/delete calls.
|
||||||
|
type saslRecorder struct {
|
||||||
|
set map[string]string // login -> password
|
||||||
|
deleted []string
|
||||||
|
failNext bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newServiceHarness(t *testing.T) (*Service, *store.Store, *saslRecorder, *fakeMaps) {
|
||||||
|
t.Helper()
|
||||||
|
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { st.Close() })
|
||||||
|
|
||||||
|
rec := &saslRecorder{set: map[string]string{}}
|
||||||
|
sasl := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
||||||
|
sasl.run = func(args []string, stdin []byte) error {
|
||||||
|
if rec.failNext {
|
||||||
|
rec.failNext = false
|
||||||
|
return errors.New("saslpasswd2 failed")
|
||||||
|
}
|
||||||
|
// args end with the login; a "-d" anywhere means delete.
|
||||||
|
login := args[len(args)-1]
|
||||||
|
del := false
|
||||||
|
for _, a := range args {
|
||||||
|
if a == "-d" {
|
||||||
|
del = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if del {
|
||||||
|
rec.deleted = append(rec.deleted, login)
|
||||||
|
delete(rec.set, login)
|
||||||
|
} else {
|
||||||
|
rec.set[login] = string(stdin)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
maps := &fakeMaps{}
|
||||||
|
return NewService(st, sasl, maps), st, rec, maps
|
||||||
|
}
|
||||||
|
|
||||||
|
func addDomain(t *testing.T, st *store.Store, name string) store.Domain {
|
||||||
|
t.Helper()
|
||||||
|
d, err := st.AddDomain(name, "selfpost")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddDomain: %v", err)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceCreateWildcard(t *testing.T) {
|
||||||
|
svc, st, rec, maps := newServiceHarness(t)
|
||||||
|
d := addDomain(t, st, "example.com")
|
||||||
|
|
||||||
|
a, pw, err := svc.Create(d.ID, "alerts", store.AddressModeWildcard, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create: %v", err)
|
||||||
|
}
|
||||||
|
if a.Login != "alerts" {
|
||||||
|
t.Errorf("login = %q", a.Login)
|
||||||
|
}
|
||||||
|
if rec.set["alerts"] != pw {
|
||||||
|
t.Errorf("sasl password %q != returned %q", rec.set["alerts"], pw)
|
||||||
|
}
|
||||||
|
if len(maps.last) != 1 || maps.last[0].Address != "@example.com" || maps.last[0].Login != "alerts" {
|
||||||
|
t.Errorf("map bindings = %+v", maps.last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceCreateListValidatesDomain(t *testing.T) {
|
||||||
|
svc, st, _, _ := newServiceHarness(t)
|
||||||
|
d := addDomain(t, st, "example.com")
|
||||||
|
|
||||||
|
// A cross-domain address is rejected before anything is written.
|
||||||
|
_, _, err := svc.Create(d.ID, "app1", store.AddressModeList, []string{"a@evil.com"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Create accepted cross-domain address")
|
||||||
|
}
|
||||||
|
apps, _ := st.ListApplicationsByDomain(d.ID)
|
||||||
|
if len(apps) != 0 {
|
||||||
|
t.Errorf("application persisted despite validation failure: %+v", apps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceCreateDuplicateLogin(t *testing.T) {
|
||||||
|
svc, st, _, _ := newServiceHarness(t)
|
||||||
|
d := addDomain(t, st, "example.com")
|
||||||
|
if _, _, err := svc.Create(d.ID, "dup", store.AddressModeWildcard, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _, err := svc.Create(d.ID, "dup", store.AddressModeWildcard, nil)
|
||||||
|
if !errors.Is(err, store.ErrLoginExists) {
|
||||||
|
t.Fatalf("duplicate create = %v, want ErrLoginExists", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceCreateRollsBackOnSASLFailure(t *testing.T) {
|
||||||
|
svc, st, rec, _ := newServiceHarness(t)
|
||||||
|
d := addDomain(t, st, "example.com")
|
||||||
|
|
||||||
|
rec.failNext = true // saslpasswd2 fails on the first (set) call
|
||||||
|
_, _, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected Create to fail when SASL set fails")
|
||||||
|
}
|
||||||
|
apps, _ := st.ListApplicationsByDomain(d.ID)
|
||||||
|
if len(apps) != 0 {
|
||||||
|
t.Errorf("registry row not rolled back: %+v", apps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceCreateRollsBackOnMapFailure(t *testing.T) {
|
||||||
|
svc, st, rec, maps := newServiceHarness(t)
|
||||||
|
d := addDomain(t, st, "example.com")
|
||||||
|
|
||||||
|
maps.failNext = true
|
||||||
|
_, _, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected Create to fail when map rebuild fails")
|
||||||
|
}
|
||||||
|
apps, _ := st.ListApplicationsByDomain(d.ID)
|
||||||
|
if len(apps) != 0 {
|
||||||
|
t.Errorf("registry row not rolled back: %+v", apps)
|
||||||
|
}
|
||||||
|
if _, ok := rec.set["app1"]; ok {
|
||||||
|
t.Errorf("SASL account not rolled back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceDelete(t *testing.T) {
|
||||||
|
svc, st, rec, _ := newServiceHarness(t)
|
||||||
|
d := addDomain(t, st, "example.com")
|
||||||
|
a, _, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.Delete(a.ID); err != nil {
|
||||||
|
t.Fatalf("Delete: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := rec.set["app1"]; ok {
|
||||||
|
t.Error("SASL account not deleted")
|
||||||
|
}
|
||||||
|
if len(rec.deleted) != 1 || rec.deleted[0] != "app1" {
|
||||||
|
t.Errorf("deleted logins = %v", rec.deleted)
|
||||||
|
}
|
||||||
|
apps, _ := st.ListApplicationsByDomain(d.ID)
|
||||||
|
if len(apps) != 0 {
|
||||||
|
t.Errorf("application not deleted: %+v", apps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If sasldb2 cannot be updated the application must stay in the registry: an
|
||||||
|
// account that still authenticates but has no panel row is invisible to the
|
||||||
|
// operator and cannot be deleted again.
|
||||||
|
func TestServiceDeleteKeepsRowWhenSASLFails(t *testing.T) {
|
||||||
|
svc, st, rec, _ := newServiceHarness(t)
|
||||||
|
d := addDomain(t, st, "example.com")
|
||||||
|
a, _, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec.failNext = true // saslpasswd2 -d fails
|
||||||
|
if err := svc.Delete(a.ID); err == nil {
|
||||||
|
t.Fatal("Delete reported success although the SASL account was not removed")
|
||||||
|
}
|
||||||
|
apps, _ := st.ListApplicationsByDomain(d.ID)
|
||||||
|
if len(apps) != 1 {
|
||||||
|
t.Fatalf("registry row dropped while the SASL account can still authenticate: %+v", apps)
|
||||||
|
}
|
||||||
|
if _, ok := rec.set["app1"]; !ok {
|
||||||
|
t.Fatal("SASL account gone despite the failure — the harness no longer proves the ordering")
|
||||||
|
}
|
||||||
|
// The delete is retryable now that the row is still there.
|
||||||
|
if err := svc.Delete(a.ID); err != nil {
|
||||||
|
t.Fatalf("retried Delete: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := rec.set["app1"]; ok {
|
||||||
|
t.Error("SASL account not deleted on retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceUpdateMode(t *testing.T) {
|
||||||
|
svc, st, _, maps := newServiceHarness(t)
|
||||||
|
d := addDomain(t, st, "example.com")
|
||||||
|
a, _, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.UpdateMode(a.ID, store.AddressModeList, []string{"alerts@example.com"}); err != nil {
|
||||||
|
t.Fatalf("UpdateMode: %v", err)
|
||||||
|
}
|
||||||
|
if len(maps.last) != 1 || maps.last[0].Address != "alerts@example.com" {
|
||||||
|
t.Errorf("map after mode switch = %+v", maps.last)
|
||||||
|
}
|
||||||
|
got, _ := st.GetApplication(a.ID)
|
||||||
|
if got.AddressMode != store.AddressModeList || len(got.Addresses) != 1 {
|
||||||
|
t.Errorf("stored app after switch = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceRegeneratePassword(t *testing.T) {
|
||||||
|
svc, st, rec, _ := newServiceHarness(t)
|
||||||
|
d := addDomain(t, st, "example.com")
|
||||||
|
a, pw1, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pw2, err := svc.RegeneratePassword(a.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RegeneratePassword: %v", err)
|
||||||
|
}
|
||||||
|
if pw1 == pw2 {
|
||||||
|
t.Error("regenerated password equals the old one")
|
||||||
|
}
|
||||||
|
if rec.set["app1"] != pw2 {
|
||||||
|
t.Errorf("sasl password not updated to new value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportApplicationWritesRowAndSASL(t *testing.T) {
|
||||||
|
svc, st, rec, maps := newServiceHarness(t)
|
||||||
|
d := addDomain(t, st, "example.com")
|
||||||
|
|
||||||
|
if err := svc.ImportApplication(d.ID, "mailer", store.AddressModeList,
|
||||||
|
[]string{"a@example.com"}, "imported-pw"); err != nil {
|
||||||
|
t.Fatalf("ImportApplication: %v", err)
|
||||||
|
}
|
||||||
|
// Registry row and SASL account written with the imported password verbatim.
|
||||||
|
apps, _ := st.ListApplicationsByDomain(d.ID)
|
||||||
|
if len(apps) != 1 || apps[0].Login != "mailer" {
|
||||||
|
t.Fatalf("apps = %+v", apps)
|
||||||
|
}
|
||||||
|
if rec.set["mailer"] != "imported-pw" {
|
||||||
|
t.Errorf("SASL password = %q, want the imported one", rec.set["mailer"])
|
||||||
|
}
|
||||||
|
// Import does not rebuild the sender map itself (the caller batches that).
|
||||||
|
if maps.calls != 0 {
|
||||||
|
t.Errorf("ImportApplication rebuilt the map %d times, want 0", maps.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportApplicationRejectsBadInput(t *testing.T) {
|
||||||
|
svc, st, rec, _ := newServiceHarness(t)
|
||||||
|
d := addDomain(t, st, "example.com")
|
||||||
|
|
||||||
|
// Empty password.
|
||||||
|
if err := svc.ImportApplication(d.ID, "mailer", store.AddressModeWildcard, nil, ""); err == nil {
|
||||||
|
t.Error("accepted empty imported password")
|
||||||
|
}
|
||||||
|
// Password with an embedded newline would truncate on the saslpasswd2 stdin.
|
||||||
|
if err := svc.ImportApplication(d.ID, "mailer", store.AddressModeWildcard, nil, "line1\nline2"); err == nil {
|
||||||
|
t.Error("accepted password with control characters")
|
||||||
|
}
|
||||||
|
// Cross-domain address.
|
||||||
|
if err := svc.ImportApplication(d.ID, "mailer", store.AddressModeList, []string{"x@evil.com"}, "pw"); err == nil {
|
||||||
|
t.Error("accepted cross-domain address")
|
||||||
|
}
|
||||||
|
if apps, _ := st.ListApplicationsByDomain(d.ID); len(apps) != 0 {
|
||||||
|
t.Errorf("rows persisted despite validation failure: %+v", apps)
|
||||||
|
}
|
||||||
|
if len(rec.set) != 0 {
|
||||||
|
t.Errorf("SASL accounts written despite validation failure: %v", rec.set)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServicePurgeDomainSASL(t *testing.T) {
|
||||||
|
svc, st, rec, _ := newServiceHarness(t)
|
||||||
|
d := addDomain(t, st, "example.com")
|
||||||
|
if _, _, err := svc.Create(d.ID, "app-a", store.AddressModeWildcard, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := svc.Create(d.ID, "app-b", store.AddressModeWildcard, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.PurgeDomainSASL(d.ID); err != nil {
|
||||||
|
t.Fatalf("PurgeDomainSASL: %v", err)
|
||||||
|
}
|
||||||
|
if len(rec.set) != 0 {
|
||||||
|
t.Errorf("SASL accounts remain after purge: %v", rec.set)
|
||||||
|
}
|
||||||
|
if len(rec.deleted) != 2 {
|
||||||
|
t.Errorf("deleted %d logins, want 2", len(rec.deleted))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
minLoginLen = 3
|
||||||
|
maxLoginLen = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
// validateLogin enforces a strict server-side whitelist for the SASL login
|
||||||
|
// (security.md). It intentionally excludes '@': the login is stored in sasldb2,
|
||||||
|
// where '@' separates the user from the realm, so allowing it would change the
|
||||||
|
// account's identity. Client validation is never trusted.
|
||||||
|
//
|
||||||
|
// The login is the one piece of user input that is passed to saslpasswd2 as a
|
||||||
|
// command argument (never through a shell, security.md); this whitelist is what
|
||||||
|
// makes that safe.
|
||||||
|
func validateLogin(login string) error {
|
||||||
|
if len(login) < minLoginLen || len(login) > maxLoginLen {
|
||||||
|
return fmt.Errorf("login must be %d-%d characters", minLoginLen, maxLoginLen)
|
||||||
|
}
|
||||||
|
for _, r := range login {
|
||||||
|
lower := r >= 'a' && r <= 'z'
|
||||||
|
upper := r >= 'A' && r <= 'Z'
|
||||||
|
digit := r >= '0' && r <= '9'
|
||||||
|
if !lower && !upper && !digit && r != '.' && r != '-' && r != '_' {
|
||||||
|
return fmt.Errorf("login may contain only letters, digits, '.', '-' and '_'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateImportedPassword guards a password taken from a domain-export file
|
||||||
|
// (architecture.md § Persistence) before it is written to sasldb2. Our own
|
||||||
|
// exports carry base64url passwords, but the file is untrusted input, so we
|
||||||
|
// reject an empty value or one containing control characters — saslpasswd2
|
||||||
|
// reads the passphrase from stdin and a newline would silently truncate it
|
||||||
|
// (security.md).
|
||||||
|
func validateImportedPassword(password string) error {
|
||||||
|
if password == "" {
|
||||||
|
return fmt.Errorf("imported application password is empty")
|
||||||
|
}
|
||||||
|
if len(password) > 1024 {
|
||||||
|
return fmt.Errorf("imported application password is too long")
|
||||||
|
}
|
||||||
|
for _, r := range password {
|
||||||
|
if r < 0x20 || r == 0x7f {
|
||||||
|
return fmt.Errorf("imported application password contains control characters")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateAddressMode checks the submitted mode is one of the two known values.
|
||||||
|
func validateAddressMode(mode string) error {
|
||||||
|
if mode != store.AddressModeWildcard && mode != store.AddressModeList {
|
||||||
|
return fmt.Errorf("invalid address mode")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeAddress lower-cases and trims a sender address. Both the local part
|
||||||
|
// and domain are treated case-insensitively for the ownership check and for the
|
||||||
|
// generated map, matching how addresses are compared elsewhere.
|
||||||
|
func normalizeAddress(addr string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(addr))
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateSenderAddress enforces that a list-mode address is well-formed and,
|
||||||
|
// critically, belongs to the application's own domain (security.md). The domain
|
||||||
|
// check is done here, before anything is written to a config file — not left to
|
||||||
|
// smtpd_sender_login_maps to catch at delivery time. domain must already be a
|
||||||
|
// validated, normalised domain name.
|
||||||
|
func validateSenderAddress(addr, domain string) error {
|
||||||
|
at := strings.LastIndexByte(addr, '@')
|
||||||
|
if at < 0 {
|
||||||
|
return fmt.Errorf("%q is not a valid email address", addr)
|
||||||
|
}
|
||||||
|
local, host := addr[:at], addr[at+1:]
|
||||||
|
if host != domain {
|
||||||
|
return fmt.Errorf("%q does not belong to domain %s", addr, domain)
|
||||||
|
}
|
||||||
|
if err := validateLocalPart(local); err != nil {
|
||||||
|
return fmt.Errorf("%q: %w", addr, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateLocalPart applies a conservative whitelist to the part before '@'.
|
||||||
|
// This is deliberately stricter than RFC 5321 (no quoted local parts) so the
|
||||||
|
// value is always safe to write verbatim into the Postfix map (security.md).
|
||||||
|
func validateLocalPart(local string) error {
|
||||||
|
if local == "" {
|
||||||
|
return fmt.Errorf("missing the part before '@'")
|
||||||
|
}
|
||||||
|
if local[0] == '.' || local[len(local)-1] == '.' {
|
||||||
|
return fmt.Errorf("local part must not start or end with '.'")
|
||||||
|
}
|
||||||
|
for i := 0; i < len(local); i++ {
|
||||||
|
c := local[i]
|
||||||
|
lower := c >= 'a' && c <= 'z'
|
||||||
|
digit := c >= '0' && c <= '9'
|
||||||
|
if !lower && !digit && c != '.' && c != '-' && c != '_' && c != '+' {
|
||||||
|
return fmt.Errorf("local part may contain only lower-case letters, digits, '.', '-', '_' and '+'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAddresses normalises, validates and de-duplicates a list of submitted
|
||||||
|
// sender addresses for a list-mode application. It requires at least one address
|
||||||
|
// and that each belongs to domain. The returned slice is de-duplicated but keeps
|
||||||
|
// submission order stable for display; the store sorts on read.
|
||||||
|
func parseAddresses(raw []string, domain string) ([]string, error) {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
out := make([]string, 0, len(raw))
|
||||||
|
for _, r := range raw {
|
||||||
|
addr := normalizeAddress(r)
|
||||||
|
if addr == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := validateSenderAddress(addr, domain); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if seen[addr] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[addr] = true
|
||||||
|
out = append(out, addr)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, fmt.Errorf("list mode requires at least one address")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestValidateLogin(t *testing.T) {
|
||||||
|
good := []string{"alerts", "prod-server", "app_1", "News.Letter"}
|
||||||
|
for _, l := range good {
|
||||||
|
if err := validateLogin(l); err != nil {
|
||||||
|
t.Errorf("validateLogin(%q) = %v, want nil", l, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bad := []string{
|
||||||
|
"ab", // too short
|
||||||
|
"alerts@example.com", // '@' not allowed (sasldb realm separator)
|
||||||
|
"has space", // whitespace
|
||||||
|
"inject\nline", // newline
|
||||||
|
"comma,login", // map value separator
|
||||||
|
"colon:login", // config separator
|
||||||
|
}
|
||||||
|
for _, l := range bad {
|
||||||
|
if err := validateLogin(l); err == nil {
|
||||||
|
t.Errorf("validateLogin(%q) = nil, want error", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateSenderAddressDomainOwnership(t *testing.T) {
|
||||||
|
// The critical check (security.md): an address must belong to the app's domain.
|
||||||
|
if err := validateSenderAddress("alerts@example.com", "example.com"); err != nil {
|
||||||
|
t.Errorf("same-domain address rejected: %v", err)
|
||||||
|
}
|
||||||
|
if err := validateSenderAddress("alerts@evil.com", "example.com"); err == nil {
|
||||||
|
t.Error("cross-domain address accepted, want rejection")
|
||||||
|
}
|
||||||
|
// A trailing-domain trick must not pass as ownership.
|
||||||
|
if err := validateSenderAddress("a@notexample.com", "example.com"); err == nil {
|
||||||
|
t.Error("suffix-domain address accepted, want rejection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateSenderAddressForm(t *testing.T) {
|
||||||
|
bad := []string{
|
||||||
|
"noat.example.com", // no '@'
|
||||||
|
"@example.com", // empty local part
|
||||||
|
".dot@example.com", // leading dot
|
||||||
|
"dot.@example.com", // trailing dot
|
||||||
|
"in ject@example.com", // space
|
||||||
|
"quote\"@example.com", // disallowed char
|
||||||
|
}
|
||||||
|
for _, a := range bad {
|
||||||
|
if err := validateSenderAddress(a, "example.com"); err == nil {
|
||||||
|
t.Errorf("validateSenderAddress(%q) = nil, want error", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAddresses(t *testing.T) {
|
||||||
|
// Normalises case, trims, drops blanks, de-duplicates.
|
||||||
|
got, err := parseAddresses([]string{" Alerts@Example.com ", "", "noreply@example.com", "alerts@example.com"}, "example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseAddresses: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0] != "alerts@example.com" || got[1] != "noreply@example.com" {
|
||||||
|
t.Fatalf("parseAddresses = %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty list in list mode is an error.
|
||||||
|
if _, err := parseAddresses([]string{"", " "}, "example.com"); err == nil {
|
||||||
|
t.Error("empty address list accepted, want error")
|
||||||
|
}
|
||||||
|
// A cross-domain address rejects the whole submission.
|
||||||
|
if _, err := parseAddresses([]string{"ok@example.com", "bad@other.com"}, "example.com"); err == nil {
|
||||||
|
t.Error("cross-domain address in list accepted, want error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratePasswordStrength(t *testing.T) {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
p, err := generatePassword()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generatePassword: %v", err)
|
||||||
|
}
|
||||||
|
if len(p) < 30 {
|
||||||
|
t.Fatalf("password too short: %d chars", len(p))
|
||||||
|
}
|
||||||
|
if seen[p] {
|
||||||
|
t.Fatalf("duplicate password generated: %q", p)
|
||||||
|
}
|
||||||
|
seen[p] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
// Package backup implements SelfPost's full-server backup and the restore
|
||||||
|
// version guard (architecture.md § Persistence). A full backup is a
|
||||||
|
// gzip-compressed tar of the consolidated persistent state under /data — the
|
||||||
|
// SQLite database (as a consistent snapshot), the per-domain DKIM keys, the
|
||||||
|
// SASL database, and the Postfix queue — plus docker-compose.yml, .env, and
|
||||||
|
// certs/ from the operator's deploy directory, and a manifest recording the
|
||||||
|
// SelfPost version that produced it. Postfix delivery logs under log/ are
|
||||||
|
// excluded (diagnostics, not state).
|
||||||
|
//
|
||||||
|
// Restore is not a separate code path in the panel: a backup is extracted into
|
||||||
|
// the operator's project directory (data/, docker-compose.yml, .env, certs/)
|
||||||
|
// before first start. The archive carries everything needed to bring the
|
||||||
|
// instance back — DKIM keys, sasldb2, sender map, queue, and deploy files —
|
||||||
|
// so the operator only adjusts hostname or proxy settings on a new host. The
|
||||||
|
// restore-specific steps the panel runs are CheckRestore, which refuses to
|
||||||
|
// boot if the manifest's version does not match the running binary so
|
||||||
|
// schema/format skew between versions cannot silently corrupt state
|
||||||
|
// (architecture.md § Persistence), and a one-time Resync of OpenDKIM's tables
|
||||||
|
// and the Postfix sender map from SQLite on that first boot, so any drift
|
||||||
|
// between the archive and the database is healed before mail flows. If
|
||||||
|
// on-disk state drifts again later — for example after a manual edit under
|
||||||
|
// /data — the Status page's "Reload configuration" button runs the same
|
||||||
|
// Resync on demand.
|
||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite" // pure-Go SQLite driver, for the VACUUM INTO snapshot
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormatFull identifies a full-server backup manifest.
|
||||||
|
const FormatFull = "selfpost-full-backup"
|
||||||
|
|
||||||
|
// ManifestName is the manifest's filename inside the data directory. After
|
||||||
|
// restore extraction it lives at data/manifest.json under the project root;
|
||||||
|
// CheckRestore reads it from the /data bind mount.
|
||||||
|
const ManifestName = "manifest.json"
|
||||||
|
|
||||||
|
// DataArchivePrefix is the path prefix for every /data entry in the archive.
|
||||||
|
const DataArchivePrefix = "data/"
|
||||||
|
|
||||||
|
// ComposeFileName and EnvFileName are required deploy files at the archive root.
|
||||||
|
const (
|
||||||
|
ComposeFileName = "docker-compose.yml"
|
||||||
|
EnvFileName = ".env"
|
||||||
|
CertsDirName = "certs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manifest is the small JSON document embedded in every backup archive. Its
|
||||||
|
// Version is the single fact that makes restore safe: the panel refuses to
|
||||||
|
// boot a data directory whose manifest version does not match its own binary
|
||||||
|
// (architecture.md § Persistence).
|
||||||
|
type Manifest struct {
|
||||||
|
Format string `json:"format"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Params configures a backup. DataDir is the consolidated state root (/data);
|
||||||
|
// DBPath is the live SQLite file within it, snapshotted consistently rather than
|
||||||
|
// copied byte-for-byte while it may be mid-write; Version is stamped into the
|
||||||
|
// manifest; DeployRoot is the host project directory mounted read-only (holds
|
||||||
|
// docker-compose.yml, .env, and optionally certs/). OnWarn is called for
|
||||||
|
// non-fatal issues such as a missing certs/ directory.
|
||||||
|
type Params struct {
|
||||||
|
DataDir string
|
||||||
|
DBPath string
|
||||||
|
Version string
|
||||||
|
DeployRoot string
|
||||||
|
OnWarn func(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// excludedFromArchive lists the data-directory entries a backup never carries.
|
||||||
|
// The live database files are replaced by a consistent VACUUM INTO snapshot
|
||||||
|
// written under the canonical name; the setup token is transient bootstrap
|
||||||
|
// state; a stale manifest from a previous restore must not be re-captured (a
|
||||||
|
// fresh one is written instead); a "tls" directory under /data is skipped when
|
||||||
|
// an operator pointed TLS_CERT_FILE inside /data; and "log" is Postfix's raw
|
||||||
|
// delivery log plus its fourteen rotated files, which is diagnostic output, not
|
||||||
|
// state to restore, and by far the largest thing under /data.
|
||||||
|
var excludedFromArchive = map[string]bool{
|
||||||
|
"selfpost.db": true,
|
||||||
|
"selfpost.db-wal": true,
|
||||||
|
"selfpost.db-shm": true,
|
||||||
|
"selfpost.db-journal": true,
|
||||||
|
"setup-token": true,
|
||||||
|
"tls": true,
|
||||||
|
"log": true,
|
||||||
|
ManifestName: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create writes a gzip-compressed tar backup to w. Archive layout:
|
||||||
|
//
|
||||||
|
// data/manifest.json, data/selfpost.db, data/<rest of /data>
|
||||||
|
// docker-compose.yml, .env, certs/...
|
||||||
|
//
|
||||||
|
// Extract the archive into an empty project directory, then docker compose up.
|
||||||
|
func Create(w io.Writer, p Params) error {
|
||||||
|
if p.DataDir == "" || p.DBPath == "" {
|
||||||
|
return fmt.Errorf("backup: DataDir and DBPath are required")
|
||||||
|
}
|
||||||
|
if p.DeployRoot == "" {
|
||||||
|
return fmt.Errorf("backup: DeployRoot is required (mount the project directory at SELFPOST_DEPLOY_ROOT)")
|
||||||
|
}
|
||||||
|
if err := validateDeployRoot(p.DeployRoot); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot, cleanup, err := snapshotDB(p.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
gz := gzip.NewWriter(w)
|
||||||
|
tw := tar.NewWriter(gz)
|
||||||
|
|
||||||
|
manifest := Manifest{
|
||||||
|
Format: FormatFull,
|
||||||
|
Version: p.Version,
|
||||||
|
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
manifestJSON, err := json.MarshalIndent(manifest, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("backup: encode manifest: %w", err)
|
||||||
|
}
|
||||||
|
if err := writeTarBytes(tw, DataArchivePrefix+ManifestName, 0o600, manifestJSON); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// The consistent SQLite snapshot, under the canonical filename the panel
|
||||||
|
// opens on start (the live file and its WAL/SHM are excluded from the walk).
|
||||||
|
if err := writeTarFile(tw, DataArchivePrefix+"selfpost.db", 0o640, snapshot); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := addTree(tw, p.DataDir, DataArchivePrefix, excludedFromArchive); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := addDeployFiles(tw, p.DeployRoot, p.OnWarn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tw.Close(); err != nil {
|
||||||
|
return fmt.Errorf("backup: close tar: %w", err)
|
||||||
|
}
|
||||||
|
if err := gz.Close(); err != nil {
|
||||||
|
return fmt.Errorf("backup: close gzip: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateDeployRoot checks that the operator project directory is mounted and
|
||||||
|
// contains the files a full backup requires. Call before streaming a response.
|
||||||
|
func ValidateDeployRoot(deployRoot string) error {
|
||||||
|
if deployRoot == "" {
|
||||||
|
return fmt.Errorf("backup: DeployRoot is required (mount the project directory at SELFPOST_DEPLOY_ROOT)")
|
||||||
|
}
|
||||||
|
return validateDeployRoot(deployRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDeployRoot(deployRoot string) error {
|
||||||
|
for _, name := range []string{ComposeFileName, EnvFileName} {
|
||||||
|
path := filepath.Join(deployRoot, name)
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return fmt.Errorf("backup: deploy root %q is missing %s: %w", deployRoot, name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func addDeployFiles(tw *tar.Writer, deployRoot string, onWarn func(string)) error {
|
||||||
|
for _, name := range []string{ComposeFileName, EnvFileName} {
|
||||||
|
src := filepath.Join(deployRoot, name)
|
||||||
|
info, err := os.Stat(src)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("backup: stat deploy file %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
return fmt.Errorf("backup: deploy file %s is not a regular file", name)
|
||||||
|
}
|
||||||
|
if err := writeTarFile(tw, name, info.Mode().Perm(), src); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
certsDir := filepath.Join(deployRoot, CertsDirName)
|
||||||
|
if _, err := os.Stat(certsDir); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
if onWarn != nil {
|
||||||
|
onWarn("certs/ not found in deploy root; backup will not include TLS material")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("backup: stat %s: %w", CertsDirName, err)
|
||||||
|
}
|
||||||
|
return addTree(tw, certsDir, CertsDirName+"/", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addTree walks root and adds every regular file (and directory, to preserve
|
||||||
|
// empty ones and modes) to tw under archivePrefix + path relative to root.
|
||||||
|
// When exclude is non-nil, top-level names relative to root are skipped.
|
||||||
|
// Non-regular, non-directory entries (symlinks, sockets) are skipped.
|
||||||
|
func addTree(tw *tar.Writer, root, archivePrefix string, exclude map[string]bool) error {
|
||||||
|
return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(root, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rel == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name := archivePrefix + filepath.ToSlash(rel)
|
||||||
|
if exclude != nil {
|
||||||
|
top := strings.Split(filepath.ToSlash(rel), "/")[0]
|
||||||
|
if exclude[top] {
|
||||||
|
if d.IsDir() {
|
||||||
|
return fs.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := d.Info()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case d.IsDir():
|
||||||
|
hdr := &tar.Header{
|
||||||
|
Typeflag: tar.TypeDir,
|
||||||
|
Name: name + "/",
|
||||||
|
Mode: int64(info.Mode().Perm()),
|
||||||
|
ModTime: info.ModTime(),
|
||||||
|
}
|
||||||
|
return tw.WriteHeader(hdr)
|
||||||
|
case info.Mode().IsRegular():
|
||||||
|
return writeTarFile(tw, name, info.Mode().Perm(), path)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeTarBytes writes an in-memory file entry.
|
||||||
|
func writeTarBytes(tw *tar.Writer, name string, mode int64, data []byte) error {
|
||||||
|
hdr := &tar.Header{
|
||||||
|
Typeflag: tar.TypeReg,
|
||||||
|
Name: name,
|
||||||
|
Mode: mode,
|
||||||
|
Size: int64(len(data)),
|
||||||
|
ModTime: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
if err := tw.WriteHeader(hdr); err != nil {
|
||||||
|
return fmt.Errorf("backup: write header %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if _, err := tw.Write(data); err != nil {
|
||||||
|
return fmt.Errorf("backup: write %s: %w", name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeTarFile streams a file from disk into the archive under name.
|
||||||
|
func writeTarFile(tw *tar.Writer, name string, mode fs.FileMode, srcPath string) error {
|
||||||
|
f, err := os.Open(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("backup: open %s: %w", srcPath, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("backup: stat %s: %w", srcPath, err)
|
||||||
|
}
|
||||||
|
hdr := &tar.Header{
|
||||||
|
Typeflag: tar.TypeReg,
|
||||||
|
Name: name,
|
||||||
|
Mode: int64(mode.Perm()),
|
||||||
|
Size: info.Size(),
|
||||||
|
ModTime: info.ModTime(),
|
||||||
|
}
|
||||||
|
if err := tw.WriteHeader(hdr); err != nil {
|
||||||
|
return fmt.Errorf("backup: write header %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(tw, f); err != nil {
|
||||||
|
return fmt.Errorf("backup: copy %s: %w", name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshotDB produces a consistent copy of the SQLite database at dbPath using
|
||||||
|
// VACUUM INTO, so the backup captures a coherent point-in-time image even while
|
||||||
|
// the panel is writing to the live file under WAL. It returns the snapshot path
|
||||||
|
// and a cleanup function the caller must defer.
|
||||||
|
func snapshotDB(dbPath string) (path string, cleanup func(), err error) {
|
||||||
|
dir, err := os.MkdirTemp("", "selfpost-backup-")
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("backup: temp dir: %w", err)
|
||||||
|
}
|
||||||
|
cleanup = func() { _ = os.RemoveAll(dir) }
|
||||||
|
target := filepath.Join(dir, "selfpost.db")
|
||||||
|
|
||||||
|
// A short busy timeout lets VACUUM INTO wait out a brief writer rather than
|
||||||
|
// failing immediately if the panel happens to be mid-write.
|
||||||
|
db, err := sql.Open("sqlite", "file:"+dbPath+"?_pragma=busy_timeout(5000)")
|
||||||
|
if err != nil {
|
||||||
|
cleanup()
|
||||||
|
return "", nil, fmt.Errorf("backup: open database: %w", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
// VACUUM INTO takes a string literal, not a bound parameter. target is a path
|
||||||
|
// we generated (never user input); single quotes are doubled defensively.
|
||||||
|
stmt := "VACUUM INTO '" + strings.ReplaceAll(target, "'", "''") + "'"
|
||||||
|
if _, err := db.Exec(stmt); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return "", nil, fmt.Errorf("backup: snapshot database: %w", err)
|
||||||
|
}
|
||||||
|
return target, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckRestore enforces the backup version guard (architecture.md §
|
||||||
|
// Persistence). If manifestPath exists (a backup was extracted into the data
|
||||||
|
// directory), its version must match binaryVersion or the panel refuses to
|
||||||
|
// start, telling the operator which image tag to use. On a match the manifest
|
||||||
|
// is consumed (deleted) so it guards only the first boot after a restore and
|
||||||
|
// never blocks a later in-place image upgrade, and restored is true so the
|
||||||
|
// caller can heal drifted daemon maps once. Absence of the manifest is the
|
||||||
|
// normal case and returns restored == false with a nil error.
|
||||||
|
func CheckRestore(manifestPath, binaryVersion string) (restored bool, err error) {
|
||||||
|
data, err := os.ReadFile(manifestPath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil // ordinary start, not a restore
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("backup: read restore manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var m Manifest
|
||||||
|
if err := json.Unmarshal(data, &m); err != nil {
|
||||||
|
return false, fmt.Errorf("backup: restore manifest %s is not valid JSON: %w", manifestPath, err)
|
||||||
|
}
|
||||||
|
if m.Format != FormatFull {
|
||||||
|
return false, fmt.Errorf("backup: %s is not a SelfPost full backup manifest (format %q)", manifestPath, m.Format)
|
||||||
|
}
|
||||||
|
if m.Version != binaryVersion {
|
||||||
|
return false, fmt.Errorf(
|
||||||
|
"backup: this backup was created by SelfPost %s but this image is %s — restore into the matching image (selfpost:%s)",
|
||||||
|
m.Version, binaryVersion, m.Version)
|
||||||
|
}
|
||||||
|
// Version matches: consume the manifest so subsequent normal starts (and
|
||||||
|
// in-place upgrades) are not gated by it.
|
||||||
|
if err := os.Remove(manifestPath); err != nil {
|
||||||
|
return false, fmt.Errorf("backup: consume restore manifest: %w", err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedDataDir builds a realistic /data tree: a migrated SQLite database plus the
|
||||||
|
// DKIM key, SASL and transient files a backup must include or exclude. It also
|
||||||
|
// seeds a deploy root beside data/ with compose, .env, and certs/.
|
||||||
|
func seedDataDir(t *testing.T) (dataDir, dbPath, deployRoot string) {
|
||||||
|
t.Helper()
|
||||||
|
deployRoot = t.TempDir()
|
||||||
|
dataDir = filepath.Join(deployRoot, "data")
|
||||||
|
if err := os.MkdirAll(dataDir, 0o750); err != nil {
|
||||||
|
t.Fatalf("mkdir data: %v", err)
|
||||||
|
}
|
||||||
|
dbPath = filepath.Join(dataDir, "selfpost.db")
|
||||||
|
|
||||||
|
st, err := store.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.AddDomain("example.com", "selfpost"); err != nil {
|
||||||
|
t.Fatalf("add domain: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.Close(); err != nil {
|
||||||
|
t.Fatalf("close store: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(dataDir, "opendkim", "keys", "example.com", "selfpost.private"), "PRIVATE KEY")
|
||||||
|
writeFile(t, filepath.Join(dataDir, "sasl", "sasldb2"), "SASLDB")
|
||||||
|
writeFile(t, filepath.Join(dataDir, "postfix", "sender_login_maps"), "@example.com login")
|
||||||
|
writeFile(t, filepath.Join(dataDir, "postfix", "queue", "deferred", "sample"), "queue-file")
|
||||||
|
// Transient files that must NOT be archived.
|
||||||
|
writeFile(t, filepath.Join(dataDir, "setup-token"), "secret-token")
|
||||||
|
writeFile(t, filepath.Join(dataDir, "selfpost.db-wal"), "wal")
|
||||||
|
writeFile(t, filepath.Join(dataDir, "selfpost.db-shm"), "shm")
|
||||||
|
// Postfix's delivery log and its rotated files: diagnostic output, not
|
||||||
|
// state, and the bulkiest thing under /data.
|
||||||
|
writeFile(t, filepath.Join(dataDir, "log", "mail.log"), "Aug 8 07:26:41 mail postfix/smtp[1]: ABC: to=<a@example.net>, status=sent (ok)")
|
||||||
|
writeFile(t, filepath.Join(dataDir, "log", "mail.log.1"), "older")
|
||||||
|
writeFile(t, filepath.Join(deployRoot, ComposeFileName), "services:\n selfpost:\n image: test\n")
|
||||||
|
writeFile(t, filepath.Join(deployRoot, EnvFileName), "SELFPOST_HOSTNAME=mail.example.com\n")
|
||||||
|
writeFile(t, filepath.Join(deployRoot, CertsDirName, "fullchain.pem"), "CERT")
|
||||||
|
writeFile(t, filepath.Join(deployRoot, CertsDirName, "privkey.pem"), "KEY")
|
||||||
|
return dataDir, dbPath, deployRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readArchive returns the set of regular-file entries (name -> content) in a
|
||||||
|
// gzip tar produced by Create.
|
||||||
|
func readArchive(t *testing.T, data []byte) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
gz, err := gzip.NewReader(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("gzip: %v", err)
|
||||||
|
}
|
||||||
|
tr := tar.NewReader(gz)
|
||||||
|
out := map[string]string{}
|
||||||
|
for {
|
||||||
|
hdr, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("tar next: %v", err)
|
||||||
|
}
|
||||||
|
if hdr.Typeflag != tar.TypeReg {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b, err := io.ReadAll(tr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("tar read %s: %v", hdr.Name, err)
|
||||||
|
}
|
||||||
|
out[hdr.Name] = string(b)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateIncludesStateExcludesTransient(t *testing.T) {
|
||||||
|
dataDir, dbPath, deployRoot := seedDataDir(t)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := Create(&buf, Params{DataDir: dataDir, DBPath: dbPath, Version: "1.2.3", DeployRoot: deployRoot}); err != nil {
|
||||||
|
t.Fatalf("Create: %v", err)
|
||||||
|
}
|
||||||
|
files := readArchive(t, buf.Bytes())
|
||||||
|
|
||||||
|
// Present.
|
||||||
|
for _, name := range []string{
|
||||||
|
DataArchivePrefix + ManifestName,
|
||||||
|
DataArchivePrefix + "selfpost.db",
|
||||||
|
DataArchivePrefix + "opendkim/keys/example.com/selfpost.private",
|
||||||
|
DataArchivePrefix + "sasl/sasldb2",
|
||||||
|
DataArchivePrefix + "postfix/sender_login_maps",
|
||||||
|
DataArchivePrefix + "postfix/queue/deferred/sample",
|
||||||
|
ComposeFileName,
|
||||||
|
EnvFileName,
|
||||||
|
CertsDirName + "/fullchain.pem",
|
||||||
|
CertsDirName + "/privkey.pem",
|
||||||
|
} {
|
||||||
|
if _, ok := files[name]; !ok {
|
||||||
|
t.Errorf("archive missing %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Excluded.
|
||||||
|
for _, name := range []string{
|
||||||
|
DataArchivePrefix + "setup-token",
|
||||||
|
DataArchivePrefix + "selfpost.db-wal",
|
||||||
|
DataArchivePrefix + "selfpost.db-shm",
|
||||||
|
DataArchivePrefix + "log/mail.log",
|
||||||
|
DataArchivePrefix + "log/mail.log.1",
|
||||||
|
} {
|
||||||
|
if _, ok := files[name]; ok {
|
||||||
|
t.Errorf("archive should not contain %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manifest is well-formed and carries the version.
|
||||||
|
var m Manifest
|
||||||
|
if err := json.Unmarshal([]byte(files[DataArchivePrefix+ManifestName]), &m); err != nil {
|
||||||
|
t.Fatalf("manifest json: %v", err)
|
||||||
|
}
|
||||||
|
if m.Format != FormatFull || m.Version != "1.2.3" {
|
||||||
|
t.Errorf("manifest = %+v, want format=%s version=1.2.3", m, FormatFull)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The archived selfpost.db is a real, openable SQLite snapshot with our data.
|
||||||
|
snapPath := filepath.Join(t.TempDir(), "restored.db")
|
||||||
|
if err := os.WriteFile(snapPath, []byte(files[DataArchivePrefix+"selfpost.db"]), 0o640); err != nil {
|
||||||
|
t.Fatalf("write snapshot: %v", err)
|
||||||
|
}
|
||||||
|
st, err := store.Open(snapPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open snapshot: %v", err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
domains, err := st.ListDomains()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list domains from snapshot: %v", err)
|
||||||
|
}
|
||||||
|
if len(domains) != 1 || domains[0].Name != "example.com" {
|
||||||
|
t.Errorf("snapshot domains = %+v, want one example.com", domains)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateRequiresDeployRoot(t *testing.T) {
|
||||||
|
dataDir, dbPath, _ := seedDataDir(t)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := Create(&buf, Params{DataDir: dataDir, DBPath: dbPath, Version: "1.0.0"}); err == nil {
|
||||||
|
t.Fatal("Create without DeployRoot succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeManifest(t *testing.T, dir, format, version string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(dir, ManifestName)
|
||||||
|
b, _ := json.Marshal(Manifest{Format: format, Version: version, CreatedAt: "now"})
|
||||||
|
if err := os.WriteFile(path, b, 0o600); err != nil {
|
||||||
|
t.Fatalf("write manifest: %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckRestoreNoManifestIsNormalStart(t *testing.T) {
|
||||||
|
restored, err := CheckRestore(filepath.Join(t.TempDir(), "manifest.json"), "1.0.0")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("CheckRestore with no manifest = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if restored {
|
||||||
|
t.Error("CheckRestore with no manifest reported a restore")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckRestoreMatchConsumesManifest(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := writeManifest(t, dir, FormatFull, "1.0.0")
|
||||||
|
restored, err := CheckRestore(path, "1.0.0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckRestore matching = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if !restored {
|
||||||
|
t.Fatal("CheckRestore matching did not report a restore")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("manifest should be consumed after a matching restore, stat err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckRestoreVersionMismatchRefusesAndKeeps(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := writeManifest(t, dir, FormatFull, "1.0.0")
|
||||||
|
restored, err := CheckRestore(path, "2.0.0")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("CheckRestore mismatch = nil, want error")
|
||||||
|
}
|
||||||
|
if restored {
|
||||||
|
t.Error("CheckRestore mismatch reported a restore")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "1.0.0") || !strings.Contains(err.Error(), "2.0.0") {
|
||||||
|
t.Errorf("error should name both versions: %v", err)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(path); statErr != nil {
|
||||||
|
t.Errorf("manifest must be kept on mismatch so the operator can switch images: %v", statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckRestoreWrongFormatRejected(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := writeManifest(t, dir, "something-else", "1.0.0")
|
||||||
|
restored, err := CheckRestore(path, "1.0.0")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("CheckRestore accepted a non-backup manifest")
|
||||||
|
}
|
||||||
|
if restored {
|
||||||
|
t.Error("CheckRestore wrong format reported a restore")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Package buildinfo exposes build-time metadata stamped via -ldflags.
|
||||||
|
package buildinfo
|
||||||
|
|
||||||
|
// Version is the SelfPost release. It is set at build time with
|
||||||
|
//
|
||||||
|
// -ldflags "-X github.com/mixeme/selfpost/internal/buildinfo.Version=<tag>"
|
||||||
|
//
|
||||||
|
// and must match the Docker image tag; it is used for the backup/restore
|
||||||
|
// compatibility check (architecture.md § Persistence). Defaults to "dev" for local/unstamped builds.
|
||||||
|
var Version = "dev"
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
// Package dnscheck performs the read-only DNS lookups behind the panel's
|
||||||
|
// deliverability checks: forward-confirmed reverse DNS (FCrDNS) for the
|
||||||
|
// server's own hostname, and the DKIM/SPF/DMARC records published for each
|
||||||
|
// sending domain.
|
||||||
|
//
|
||||||
|
// Every lookup is bounded by a timeout and results are cached, because DNS is
|
||||||
|
// the one part of the status page that talks to the network: a slow or dead
|
||||||
|
// resolver must degrade a single card to "could not check", never hang the
|
||||||
|
// page. Nothing here changes state — the panel only reports what the world can
|
||||||
|
// see about this server.
|
||||||
|
package dnscheck
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/health"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// lookupTimeout bounds all the lookups of a single check together, so a
|
||||||
|
// dead resolver costs one wait and not one per record type.
|
||||||
|
lookupTimeout = 5 * time.Second
|
||||||
|
// serverTTL/domainTTL are how long a cached result stays fresh. The
|
||||||
|
// server's own hostname/PTR is cheap and rarely changes; a domain's
|
||||||
|
// records are three lookups, and the operator has just published them, so
|
||||||
|
// a few minutes plus an explicit Re-check button is the right trade.
|
||||||
|
serverTTL = time.Minute
|
||||||
|
domainTTL = 5 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// Result is the outcome of one published-record check.
|
||||||
|
type Result struct {
|
||||||
|
Status health.Status
|
||||||
|
// Detail is a full sentence for the operator: what was found and, when
|
||||||
|
// something is wrong, what to do about it.
|
||||||
|
Detail string
|
||||||
|
// Records is what was actually found in DNS, shown verbatim so the
|
||||||
|
// operator can compare it with what they published.
|
||||||
|
Records []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server is the state of the server's own name in DNS: the addresses
|
||||||
|
// SELFPOST_HOSTNAME resolves to, and whether their PTR records point back at
|
||||||
|
// it. Receiving servers weigh this heavily, so a mismatch is an error.
|
||||||
|
type Server struct {
|
||||||
|
Hostname string
|
||||||
|
IPs []string // forward-resolved addresses, reused for the SPF check
|
||||||
|
PTR Result
|
||||||
|
CheckedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain is the published-DNS state of one sending domain.
|
||||||
|
type Domain struct {
|
||||||
|
Name string
|
||||||
|
DKIM Result
|
||||||
|
SPF Result
|
||||||
|
DMARC Result
|
||||||
|
DMARCReportAuth Result // zero when external rua= is not used
|
||||||
|
Overall health.Status
|
||||||
|
CheckedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query describes the domain to check. ExpectedDKIM is the TXT value the panel
|
||||||
|
// tells the operator to publish (domain.DKIMRecord.Value), so the check
|
||||||
|
// compares DNS against the key this server actually signs with. Hostname and
|
||||||
|
// ServerIPs identify this server and come from a preceding Server check.
|
||||||
|
type Query struct {
|
||||||
|
Name string
|
||||||
|
Selector string
|
||||||
|
ExpectedDKIM string
|
||||||
|
Hostname string
|
||||||
|
ServerIPs []string
|
||||||
|
DMARCReportEmail string // resolved rua= destination; empty = policy-only template
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolver is the slice of *net.Resolver this package uses, as an interface so
|
||||||
|
// tests can drive the checks without touching the network.
|
||||||
|
type resolver interface {
|
||||||
|
LookupTXT(ctx context.Context, name string) ([]string, error)
|
||||||
|
LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error)
|
||||||
|
LookupAddr(ctx context.Context, addr string) ([]string, error)
|
||||||
|
LookupMX(ctx context.Context, name string) ([]*net.MX, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checker runs the checks and caches their results. The zero value is not
|
||||||
|
// usable; call New.
|
||||||
|
type Checker struct {
|
||||||
|
resolver resolver
|
||||||
|
timeout time.Duration
|
||||||
|
serverTTL time.Duration
|
||||||
|
domainTTL time.Duration
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
servers map[string]cached[Server]
|
||||||
|
domains map[string]cached[Domain]
|
||||||
|
}
|
||||||
|
|
||||||
|
type cached[T any] struct {
|
||||||
|
value T
|
||||||
|
expires time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a Checker querying the given recursive resolvers (empty means
|
||||||
|
// DefaultResolvers) with the package's default timeout and cache lifetimes.
|
||||||
|
// The lookups deliberately bypass the system resolver — see externalResolver.
|
||||||
|
func New(resolvers []string) *Checker {
|
||||||
|
return newChecker(newExternalResolver(resolvers), lookupTimeout, serverTTL, domainTTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newChecker(r resolver, timeout, srvTTL, domTTL time.Duration) *Checker {
|
||||||
|
return &Checker{
|
||||||
|
resolver: r,
|
||||||
|
timeout: timeout,
|
||||||
|
serverTTL: srvTTL,
|
||||||
|
domainTTL: domTTL,
|
||||||
|
servers: make(map[string]cached[Server]),
|
||||||
|
domains: make(map[string]cached[Domain]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server checks the server's own hostname. force skips the cache, for the
|
||||||
|
// Re-check button.
|
||||||
|
func (c *Checker) Server(hostname string, force bool) Server {
|
||||||
|
if !force {
|
||||||
|
c.mu.Lock()
|
||||||
|
entry, ok := c.servers[hostname]
|
||||||
|
c.mu.Unlock()
|
||||||
|
if ok && time.Now().Before(entry.expires) {
|
||||||
|
return entry.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
|
||||||
|
defer cancel()
|
||||||
|
srv := c.checkServer(ctx, hostname)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.servers[hostname] = cached[Server]{value: srv, expires: srv.CheckedAt.Add(c.serverTTL)}
|
||||||
|
c.mu.Unlock()
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain checks one sending domain's published records. force skips the cache,
|
||||||
|
// for the Re-check button on the domain page.
|
||||||
|
func (c *Checker) Domain(q Query, force bool) Domain {
|
||||||
|
if !force {
|
||||||
|
c.mu.Lock()
|
||||||
|
entry, ok := c.domains[q.Name]
|
||||||
|
c.mu.Unlock()
|
||||||
|
if ok && time.Now().Before(entry.expires) {
|
||||||
|
return entry.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
|
||||||
|
defer cancel()
|
||||||
|
d := c.checkDomain(ctx, q)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.domains[q.Name] = cached[Domain]{value: d, expires: d.CheckedAt.Add(c.domainTTL)}
|
||||||
|
c.mu.Unlock()
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forget drops a domain's cached result, so the next page view re-checks it.
|
||||||
|
// Used when a domain is removed or re-imported.
|
||||||
|
func (c *Checker) Forget(domainName string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
delete(c.domains, domainName)
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDomain runs the three record checks concurrently: they are independent,
|
||||||
|
// and in series three timeouts would stack up into a page that looks hung.
|
||||||
|
func (c *Checker) checkDomain(ctx context.Context, q Query) Domain {
|
||||||
|
d := Domain{Name: q.Name, CheckedAt: time.Now()}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(4)
|
||||||
|
go func() { defer wg.Done(); d.DKIM = c.checkDKIM(ctx, q) }()
|
||||||
|
go func() { defer wg.Done(); d.SPF = c.checkSPF(ctx, q) }()
|
||||||
|
go func() { defer wg.Done(); d.DMARC = c.checkDMARC(ctx, q) }()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
hub := EmailDomain(q.DMARCReportEmail)
|
||||||
|
if hub != "" && !strings.EqualFold(hub, q.Name) {
|
||||||
|
d.DMARCReportAuth = c.checkReportAuth(ctx, hub)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
wg.Wait()
|
||||||
|
d.Overall = health.Worst(d.DKIM.Status, d.SPF.Status, d.DMARC.Status, d.DMARCReportAuth.Status)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupTXT wraps the resolver's TXT lookup, separating "the name does not
|
||||||
|
// exist / has no TXT records" (a finding to report) from "the lookup failed"
|
||||||
|
// (nothing was learned).
|
||||||
|
func (c *Checker) lookupTXT(ctx context.Context, name string) (records []string, found bool, err error) {
|
||||||
|
txt, err := c.resolver.LookupTXT(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
var dnsErr *net.DNSError
|
||||||
|
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if len(txt) == 0 {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
return txt, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupFailed is the shared shape for "the resolver did not answer": unknown,
|
||||||
|
// not an accusation against the domain's configuration.
|
||||||
|
func lookupFailed(what string, err error) Result {
|
||||||
|
return Result{
|
||||||
|
Status: health.StatusUnknown,
|
||||||
|
Detail: "Could not check " + what + ": the DNS lookup failed (" + dnsErrorText(err) + "). Try Re-check in a moment.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dnsErrorText reduces a resolver error to its message, without the internals
|
||||||
|
// (Go wraps the name and server into the string form).
|
||||||
|
func dnsErrorText(err error) string {
|
||||||
|
var dnsErr *net.DNSError
|
||||||
|
if errors.As(err, &dnsErr) {
|
||||||
|
if dnsErr.IsTimeout {
|
||||||
|
return "timed out"
|
||||||
|
}
|
||||||
|
return dnsErr.Err
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeName lowercases a DNS name and drops the root label, so a PTR answer
|
||||||
|
// ("mail.example.com.") compares equal to a configured hostname.
|
||||||
|
func normalizeName(name string) string {
|
||||||
|
return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user