65 Commits
Author SHA1 Message Date
mixandClaude Opus 5.5 f20c942d64 release: 2.15.0: server-dmarc-hint, upstream-tls-verify
test / test (push) Waiting to run
The server's own name gets a DMARC record on Settings and Health, and an
inbound domain's upstream a Verified TLS mode (Postfix secure, with
Debian's CA bundle). Migration 0014; schema 14. The pins (compose, README,
guide), the guide's backup example, the planned cuts and the two roadmap
rows. No tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:28:24 +03:00
mixandClaude Opus 5.5 6b4106f60e upstream-tls-verify: Verified TLS to an inbound domain's upstream
Owner, 2026-10-18: "Оба предложения делаем"; the mockup is c10cd78. The
security audit's M7: Required is Postfix's encrypt level, which checks no
certificate - it keeps the mail from a listener, not from one who answers
in the upstream's place.

- A fourth TLS mode, Verified, stored as Postfix's level secure (migration
  0014 rebuilds inbound_transports with the wider CHECK; schema 14) and
  written to the TLS policy map like the others. Postfix matches the
  certificate against the next hop's name (smtp_tls_secure_cert_match =
  nexthop, dot-nexthop, the default; postconf(5)), so Verified is refused
  for an upstream given by address (checkSecureHost).
- Postfix's SMTP client trusts Debian's ca-certificates bundle
  (smtp_tls_CAfile): Postfix trusts none by default (DEF_SMTP_TLS_CA_FILE
  "", DEF_TLS_APPEND_DEF_CA 0). The levels may and encrypt only log the
  verdict, so mail to recipients' MX is unchanged.
- The page: the select's fourth option, a help line under it, the lead
  "over verified TLS", the list's tag "verified"; Help word for word with
  the mockup; guide § Inbound relay, with a posttls-finger command to try
  an upstream first.

Tests: TestVerifiedTLSNeedsTheUpstreamsName, TestRenderInboundMapsSecure,
TestUpstreamTLSModeSecure; schema tests at 14. e2e
inbound_upstream_verified_tls: an address refused, a name saved as
"[sink]:25 secure" in tls_policy, the page says verified, the CA file set
and present, and posttls-finger -l secure with it calls the stand's
self-signed port 25 "Untrusted" (the stand has no TLS upstream). make e2e
(65 passed) and go vet + go test (26 packages) on the test server for this
tree. Tried against the live upstream from the 2.14.5 container:
mc-ent.mixfed.ru gives "Verified TLS connection established".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:27:37 +03:00
mixandClaude Opus 5.5 c10cd78eff design: upstream-tls-verify - a Verified TLS mode to the upstream
Owner, 2026-10-18: "Оба предложения делаем" (the roadmap's
upstream-tls-verify, from the security audit's M7). The upstream's TLS
select gets a fourth choice between Required and Off: Verified, which also
checks the upstream's certificate - issued by a trusted authority for the
host given, so the host is given by name. A help line under the select
says how Required and Verified differ; the domain's lead says "over
verified TLS"; the list's TLS tag "verified" (green, as required);
Help's Upstream topic describes Verified and when the mail waits.
check.py: 0 violations; outlines unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:07:51 +03:00
mixandClaude Opus 5.5 554e007f22 server-dmarc-hint: the server's own name gets a DMARC record
Owner, 2026-10-18: "Оба предложения делаем"; the mockup is 8b2dc8f. The
security audit's S3 left a product side: the panel showed the DKIM and SPF
records of the server's own name, not a DMARC one, and without it a
receiver does not refuse mail forged in that name - a false password-reset
message among it.

dnscheck.ServerMail checks a third record (Mail.DMARC, checkServerDMARC):
the sending-domain DMARC check held to more - missing is an error, as a
missing DKIM or SPF record of this name is (the page draws it as a
warning: notifications still go), p=none a warning ("weak": it refuses
nothing), quarantine or reject ok - and Overall includes it. The record to
publish is ServerMailDMARC, v=DMARC1; p=reject: only the panel sends as
the name, signed with its key, from the address its SPF names, so reject
costs nothing. Settings › Notification mail draws it after SPF
(view.ServerMailDMARC, folded when published), Health's card has the row,
Re-check DNS re-reads it with the others.

Tests: TestServerMailDMARC (missing, p=none, quarantine, reject); the
Settings and Health tests publish and expect three records. Help word for
word with the mockup; guide § Notifications; roadmap; CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:05:39 +03:00
mixandClaude Opus 5.5 8b2dc8f40f design: server-dmarc-hint - the server's own name gets a DMARC record
Owner, 2026-10-18: "Оба предложения делаем" (the roadmap's
server-dmarc-hint, from the security audit's S3). Settings › Notification
mail gets a third record after DKIM and SPF: DMARC at _dmarc.<hostname>,
value v=DMARC1; p=reject, with a help line - without it a receiver does
not refuse mail forged in the server's name, a false password-reset
message included; only the panel sends as this name, signed, so reject is
safe. Health's Notification mail card gets the DMARC row; Help's
Notifications topic names all three records. check.py: 0 violations;
outlines.json rewritten for the third record of the Settings box
(record×2 -> record×3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 09:56:44 +03:00
mixandClaude Opus 5.5 c736a7e144 release: 2.14.5: ETRN discarded silently
test / test (push) Waiting to run
No migration; schema 13. The pins (compose, README, guide), the guide's
backup example and the planned cuts. No tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 01:01:53 +03:00
mixandClaude Opus 5.5 8eec6728d4 postfix: ETRN discarded from EHLO silently
After the 2.14.4 rollout the test server's mail.log held "discarding EHLO
keywords: ETRN" for every connection on every port: smtpd logs a discard
unless the mask has EHLO_MASK_SILENT (Postfix 3.10.13, src/smtpd/smtpd.c:2016;
src/global/ehlo_mask.c:88 "SILENT-DISCARD"; postconf(5)
smtpd_discard_ehlo_keywords, "Specify the silent-discard pseudo keyword to
prevent this action from being logged"). smtpd_discard_ehlo_keywords is now
silent-discard,etrn; ETRN is still refused (smtpd_etrn_restrictions=reject)
and still not offered.

make e2e (64 passed; checkPostfixHardening reads the value back,
testInboundRelay still sees no ETRN in EHLO and 554) and go vet + go test
(26 packages) on the test server for this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 01:01:41 +03:00
mixandClaude Opus 5.5 58356fb4d4 release: 2.14.4: the follow-ups of the security audit of 2026-10-17
test / test (push) Waiting to run
Stages 0-6 of docs/security-audit-2026-10-17.md in one cut: the high A2
(the user form asks for the account password), the seven mediums (M1, M2,
M3, W1, I1, I2, A1) and the lows and nits the plan names, with three
read-only security reviews of the fixes and their findings fixed. Stage H
(the test server's host) is the owner's.

No migration; schema 13. Upgrading: give the TLS key to group 10997;
copy antispam/ again where the sidecar runs (rspamd 4.2.2, a third file);
set TRUSTED_PROXY_CIDR behind Apache on the host if unset.

make e2e (64 passed) and go vet + go test (26 packages) on the test server
for 32f6ed6, the tree this release commit adds only pins and notes to. The
pins (compose, README, guide), the guide's backup example, the planned
cuts, the roadmap row and the plan's stages marked shipped. No tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 23:12:55 +03:00
mixandClaude Opus 5.5 32f6ed626d findings of the security review of stages 2-6
The review of 4433688..0808779 (read-only, a separate agent) found one
medium and some lows; all fixed here.

Medium, proven by the reviewer: A1's userKey put the typed username into
the limiter map whole, and the public forms had no body bound beyond
net/http's 10 MiB for a parsed form - each refused request kept up to
10 MiB in memory for fifteen minutes. userKey now holds a 96-bit hash of
the folded name, and sign-in, reset, a reset link's page and setup read at
most 64 KiB (limitForm, http.MaxBytesReader).
TestPublicFormsAreBoundedAndKeysShort.

Lows:
- Refund skipped the request that filled a bucket (count < max), so a
  right password at the ceiling left the shared address blocked; Refund
  now always undoes the Allow it follows.
- The reset form did not give the address its attempt back when the
  name's bucket refused (sign-in did); it does.
- A3's day of five links is spent only by a link that goes out, and the
  price of the pause - anyone knowing a username can pause that owner's
  reset for a day - is recorded in security.md.
- longHeaderLine counted a trailing CR on a header's first line.
- test.yml declares permissions: contents: read.

Not changed, as the review's nits: the store matches names exactly while
the limiter folds them (stricter, said in the comment now); a session from
a longer window is honoured until its next Touch (the next request).

go test ./internal/web/auth ./internal/milter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:58:12 +03:00
mixandClaude Opus 5.5 4717796da6 roadmap: security-audit-2026-10-17 at 27 of 29 (S4 done)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:48:41 +03:00
mixandClaude Opus 5.5 ecfe23504e antispam: rspamd 4.2.2-0, no capabilities, only the milter on the network (audit S4)
Security audit 2026-10-17, finding S4 (low), stage 4. The sidecar ran
rspamd 3.11.1 (December 2024) with default capabilities; upstream is 4.2.2
(2026-10-10), and the line between fixed a heap overflow in
util.lower_utf8, a use-after-free on reload and parser crashes - rspamd
reads every byte a stranger sends to port 25.

A read-only reading of rspamd 3.11.1 and 4.2.2 and of rspamd-docker
v3.11.1+3 and v4.2.2+0, side by side (sources in dev/upstream), found every
function SelfPost relies on unchanged: milter_headers (skip_wanted, the
custom routine, remove 0, create_specific_keyed, get_queue_id from {i}),
the X-Spamd-Result text, multimap (from/smtp, ip, the reject pre-result,
file-map rereading), the image's uid 11333 and CMD. It also found that the
image binds the normal worker (which takes client-named file and shm
inputs) and the controller (default password "q1") on every address,
reachable from any container on the compose network - so:

- image rspamd/rspamd:4.2.2-0 (the one build of 4.2.2; the others float);
- cap_drop: [ALL], no-new-privileges (rspamd starts unprivileged, ports
  above 1024, nothing setuid);
- RSPAMD_LOCAL_ADDR=localhost keeps every worker on loopback, and a new
  antispam/rspamd/worker-proxy.inc puts the proxy alone on *:11332. rspamd
  expands its template syntax in comments too - the first version of that
  comment quoted it and the config failed to load; it quotes none now.

Checked in a throwaway container on the test server with the three files
and the fragment's options (dev/server/rspamd-try.sh): proxy on any
address, 11333/11334 on loopback; from a private address a sender's own
verdict headers removed and rspamd's added; X-SelfPost-Filter equal to
openssl's HMAC-SHA256 of the queue id; a denied address refused with the
deny list's message; healthy. The e2e has no rspamd; the live sidecar is
checked at the rollout. Citations re-pointed to 4.2.2.

Docs: guide § Inbound relay (the third file; copy antispam/ again);
CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:48:31 +03:00
mixandClaude Opus 5.5 ec1c3a6138 docs: the 2026-10-17 security audit in security.md's history and the roadmap
"After the plan" of docs/security-audit-2026-10-17.md: security.md's
history paragraph names A2 and the seven mediums and what closed them; the
roadmap gets the row security-audit-2026-10-17 (26 of 29 plan lines; stage
H is the owner's host, S4 in progress) and two proposed rows the audit left
as product choices: upstream-tls-verify (M7's optional "secure") and
server-dmarc-hint (S3's product side).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:41:25 +03:00
mixandClaude Opus 5.5 08087795cd docs, comments: the drift the audit listed (audit 2026-10-17, stage 6)
The lines under "Doc and comment drift" that the findings' own commits
did not already fix:
- security.md: a newer confirmation link replaces the older, a reset link
  is not replaced; html/template renders every page (no themes); the
  container keeps eight capabilities, named, not "KILL".
- architecture.md: ImportKey writes over a name's key, as an import must;
  the error text reaches the browser on the Backup page's import refusal.
- guide: with Caddy the key is in Caddy's data, so a full backup refuses
  until certs/ holds a copy (or back up caddy-data).
- comments: sasldb2 holds application passwords in the clear (store,
  app/password.go); texthash folds ASCII (sender.go); the supervisor
  socket's group is supervisor (rpc.go); cert-reload's OpenSSL measurement
  was 3.0.x of bookworm; sendReset's limits (links.go).
Migration 0013's comment stays as shipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:40:24 +03:00
mixandClaude Opus 5.5 4f999f4f95 auth, docs: a session from a longer window is cut; the remaining nits (audit A6, A7, A10, W7)
Security audit 2026-10-17, stage 6.

A10: a session created under a longer PANEL_SESSION_IDLE_DAYS kept its old
expiry after the setting was lowered (the renewal arithmetic took
ExpiresAt minus the new window for the last renewal). Touch now cuts such
a session to now plus the window at once. TestSessionMadeUnderALongerWindowIsCut.

A6, A7, W7 - security.md now says what the code does, which is what it
should do: a wrong method answers 405 with Allow from the router before the
role is looked at (the routes are public source); the setup token is made
anew at the first /setup/ request after expiry, a stranger's included, and
logged where only the operator reads it; two GETs make what must exist,
the server's own DKIM key on Settings and Health. README's local trial
says the compose file is the hardened deployment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:38:52 +03:00
mixandClaude Opus 5.5 5c5f49bad1 signer: MIME headers and Message-ID signed; a long header line logged (audit M6, M8)
Security audit 2026-10-17, M6 and M8 (nits), stage 6.

M6: Content-Type, MIME-Version and Message-ID were not in h= (as in
OpenDKIM's default list): a relay could change a MIME boundary or the type
and the signature still verified. They are signed when present; Message-ID
is not over-signed, since cleanup adds one a message lacks after the
milters and an absent name in h= would refuse it.

M8: a header line over smtp_line_length_limit (998) is broken by Postfix's
smtp client after signing and the signature fails silently; only body
lines were logged. longHeaderLine names the header, once per message, in
the same words.

Tests: TestSignLinesCoverTheMIMEHeadersAndLongHeaderLinesAreNamed; the h=
expectations gain Message-ID; e2e dkim_shapes expects content-type: in h=
(and the receiver's verification over it). Docs: security.md,
architecture.md, guide; CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:37:14 +03:00
mixandClaude Opus 5.5 820b1bb968 docs: the upstream's Required TLS checks no certificate (audit M7)
Security audit 2026-10-17, finding M7 (low), stage 6; Help's mockup is
c09368c. postfix/inbound.go writes "encrypt" for Required, and Postfix
verifies no certificate at that level: a domain user who chooses it gets
privacy against passive observers only. The guide § Inbound relay and Help
(word for word with the mockup) say so. Offering "secure" with the
upstream's name is a choice for domain users and hours of work: it goes on
the roadmap as a proposal, not into this plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:34:29 +03:00
mixandClaude Opus 5.5 c09368c544 design: help says the upstream's TLS checks no certificate
Security audit 2026-10-17, finding M7 (stage 6): the upstream TLS mode
"Required" is Postfix's encrypt level, which verifies no certificate, so
it protects against a passive observer only. Help's Upstream topic says
so for both modes. check.py: 0 violations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:33:34 +03:00
mixandClaude Opus 5.5 68493dc097 ci: actions by commit, govulncheck, images pinned, base packages watched (audit I4, I5)
Security audit 2026-10-17, findings I4 and I5 (low), stage 5.

I4: every action is pinned by commit with its version in a comment - the
latest release of the major each used, so nothing changes in behaviour
(checkout v4.4.0, setup-go v5.6.0, setup-buildx-action v3.12.0,
build-push-action v6.19.2, login-action v3.7.0; commits read with git
ls-remote, the tags are lightweight). govulncheck v1.9.0 runs on the
source in test.yml, and in release.yml on the image's panel and
selfpost-backup binaries before the push (-mode=binary: the base image's
Go, which may lag the runner's - a fixed stdlib advisory then fails the
release until golang:1.26-trixie moves, which is the point). certbot is
pinned to v5.8.0 and Caddy to 2.11.7 (Docker Hub, 2026-10-17).
provenance/SBOM stay off until the GHCR cleanup keeps attestations, as the
plan says.

I5: published tags are never rebuilt, so a Debian security fix reaches
operators with the next release. The guide (§ Upgrading) and SECURITY.md
say so; base-packages.yml runs .github/scripts/base-packages.sh every
Monday: what debian:trixie-slim would install for postfix, libsasl2-2,
libsasl2-modules-db, sasl2-bin, openssl, libssl3t64, libdb5.3t64 against
the table in development.md (the 2.14.3 image's versions), red when one
moved. Tried on the test server: green as it stands, red with a version
changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:33:02 +03:00
mixandClaude Opus 5.5 bde6a4000e import: bounds on a domain export (audit W4, W5, W8)
Security audit 2026-10-17, findings W4/W5/W8 (low), stage 4 - all
global-only and self-inflicted, but unbounded:
- W4: ImportKey took any RSA key; a 16 384-bit key makes every signature
  of the domain cost seconds. Now 1024-4096 bits (MinImportKeyBits,
  MaxImportKeyBits); under 1024 Go's parser already refuses.
- W5: an export's applications were unbounded, each a copy of sasldb2 and
  a saslpasswd2 run on the panel's one connection. Now at most 500
  (maxImportApplications).
- W8: an import named as the hostname silently replaced the server's own
  signing key while DNS still held the old record. Now refused with a
  sentence.

Tests: TestImportKeyRefusesAnOversizedKey (4104 bits); the import cases.
Docs: guide § Moving a domain; CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:27:21 +03:00
mixandClaude Opus 5.5 668e299c69 postfix: no reload for a map that did not change; DNS re-checks limited (audit W6/M9)
Security audit 2026-10-17, findings W6/M9 (low), stage 4. Every
application, upstream, recipient or report-address save reloaded Postfix
through supervisord, and POST .../dns-recheck bypassed the five-minute
cache for up to ~14 resolver queries - both at whatever rate an
authenticated user pressed.

- writeChanged: each generated map is written only when its content
  differs from the file on disk, and Postfix is reloaded only when one was
  (sender map, inbound maps, DMARC maps). Health's Reload configuration
  ends with an explicit reload (app.Service.ReloadPostfix, SenderMaps
  gains Reload), so it still re-reads whatever is on disk. No debounce:
  with unchanged saves gone, a burst is real changes Postfix must see.
- auth.AllowDNSRecheck: twenty forced re-checks in 15 minutes per user;
  past that the outbound, inbound and settings re-check show the cached
  result without the "re-checked" flash, and the log says why.

Tests: TestARebuildThatChangesNothingDoesNotReload,
TestDNSRechecksArePerUser; the fakes gain Reload. Docs: guide § Overview
and Health; CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:25:20 +03:00
mixandClaude Opus 5.5 dd950364a3 deploy: a process ceiling on every container (audit I3)
Security audit 2026-10-17, finding I3 (low), stage 4. No compose file set
pids_limit: a fork bomb from a compromised panel or postfix had the host's
pid space as its limit. Now 1024 for SelfPost - the plan said 512, but
Postfix starts up to default_process_limit (100) processes per service,
and a busy server runs three smtpd services, cleanup and smtp at once -
256 for nginx, Caddy, Traefik and rspamd, 64 for certbot. The comment that
said root runs "very briefly" now says which root processes stay.

e2e: checkPidsLimit reads /sys/fs/cgroup/pids.max in the container.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:19:36 +03:00
mixandClaude Opus 5.5 a327962008 web: /healthz is answered only to local probes (audit I6)
Security audit 2026-10-17, finding I6 (low), stage 3. /healthz is
registered outside auth and every proxy fragment passed it through: the
live server answered "ok" to the internet, and "unhealthy: postfix"
would have been a free tell.

Now handleHealth answers only a request with no X-Forwarded-For (nor
Forwarded) from loopback or a private address: the image's HEALTHCHECK
(127.0.0.1), the host through the loopback-published port (the Docker
gateway), a monitor on the Docker network. Every supported proxy appends
X-Forwarded-For, so through it the endpoint is 404. The plan named
"trusted-proxy peers"; that would have let the internet in again through
the trusted proxy, hence this shape. The e2e's readiness probe is the
host's, and stays answered.

Test: TestHealthzAnswersOnlyLocalProbes. Docs: guide § Container health,
architecture.md; CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:17:27 +03:00
mixandClaude Opus 5.5 8aad5b6223 auth: reset links pause for a day after five (audit A3)
Security audit 2026-10-17, finding A3 (low), stage 3. sendReset spaced
reset mails ten minutes apart and capped them at five an hour per account,
so a stranger who repeated a username kept a live 30-minute link arriving
in the administrator's mailbox five times an hour, for ever.

Now five reset links a day per account (resetDayLimiter); the sixth
request sends one notice instead - "Reset links paused for a day", no
link, saying further requests are ignored for 24 hours and that a global
administrator can set a password on Server > Users - and every later one
in the window sends nothing (resetNoticeLimiter), logged either way. In
memory, like every limiter: a restart clears them.

Test: TestResetLinksPauseForADayAfterFive. Docs: security.md, guide §
Account; CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:15:50 +03:00
mixandClaude Opus 5.5 4433688500 findings of the security review of stage 1
The review of d254a99..fd29468 (read-only, a separate agent) found all
five fixes sound and nothing above low. Fixed here:

- The M1 e2e step passed on the unfixed code: the stranger's RSET between
  MAIL FROMs ends the transaction, Postfix's mail_reset() calls
  milter_abort(), and the old code released the slot too. The stranger now
  keeps one MAIL FROM open (strangerHoldsMailFrom) while the domain's own
  application sends; under the old code that slot was the domain's one.
- A domain import could still store a report address on the server's own
  name (W1's form rule only): checkDomainImport refuses one that is not
  the domain's own hosted address. Not a mailbox either way, but the
  panel would show an address the server refuses. Cases in
  TestDomainImportIsCheckedLikeTheForms.
- DeleteDomain matches the reports' domain without regard to case.
- A cut that fails after the journal rows were written says the rows were
  written.
- dmarc.Service.Resync and inbound.Service.Resync are serialised like
  app.Service.Resync (W3's shape, outside its diff).

Left as the review's nit: three ApplicationByLogin lookups for a refused
MAIL FROM (a refusal is rare; the store is local).

go test ./internal/dmarc ./internal/inbound ./internal/store; the import
test; GOOS=linux go vet ./test/e2e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:13:36 +03:00
mixandClaude Opus 5.5 3a8e19b71c auth: sign-in limits per address and name; successes free (audit A1, A8, A9)
Security audit 2026-10-17, findings A1 (medium), A8, A9 (low), stage 3.
Under the shipped Apache-on-host deployment with TRUSTED_PROXY_CIDR empty,
every visitor has the Docker gateway's address, and every public limit was
keyed on the address alone: ten POSTs to /login per 15 minutes (any body,
successes counted) locked out every administrator; likewise /login/reset,
the link pages and /setup/. IPv6 was keyed per /128.

Now (internal/web/auth/limitkeys.go):
- sign-in counts per address and username (10 / 15 min) and per address
  alone at 100; a right password refunds both (rateLimiter.Refund);
- a reset request counts per address and username (5) and per address
  alone (50); a link's page per link, by token hash (30), and per address
  alone (300); setup per address as before;
- an IPv6 address is keyed by its /64 (limitAddr);
- proxyWatch: when the first 20 requests after a start all come from one
  private or loopback peer with X-Forwarded-For and no proxy is trusted,
  the log says so once with the TRUSTED_PROXY_CIDR value to set;
- a refused sign-in or a lockout is logged, five lines per address in 15
  minutes, the typed name printable and cut (refusalLog, logName).
The shipped compose cannot know the gateway, so the guide's install step
prints it (docker network inspect) and says to set it.

Tests: TestLoginBehindOneUntrustedProxyKeepsOthersIn (the reader's case),
TestLoginSuccessesDoNotCount, TestIPv6IsLimitedPerSlash64,
TestProxyWatchAndRefusalsAreLogged; the existing lockout tests pass.

Docs: security.md § First-run setup, § Authentication; guide § Full
deployment; CHANGELOG; plan. Also the I1 plan line's Done note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:10:47 +03:00
mixandClaude Opus 5.5 1bafd78364 image: the TLS key's group is the panel's alone, 10997 (audit I1)
Security audit 2026-10-17, finding I1, stage 2; the help mockup is ff3a118.
The guide had operators give the TLS private key to group 10998 so the
panel can archive it, and the Dockerfile puts postfix in that group (for
sasldb2 and the maps): `docker exec -u postfix head privkey.pem` printed
the live key. postfix-config.sh copies the key to a root-only directory
precisely so that a compromised Postfix daemon cannot read it - the mounted
original undid that.

Now a group backupkey (gid 10997, outside the system range like 10998)
whose one member is the panel; the guide (Apache/certbot, nginx, Taking a
full backup, the backup table), Help, and the backup's refusal name 10997
and say why; postfix-config.sh, extract-cert.sh and the Dockerfile's
comments follow. A key still given to 10998 keeps working for the backup.
CHANGELOG has an Upgrading note.

e2e: the stage gives its key to 10997 at 0640 when the runner is root
(stageKeyMode; 0644 otherwise), and testFullBackupRestore, after the
refusal that now names chgrp 10997, checks that the postfix uid cannot read
/etc/postfix/tls/privkey.pem and the panel can.

Docs: security.md § Output and process (the groups); guide; CHANGELOG;
plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:03:10 +03:00
mixandClaude Opus 5.5 17b7049311 design: help says only a wrong account password counts against the limit
The review of audit stage 0 (e9d2cbb): a right account password now gives
its limiter attempt back, so the Backup topic's "every attempt, right or
wrong, counts" becomes "a wrong one counts against the limit of a password
change, ten in 15 minutes". check.py: 0 violations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:00:01 +03:00
mixandClaude Opus 5.5 ff3a118b2f design: help gives the TLS key to group 10997, the panel's alone
Security audit 2026-10-17, finding I1 (stage 2): the guide told operators
to give the TLS private key to group 10998 so the panel can back it up,
and postfix is in that group too - every network-facing Postfix daemon
could read the key. The key gets a group of its own, 10997, whose one
member is the panel. Help's Backup topic names it and says why. check.py:
0 violations; outlines unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:59:41 +03:00
mixandClaude Opus 5.5 e9d2cbbe28 findings of the security review of stage 0
The review of 2c27275..2c8e981 (read-only, a separate agent) found no high
or medium; three small ones, fixed here:

- reauth took a limiter attempt before the compare, right password
  included: with A2 asking on every user save, ten saves in fifteen
  minutes locked the administrator out of Account's password change, the
  backup and the export. A right password now gives its attempt back
  (rateLimiter.Refund, Module.PasswordCheckPassed; a bucket already at its
  limit stays blocked). TestRightAccountPasswordsDoNotUseUpTheLimit.
- LoggedPath hid the tokens of /login/reset/ and /confirm/ but not of the
  first administrator's /setup/<token>; now it does (test case added).
- The entrypoint's own `[ -e ] || : >` lines (sender_login_maps, the four
  inbound maps, mail.log) get the `[ -L ]` guard postfix-config.sh got for
  I2. Safe before too - drop_links runs first on those paths and the panel
  is not running yet - this is the belt and braces.

The review also confirmed, with a probe of the regex against Postfix's
formats, that a port-25 client can place only its own envelope in an
Inbound log row, and that every path to a password, e-mail or role asks
for the signed-in password.

go test ./internal/web/handlers ./internal/web/auth; sh -n entrypoint.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:58:38 +03:00
mixandClaude Opus 5.5 fd29468e00 app: the sender map's rebuild is serialised (audit W3)
Security audit 2026-10-17, finding W3 (low), stage 1. Resync read the
bindings and wrote the map with no lock (the selector table has tableMu);
two application changes at once could write in the order opposite to their
reads and leave the map stale - an application whose binding was missing
refused with reject_sender_login_mismatch until the next change. Now a
mutex on the Service holds the read, the write and the reload together.

Test: TestResyncRunsOneAtATime (eight at once, never two inside the
rebuild).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:55:07 +03:00
mixandClaude Opus 5.5 b81ff9f135 domains: a domain's DMARC reports are deleted with it (audit A5/W2)
Security audit 2026-10-17, finding A5/W2 (low), stage 1; the mockup is
7effc98. dmarc_reports belong to a domain by name, outlive the domain (no
FK), and a domain deleted and added again handed its old reports to
whoever it was assigned to then - the logs were moved to ids in 2.5.0 for
exactly this case, reports were not.

The cheap form of the plan: store.DeleteDomain deletes the domain's
reports (their records by the table's cascade) in the same transaction as
the domain. The confirmation page says so when the domain has reports
("delete the 12 DMARC reports received for it;"), counted by the new
store.CountDMARCReports; NewOutDomainDelete takes the count.

Tests: TestDeletingADomainDeletesItsReports (the domain added again has
none, another domain keeps its report and record, a second delete is
ErrDomainNotFound); TestOutDomainDeleteNamesWhatGoesWithIt with 12, 1 and
0 reports.

Docs: security.md § Roles; CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:52:06 +03:00
mixandClaude Opus 5.5 7effc98e5d design: domain delete names the DMARC reports that go with it
Security audit 2026-10-17, A5/W2 (stage 1): a domain's DMARC reports are
now deleted with it, so the confirmation lists them - "delete the 12
DMARC reports received for it;" - before "stop signing mail as the
domain." Shown only when the domain has reports (the code decides; the
mockup shows a domain that has). check.py: 0 violations; outlines
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:49:12 +03:00
mixandClaude Opus 5.5 626bd337be dmarc: no custom report address on the server's own name (audit W1)
Security audit 2026-10-17, finding W1, stage 1. HandleDomainDMARC took any
address validate.Email takes; AllowedRecipients opened every stored address
on the hostname, not only the domain's hosted one; checkReportDomain
cross-checked the claimed domain only against a dmarc-reports+<tag> local
part. So a domain user of tenant A could store ops@<hostname> and from then
on anyone could mail a report claiming tenant B's domain to it, stored and
shown under B and counted toward B's 500; or store B's hosted address and
open B's mailbox without B choosing hosted. TestAllowedRecipients asserted
ops@<host> was allowed - a choice, now reversed.

Now: a custom address on the hostname is refused on the form ("choose
SelfPost hosted"); AllowedRecipients adds a domain's address only when it
is that domain's own hosted address; checkReportDomain requires a tag and
the claimed domain to equal it. The live registry (checked 2026-10-17)
holds two hosted addresses and no custom one on the hostname, so nothing
there changes.

Tests: TestCustomReportAddressOnTheHostnameIsRefused (typed, another
domain's hosted, the domain's own hosted typed as custom; an address
elsewhere still saves); TestAllowedRecipientsFollowTheDomainAddresses
inverted plus another domain's hosted address stored; untagged addresses
rejected in TestIngestMessageRejectsTagMismatch; the ingest tests deliver
to tagged addresses.

Docs: security.md § Accepted risks (DMARC), guide § DMARC reports;
CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:47:34 +03:00
mixandClaude Opus 5.5 c50314fb4c inbound journal: one row per recipient, and every row counts to the cap (audit M3)
Security audit 2026-10-17, finding M3, stage 1. inboundSession.RcptTo
appended every RCPT (the outbound session de-duplicates), and the end of
the message inserted a row per recipient one statement at a time inside
Postfix's callback; the cut to 10 000 rows every 500 counted only the log
tailer's rows. Postfix accepts a repeated recipient up to
smtpd_recipient_limit (1 000) and sends each to the milter, so one message
was a thousand rows, uncut until the six-hourly prune.

Now: the inbound milter folds a repeated recipient (case-insensitive, as
cleanup's been_here() with BH_FLAG_FOLD does, and as session.RcptTo does);
a message's rows go in one transaction (store.InsertInboundSpamMessage,
which replaces InsertInboundSpam in milter.InboundStore); the count and the
cut moved from the tailer into the store (countInboundWrites, an atomic
counter on Store) so the milter's rows, the tailer's and the tailer's
start-up re-reads all count. store.InboundSpamLogMaxRows is the one cap.

Tests: TestInboundRecipientRepeatedIsOneRow (1 000 RCPT in two cases, one
row); TestInboundJournalIsCutWhicheverWriterWrites (a small cap: a message's
rows and refusals together cross the mark, the table is cut); the tailer's
own counting test went with its counter. go test ./internal/store
./internal/milter ./internal/logtail (Windows-only Follow failures aside).
Also gofmt of a test line from the M2 commit.

Docs: security.md § Mail path, architecture.md; CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:44:28 +03:00
mixandClaude Opus 5.5 d254a9952d milter: the level-2 domain limit is the login's domain's (audit M1)
Security audit 2026-10-17, finding M1, stage 1. overLimit took
domainOf(MAIL FROM) whatever the login, and MailFrom ran it with an empty
login: with smtpd_delay_reject at its default Postfix runs no sender
restriction at MAIL and sends the milter's MAIL event at once, so a
stranger on 465/587 with no AUTH, or another tenant's application, could
name a victim domain in MAIL FROM and take its slots for the 10-minute
in-flight TTL, get the victim's application answered 451, and have a limit
row (and the "a rate limit is refusing mail" notification) written under
the victim's domain.

Now overLimit returns at once without a login and takes the domain from
ApplicationByLogin (loginDomain, fail-open like the rest); recordRejected's
Domain is the login's too. The send log already counts a domain's messages
by the login's domain (store.CountMessages, loginDomainID), so the limit
and the count now agree. The null-sender special case is gone: every
message is held to its login's domain.

Tests: TestRateLimitIsNotReservedByAnotherSendersMailFrom (a stranger with
no login; another tenant's application) - fails on the old code at the
second MAIL FROM; the domain-limit tests sign in as app1 at example.com.
e2e domain_limit_not_reserved_by_a_stranger: a domain with a limit of one,
three unauthenticated MAIL FROM naming it on 465, then its own message
passes and its second is refused 451.

Docs: security.md § Mail path; CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:41:06 +03:00
mixandClaude Opus 5.5 2c8e981f89 docs: SECURITY.md supports the 2.x line (audit X1)
Security audit 2026-10-17, finding X1, stage 0: the supported-versions
table named 1.x while the product is at 2.14 - an operator would have
read 2.x as unsupported. Latest 2.x yes, earlier 2.x and 1.x no.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:37:08 +03:00
mixandClaude Opus 5.5 8820294472 postfix: ETRN refused and not offered (audit S6)
Security audit 2026-10-17, finding S6 (low), stage 0 - proven from
outside: `ETRN <an inbound domain>` on port 25 answered "250 Queuing
started". smtpd_etrn_restrictions was empty (Postfix allows every client)
and fast_flush_domains = $relay_domains, so a stranger could flush the
deferred queue toward an inbound domain's upstream at will. Now
smtpd_etrn_restrictions=reject (etrn_cmd -> smtpd_check_etrn -> reject_code
554, Postfix 3.10.13 src/smtpd/smtpd.c) and smtpd_discard_ehlo_keywords=etrn.

e2e: testInboundRelay sends EHLO + ETRN for the inbound domain on port 25
and expects 554 and no ETRN in the EHLO reply (etrnPort25);
checkPostfixHardening reads both settings back. To be run on the server.

Docs: architecture.md § Postfix; CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:36:55 +03:00
mixandClaude Opus 5.5 5210cb19f2 auth: the duplicate-cookie line hides a link's token (audit A4)
Security audit 2026-10-17, finding A4 (low), stage 0. The line logged when
a request carries two unprefixed session cookies printed r.URL.Path raw,
so GET /confirm/<token> wrote the token to the log (reachable only with
PANEL_COOKIE_SECURE=false, where a sibling host can plant the bare name).
loggedPath moves from internal/web to internal/web/auth as LoggedPath
(web imports auth, not the other way) and both lines - the origin check's
refusal and the duplicate-cookie one - go through it.

Tests: TestDuplicateCookieLineHidesALinksToken; TestLoggedPathHidesTokens
moved with the function. go test ./internal/web ./internal/web/auth.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:35:32 +03:00
mixandClaude Opus 5.5 da2e8b2ffe milter: the sender check keeps two From: and two Sender: values (audit M5)
Security audit 2026-10-17, finding M5 (low), stage 0. senderSession.Header
appended every From:/Sender: value; Postfix bounds one header at 60 000
bytes but not their number, and the signer's 1 MiB cap runs after the
sender check, so an application could make it hold megabytes per
connection (the reader's test: a hundred lines, 6 MB). judge needs only
none / one / more, so two of each are kept; the refusal text joins what is
kept, as before.

Tests: "a hundred From: lines" in the judgement table (refused, more than
one From: line); TestSenderKeepsTwoFromAndSenderLines. go test
./internal/milter.

Docs: security.md (the sentence "a few headers each"); CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:33:53 +03:00
mixandClaude Opus 5.5 683fdafc6d logtail: Inbound log takes Postfix's own tail, not a recipient's (audit M2)
Security audit 2026-10-17, finding M2, stage 0 - proven on the live server:
Postfix quotes a refused recipient in its reply in internal form (quotes
removed, "; < > =" kept: extract_addr, tok822_internalize), so
RCPT TO:<"; from=<ceo@…> to=<board@…> proto=ESMTP helo=<x>; "@domain>
spelled a whole tail inside the reply, and inboundRejectRe, whose reply
ended at the FIRST "; from=<", took the stranger's sender, recipient and
inbound domain for the row.

Now the reply is greedy and the tail anchored: "; from=<…> to=<…>
proto=\S+", a HELO name holding none of Postfix's NEUTER_CHARACTERS
(smtpd.c:1634, helo_cmd neuters ` <>()\";@`), and one-word SASL fields to
the end of the line (a `.*` SASL tail would have let a quoted MAIL FROM
spell the whole tail). Belt and braces: a reply that still holds
"; from=<" - a line with two tails - makes no row, so neither a recipient
nor an un-neutered HELO name can choose one. The quote/bracket/space
filter on from/to stays.

Tests: TestInboundRecipientCannotWriteTheRow (the live line's shape, and
with a forged MAIL FROM too); TestInboundHeloCannotWriteTheRow now feeds
HELO names as Postfix logs them (neutered) and expects no row for the
un-neutered ones. go test ./internal/logtail on Windows (the three known
Windows-only Follow failures aside).

Docs: security.md § Mail path; CHANGELOG; plan line ticked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:33:12 +03:00
mixandClaude Opus 5.5 f2ef705470 entrypoint: no link followed at the DMARC maps (audit I2)
Security audit 2026-10-17, finding I2 (= M4), stage 0. drop_links listed
the outbound maps, sasldb2, the key tree and mail.log but not the three
DMARC maps that postfix-config.sh creates as root with `[ -e ] || : >`
when DMARC_REPORTS_ENABLE=true; -e is false for a dangling link and the
redirect follows it, and /data/postfix is the panel's. Now the three are in
drop_links, and both map loops refuse to create through a link
(`[ -e ] || [ -L ] || : >`).

e2e: TestStartsOnA28Data plants a dangling /data/postfix/dmarc_transport
with DMARC reports on and expects the "removing the symbolic link" line,
no file at the link's target, and a regular map. To be run on the server
with the stage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:30:03 +03:00
mixandClaude Opus 5.5 a37aba6594 user-reauth: the user form asks for your account password (audit A2)
Security audit 2026-10-17, finding A2 (high), stage 0; the mockup is
2c27275. A stolen global session could set its own password on the user
form - no current password asked, the session kept - and then pass the
backup's reauth with it, or add a second global user with a known
password. Now the user form calls reauth (the backup's check: bcrypt
against the signed-in user's own hash, under auth.AllowPasswordChange) when
it adds a user and when a save sets a password, changes the e-mail (reset
links go there) or changes the role, the own account included; deleting a
user asks too, refused on its own confirmation page (renderUserDelete, a
Flash on UserDelete). Name and reach alone are not asked. Missing or wrong:
401 on the same page, "Your account password is incorrect.", nothing saved.

Tests: TestUserFormAsksForTheAccountPassword (create, each of password,
e-mail, role, own password, reach alone, delete); the user-form tests send
the field (withAccountPassword gives root a real hash and h an auth
module). go vet, go test ./internal/web/... on Windows.

Docs: security.md § Backup and domain export, guide § Users, help word for
word with the mockup; CHANGELOG [Unreleased] Security; plan line ticked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:28:18 +03:00
mixandClaude Opus 5.5 2c27275970 design: user-reauth - your account password on the user form and user delete
Security audit 2026-10-17, finding A2, stage 0 (the owner, 2026-10-17:
"Выполняй план после аудита"). A stolen global session could set its own
password on the user form and so pass backup-reauth, or add a second global
administrator. The user form gets "Your account password" (autocomplete
current-password) as the last field of box 01, with help that says it is
yours, not the edited user's, and when it is asked: adding a user, changing a
password, an e-mail or a role. Not required in the markup on the edit form -
the server asks only for those changes. The delete page gets the same field,
required, before its buttons. Help's Users topic says all of it. check.py:
0 violations; outlines unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:20:49 +03:00
mixandClaude Fable 5.1 71f307d4ca docs: security audit 2026-10-17 - report and plan
Full security audit at 44bf09d (2.14.3): four zone readers, machine checks,
the live installation read-only, probes from outside. No critical; one high
(A2: a stolen global session sets its own password on the user form and so
passes backup-reauth), seven mediums (M1, M2, M3, W1, I1, I2, A1), the rest
low and nits; "checked and sound" per zone; doc drift; a seven-stage plan
with hours and a recommendation. The host's own findings are in dev/, not
published.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-17 19:34:43 +03:00
mixandClaude Opus 5.5 44bf09d7c2 roadmap: local-time - the DMARC day, the one exception, recorded
test / test (push) Waiting to run
The owner's decision of 2026-10-17 ("Показ времени отчёта по UTC
принимаем. Это не проблема. Пояснения добавь."), shipped in 2.14.2,
written where the row's other decisions are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 18:26:29 +03:00
mixandClaude Opus 5.5 22bfcdb5e4 release: 2.14.3: label-tag - a tag beside a label
test / test (push) Waiting to run
No migration; schema 13. The pins (README, compose, guide), the guide's
backup example and the planned cuts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:02:52 +03:00
mixandClaude Opus 5.5 25813316b3 label-tag: a tag beside a label takes the label's line height
The owner's phone, 2026-10-17: "Маленький зазор над полем почты"; design
the commit before this one. panel.css takes the two rules theme.css gained
(TestPanelCSSIsTheMockupStylesheet holds them equal): a tag in a field's
label or a fact's dt has no padding of its own and line-height 1.5, so the
line keeps the label's height. CHANGELOG [Unreleased] § Fixed.

go vet + go test (26 packages) and make e2e on the test server for this
tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:02:51 +03:00
mixandClaude Opus 5.5 1fe90c40f8 design: label-tag - a tag beside a label is no taller than its line
The owner's phone, 2026-10-17: "Маленький зазор над полем почты" - on
Account the e-mail's "not confirmed" tag made its label's line taller
(Bulma's tag is 2em high, 26 px against the label's 21), leaving less room
under it than under Username. A tag beside a label's text - a field's
label or a fact's - now takes the text's line height; measured on the
mockup at 412 px, both labels leave the same room above their field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:49:00 +03:00
mixandClaude Opus 5.5 7289aec60b release: 2.14.2: dmarc-day, health-sockets - the last small items
test / test (push) Waiting to run
No migration; schema 13. The pins (README, compose, guide), the guide's
backup example and the planned cuts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:38:36 +03:00
mixandClaude Opus 5.5 0eaf69a830 dmarc-day, health-sockets: the reporter's day said; the inbound journal dialled
The owner, 2026-10-17: "Показ времени отчёта по UTC принимаем. Это не
проблема. Пояснения добавь. Все мелочи закрой." Designs 9b33b80, 2bf9056.

DMARC: a report's day stays the reporter's, in UTC (RFC 7489 § 7.2); the
report's Window now says so under its times (windowNote), and Settings'
Time zone box and Help (#settings, #dmarc) name it as the one exception to
the panel's zone. formatDMARCWindow counts a window that ends at midnight
as ending the day before, so a daily report is one day, not "20 Sep - 21
Sep" (TestDMARCWindowIsTheReportersDay, in Moscow's zone).

Health: with the inbound relay on, the inbound journal milter's socket is
dialled (InboundJournalSocket through web and handlers Config), fail-open
as the send log's, and Overview's card counts it; the send log's socket is
named journal-milter, as the mockup names it.

The confirmation link's sign-in state is drawn (confirm-email-signin.html);
the panel already renders it.

guide: /license and /server/components, the DMARC pages' paths and day,
selfpost-backup -o / -i / -version, the Inbound log's retention field,
PANEL_COOKIE_SECURE, the inbound journal's socket, the DMARC exception to
the zone. CHANGELOG [Unreleased].

(The e2e case negative_level1_ratelimit, listed as able to pass falsely,
was fixed on 2026-10-14 - the note was stale.)

go vet + go test (26 packages) and make e2e on the test server for this
tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:38:34 +03:00
mixandClaude Opus 5.5 2bf9056d20 design: health-sockets - the inbound journal's socket on Health
The owner's "Все мелочи закрой" (2026-10-17), from the docs audit: the
inbound journal milter (Inbound › Log, port 25, fail-open) was the one
milter of the panel Health did not dial. Its row joins Milter sockets with
the inbound relay on, and Overview's card counts it (6 of 6 answering).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:21:59 +03:00
mixandClaude Opus 5.5 9b33b805d8 design: dmarc-day - the reporter's day named as such; confirm's sign-in state
The owner, 2026-10-17: "Показ времени отчёта по UTC принимаем. Это не
проблема. Пояснения добавь. Все мелочи закрой."

A DMARC report's day stays the reporter's, in UTC - the title and the lists
name it - and the panel now says so: the report's Window shows its
boundaries in this panel's zone with a line under them naming the
reporter's day; Settings' Time zone box and Help (#settings, #dmarc) name
it as the one exception to "every time is this zone's".

confirm-email-signin.html: the confirmation link opened without its
account's session (since the security review of password-reset): what to
do, and a link to sign in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:15:27 +03:00
mixandClaude Opus 5.5 de4274ccc9 release: 2.14.1: overview-head - the mockup's sentence on Overview
test / test (push) Waiting to run
No migration; schema 13. The pins (README, compose, guide), the guide's
backup example and the planned cuts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:48:36 +03:00
mixandClaude Opus 5.5 1eec1c6011 overview-head: the mockup's sentence, made from the cards
The owner's "1" (2026-10-17), the two shown side by side; design 9aebe1e.

overviewHead writes "Mail is flowing. Nothing needs attention." when every
card is fine, "Mail is flowing. One thing needs attention." (or "N things
need") for warnings, and "Mail may not be flowing. ..." when a card fails;
the lead names each card that is not fine, linked, with its value inside
the sentence ("TLS certificate: expires in 12 days."), separated by
semicolons. lowerFirst lowers a value's first letter unless it is an
abbreviation ("CPU 98 %"); every value the cards write is a phrase.

Tests: TestOverviewHeadSentences (the three states, two cards, an
abbreviation), the Overview page and fragment, TestFilterDownIsAMissingSocket.
go vet + go test (26 packages) and make e2e on the test server for this
tree.

Docs: guide § Overview, CHANGELOG [Unreleased].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:48:34 +03:00
mixandClaude Opus 5.5 9aebe1efe4 design: overview-head - the mockup's sentence, made from the cards
The owner's "1" (2026-10-17), shown both: Overview's head says what the
mockup says - "Mail is flowing. One thing needs attention." - and is made
from the cards as the panel's is. Its lead is what the panel can write: each
card that is not fine, linked, with its value ("TLS certificate: expires in
12 days."), instead of a sentence the cards do not hold ("has not renewed
yet").

The states the panel writes from it: all fine, "Mail is flowing. Nothing
needs attention."; a warning, "Mail is flowing. One thing needs attention."
(or "N things need"); a failure, "Mail may not be flowing. One thing needs
attention." - a failed card is Postfix, a socket, a queue that cannot be
read, which can stop mail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:29:23 +03:00
mixandClaude Opus 5.5 3d23c41ae7 release: 2.14.0: password-reset - a confirmed e-mail, then a link to reset
test / test (push) Waiting to run
Migration 0013; schema 13. The pins (README, compose, guide), the guide's
backup example, the planned cuts and the roadmap row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:05:08 +03:00
mixandClaude Opus 5.5 b8e156c9f4 password-reset: a confirmed e-mail, then a link to reset the password
Agreed by the owner on 2026-10-13 ("the e-mail must be confirmed");
notifications to an unconfirmed address settled on 2026-10-17: "Без
подтверждения слать". Mockups 2f5f5b2, route guard b7bbb2c, panel guard
0774da2.

Migration 0013: users.email_confirmed_at ('' for every address saved
before it) and user_links - the SHA-256 of a link's token, its user,
purpose (confirm, reset), address, created and expiry. A changed address
loses its mark (keepConfirmed, in UpdateUser and SaveUser).

internal/web/auth/links.go. A link is 256 bits from crypto/rand, mailed as
https://SELFPOST_HOSTNAME/... - never the request's Host. Confirmation:
24 h, sent when an address is saved (Account, or the global role's user
form) and by Account's Send the link again; its page /confirm/<token> has
a button (a GET changes nothing) and confirms only for a session of its
own account. Reset: /login/reset asks for a username and answers the same
for every name (the sign-in page, "If that account has a confirmed e-mail,
the link is on its way"), mailing after the answer; only a confirmed
address gets one, none within ten minutes of the last, five links an hour
per account, five requests in 15 minutes per client address;
/login/reset/<token> (30 min) sets the password with the link taken in
the same transaction that checks the address again
(store.TakeResetLink), then ends every session. Changing the e-mail on
Account asks for the current password. A password set on Account or the
user form removes the reset links; a restore removes every link; the stop
waits for links being mailed (Server.Drain); a rejected request's log line
hides a link's token (loggedPath).

Reviewed for security before the commit (2026-10-17): no high; two
medium and five low, all fixed here - confirming needs the account's
session and the mail does not name the account (a mistyped address was a
takeover); a reset request neither replaces the links already sent nor
spends the account's quota inside ten minutes; take-and-set atomic; the
confirmation mail sent after the answer; the stop waits for mail; a
restore deletes the links; the token kept out of the log.

Tests: store TestUserLinks, TestUserLinkTakenOnce, TestEmailConfirmation,
TestTakeResetLink; auth TestResetSendsALinkOnlyToAConfirmedAddress,
TestResetLinkSetsThePasswordOnce, TestResetLinkDiesWithTheAddress,
TestResetRequestsAreLimited, TestResetRequestsAreSpaced, TestConfirmLink,
TestLinkPathsWithoutAToken; handlers TestAccountEmailIsConfirmedByItsLink,
TestUserFormSendsANewAddressItsLink; web TestLoggedPathHidesTokens; the
view's pages and fixtures; e2e password_reset_by_e_mail runs it through
the sink (and checks a link from before the restore is dead). go vet +
go test (26 packages) and make e2e on the test server for this tree.

Docs: guide § Account, security.md (the bullet and what ends sessions),
architecture (routes), schema-migrations (row 13, users, user_links),
help #account (word for word with the mockup), roadmap row done,
CHANGELOG [Unreleased].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:03:27 +03:00
mixandClaude Opus 5.5 09edc0fac6 guard: panel - the reset and confirmation pages are signed-out screens
password-reset (owner, 2026-10-13: "the e-mail must be confirmed";
2026-10-17: "Без подтверждения слать"); mockups 2f5f5b2. login-reset,
login-reset-set and confirm-email are drawn as sign-in and setup are, the
form column of the signed-out screen with no shell to outline: they join
those two in noOutline, and the structure check's signed-out case reads
one list of them (signedOut) instead of naming two pages. Every other rule
holds them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:14:46 +03:00
mixandClaude Opus 5.5 b7bbb2ca14 guard: routes - the reset link, the confirmation link, Send the link again
password-reset, agreed 2026-10-13 ("the e-mail must be confirmed") and
settled 2026-10-17 ("Без подтверждения слать": notifications go to an
unconfirmed address, a reset link only to a confirmed one); mockups
2f5f5b2.

Public, as /setup/ is: /login/reset (the form that asks for a link),
/login/reset/<token> (the new password from the link), /confirm/<token>
(the address confirmed from its link) - each link is opened from a mail,
signed in or not. Behind a session: POST /account/email/confirm, Send the
link again on Account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:02:34 +03:00
mixandClaude Opus 5.5 2f5f5b2b93 design: password-reset - a confirmed e-mail, then a link to reset
Agreed 2026-10-13 ("the e-mail must be confirmed"); his choice of
2026-10-17 for notifications: "Без подтверждения слать" - they go to the
address confirmed or not; only a reset link needs it confirmed.

Sign-in gets "Forgot your password?" (login.html). login-reset.html asks
for the username and says what will happen; login-reset-sent.html is the
sign-in page after it, the same answer for any name ("If that account has
a confirmed e-mail, the link is on its way"); login-reset-set.html sets
the new password from the link and says that every session ends.
confirm-email.html is where the confirmation link lands: a button, not a
GET that changes state, as a mail scanner opens links.

Account: the e-mail's tag (confirmed / not confirmed), what a new address
is sent, the current password asked only to change the e-mail (roadmap,
2.0 security review: the e-mail becomes where a reset link goes), and
"Send the link again" while it is unconfirmed. The user form shows the
tag too. Help #account says it all. The user form loses "The same form,
empty, creates a new user" (panel-words: a new user has its own head).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:02:07 +03:00
mixandClaude Opus 5.5 b7f04217f2 release: 2.13.1: docs-audit - the documents and the panel's words against the code
test / test (push) Canceled after 0s
No migration; schema 12. The pins (README, compose, guide), the guide's
backup example and the planned cuts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 02:33:34 +03:00
mixandClaude Opus 5.5 ddafc96bab docs-audit: the guide and the panel's words against the code
The owner's "проверь, что документация актуальна с кодом" (2026-10-16);
design c5edbd8 and f1cc3f3 settled which side moves.

The panel follows its mockups: an inbound domain's MX box ends with
"Checked N ago" (dnscheck.InboundMXCheckedAt, from the cache) and its side
menu has See also › Log for this domain (the Inbound log, filtered); a
deferred message's history ends with the open dot of the wait in the
queue, the lifetime from this Postfix; Health's lead says six cards;
Overview's head says it refreshes on its own; DMARC's hint for an
unaligned third party is the mockup's sentence.

Help, word for word with the mockup and true to the code: the
application's password kept by the server (sasldb2) for an export, a
From:/Sender: refusal one row of the Outbound log, every backup password
check counted (AllowPasswordChange before the compare), a password set
for another user ends all their sessions, the limit mail daily per
sending domain. The Filter lists topic and its menu entry are no longer
shown to a domain administrator: their page is 404 to one
(requireInboundGlobal), and Help shows only what its viewer can open.

guide.md: Health's readings in the order of the boxes, with Notification
mail; the menu and role tables have the Inbound log and Filter lists; port
25's chain has the inbound journal; level 2 does not depend on the client
address, the client list refuses a message without one; four kinds of
refusal (wrong Sender); the log is filtered, not searched, and a message
is traced by its row; a bounced row shows only the queue lifetime;
TRUSTED_PROXY_CIDR serves sign-in and setup, the account-change limit is
per user; notifications' first check and hourly ceiling. The Apache
vhost's comment names mod_rewrite.

Tests: TestHelpHasTheInboundLogAndFilterListsTopics (the topic per
viewer), TestDeliveryPageDeferredUsesRetryPolicy (the wait),
TestInDomainShowsTheMockupsBoxes (checked, the log link), the Overview
head; the DMARC tests take the new hint. go vet + go test (26 packages)
and make e2e on the test server for this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 02:32:44 +03:00
mixandClaude Opus 5.5 f1cc3f3b37 design: panel-words - two choices of the panel kept
Following c5edbd8, two lines where the panel had already decided on
purpose, with tests that hold it (server_test.go,
TestUserDeleteSaysNothingOfReportAddresses; the user form's e-mail help):
deleting a user names no domain - "The domains" read as if one could lose
something - and the e-mail field of the user form is "the user's own
address", not "for notifications". The mockups take the panel's words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 02:14:33 +03:00
mixandClaude Opus 5.5 c5edbd84c7 design: panel-words - the mockups and the panel say the same
The owner's "проверь, что документация актуальна с кодом" (2026-10-16):
the words the mockups put on screen against what the panel renders and
does, page by page (docs-audit, 2026-10-17). Where the code is right about
behaviour the mockup follows it; where the mockup is right the code
follows in the next commit.

The mockups now say: "Refreshes automatically" / "refreshes on its own"
instead of "every 10 s" (panel.js polls at 5 s, 30 s, up to 60 s) on
Health, Overview, System log and both queues; System log points at the
Outbound log, the one with a page per message; the Spam filter facts are
what the panel knows without the quarantine (Applies to, Filter, If the
filter is down), and Valid recipients takes one per line or comma; a DMARC
report's title has no year, as its own title line; the "same form, empty"
sentences go (a new application and a new user have their own heads); an
application's login is fixed; the auto limit's multiplier is 1.5-5,
default 2.5 (store/ratelimits.go); the user form's e-mail is for
notifications (no reset link yet) and All makes the single domains
ignored (nothing greys them); a user's deletion says "(outbound)"; a
message's history is the panel's own steps, with the queue's wait after
a deferral; the footer is the panel's, "Copyright © 2026 Mikhail
Yenuchenko".

Help, true to the code: an application's password is kept by the server
(sasldb2) so an export can carry it; a refusal for From:/Sender: is one
row of the Outbound log; every password check of a backup counts, right
or wrong, ten in 15 minutes; a password set on the user form signs that
user out everywhere, but your own keeps your session; the limit mail is
daily per sending domain (notify.go, "limit:"+domain).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 02:10:29 +03:00
mixandClaude Opus 5.5 be98efa549 docs-audit: architecture, schema, security and the rest against the code
The owner's "проверь, что документация актуальна с кодом" (2026-10-16).
Read-only reviews checked every present-tense claim against the code;
each finding was read in the source before it was fixed here.

architecture.md: held inbound mail has its row from the journal milter,
not the tailer; the antispam action is accept or tempfail, anything else
stops the start; tls-internal's certificate is 0644; the signer logs to
stderr; the log's fingerprint is the inode, not the device; a mail.log
postlogd makes is 0640 (maillog_file_permissions); an unreadable stored
place starts at end-of-file too; the test-message route; no map in page
data; design-first compares whitespace aside; the queue and sasldb2.new
are not backed up; antispam/ under /data; POSTFIX_QUEUE_DIR's desync;
SELFPOST_BACKUP_PASSWORD (the CLI's).
schema-migrations.md: inbound_spam_log's upstream columns (v12),
send_log's sender reason, row 9's client_ip.
security.md: /data/opendkim since 2.9.0 (removed, or the start refused),
sasldb2 under /data/sasl, the trigger for mandatory encryption, the
password minimum counted in characters.
development.md: Staticcheck in CI, the e2e ports 20025 and 20053/udp.
README: the client IP list refuses at MAIL FROM, overrides are per
application. product.md: the optional rspamd sidecar.

Code: validate.AdminPassword counted bytes, so six Cyrillic letters
passed for twelve characters; it counts runes, as the backup password
always did (TestAdminPasswordCountsCharacters). envdoc reads
cmd/selfpost-backup too, and a name kept in a constant. A stale comment
of cmd/panel's run ("three roles").

go vet + go test (26 packages) and make e2e on the test server for this
tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 02:07:55 +03:00
235 changed files with 6728 additions and 675 deletions
+32
View File
@@ -0,0 +1,32 @@
#!/bin/sh
# base-packages.sh: compare the Debian packages the mail path runs on with
# what debian:trixie-slim would install today (security audit 2026-10-17,
# I5). The image installs without pinning, and published tags are never
# rebuilt, so a security update of postfix, Cyrus SASL, OpenSSL or Berkeley
# DB reaches operators only with the next release. This fails when one of
# them has moved past the version the last release was built with - the
# table between the base-package-versions markers in docs/development.md -
# as the nudge to cut a patch release (and then to update the table).
#
# Run by .github/workflows/base-packages.yml every week; needs docker.
set -eu
doc=docs/development.md
table=$(sed -n '/<!-- base-package-versions -->/,/<!-- \/base-package-versions -->/p' "$doc" |
sed -n 's/^| `\([^`]*\)` | `\([^`]*\)` |$/\1 \2/p')
[ -n "$table" ] || { echo "no base-package-versions table in $doc" >&2; exit 2; }
names=$(echo "$table" | cut -d' ' -f1 | tr '\n' ' ')
# apt-cache policy's "Candidate:" is what an install would take now.
now=$(docker run --rm debian:trixie-slim sh -c "apt-get update -qq >/dev/null && for p in $names; do printf '%s %s\n' \"\$p\" \"\$(apt-cache policy \"\$p\" | sed -n 's/^ *Candidate: //p')\"; done")
moved=0
echo "$table" | while read -r name built; do
cand=$(echo "$now" | awk -v n="$name" '$1 == n { print $2 }')
if [ "$cand" != "$built" ]; then
echo "::warning::$name: the last release was built with $built, trixie now installs ${cand:-nothing}" >&2
echo moved
fi
done | grep -q moved && moved=1
if [ "$moved" = 1 ]; then
echo "A package the mail path runs on has moved: cut a patch release, then update the table in $doc." >&2
exit 1
fi
echo "Every watched package is as the last release was built."
+20
View File
@@ -0,0 +1,20 @@
name: base packages
# Every Monday: has a Debian package the mail path runs on moved since the
# last release was built? A red run is the nudge to cut a patch release
# (security audit 2026-10-17, I5; .github/scripts/base-packages.sh).
on:
schedule:
- cron: "17 5 * * 1"
workflow_dispatch:
permissions:
contents: read
jobs:
watch:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Compare with trixie
run: sh .github/scripts/base-packages.sh
+22 -8
View File
@@ -66,14 +66,14 @@ jobs:
arch: arm64
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
ref: v${{ needs.prepare.outputs.version }}
- uses: docker/setup-buildx-action@v3
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Build image (native, loaded locally for the e2e gate)
uses: docker/build-push-action@v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
file: build/Dockerfile
@@ -83,7 +83,7 @@ jobs:
VERSION=${{ needs.prepare.outputs.version }}
tags: selfpost:e2e
- uses: actions/setup-go@v5
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: "1.26"
cache-dependency-path: test/e2e/go.sum
@@ -110,8 +110,22 @@ jobs:
fi
done
# The binaries about to be pushed, as built in the image - with the Go
# of golang:1.26-trixie, which may lag the runner's: a standard-library
# advisory fixed in a later patch fails the release until the base tag
# moves, which is the point (security audit 2026-10-17, I4).
- name: Vulnerabilities in the image's binaries
run: |
set -euo pipefail
id=$(docker create selfpost:e2e)
trap 'docker rm -f "$id" >/dev/null' EXIT
for bin in panel selfpost-backup; do
docker cp "$id:/usr/local/bin/$bin" "/tmp/$bin"
go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 -mode=binary "/tmp/$bin"
done
- name: Log in to ghcr.io
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -152,14 +166,14 @@ jobs:
needs: [prepare, build]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
ref: v${{ needs.prepare.outputs.version }}
- uses: docker/setup-buildx-action@v3
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to ghcr.io
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+14 -2
View File
@@ -5,11 +5,16 @@ on:
branches: [main]
pull_request:
# The job reads the repository and nothing else (security audit 2026-10-17,
# review of stages 2-6).
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
# design-first.sh reads each commit's parent and the template history.
fetch-depth: 0
@@ -20,7 +25,7 @@ jobs:
HEAD: ${{ github.event.pull_request.head.sha || github.sha }}
run: bash .github/scripts/design-first.sh "$BASE" "$HEAD"
- uses: actions/setup-go@v5
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: "1.26"
cache: true
@@ -51,6 +56,13 @@ jobs:
- name: Test
run: go test ./...
# Known vulnerabilities in the module's dependencies and in the
# standard library of this Go, reached from SelfPost's code (security
# audit 2026-10-17, I4: twelve stdlib advisories went unnoticed until
# the base image's tag happened to move). Pinned like staticcheck.
- name: Vulnerabilities
run: go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./...
# The e2e module runs only in release.yml, after a release is published;
# a type error there would first show up then. Compile it here without
# running it: `go test -run` would still run TestMain, which brings up
+261
View File
@@ -5,6 +5,267 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased]
## [2.15.0] - 2026-10-18
The two proposals the security audit left as product choices. Migration
0014; schema 14.
### Added
- **Verified TLS to an inbound domain's upstream** (upstream-tls-verify; the
security audit's M7): a fourth choice beside Opportunistic, Required and
Off - Postfix's `secure` level, which also checks that the upstream's
certificate was issued by a trusted authority for its name. The upstream
is then given by name. Postfix's SMTP client now trusts Debian's
`ca-certificates`; mail to the recipients' MX is unchanged. Migration
0014 widens the stored TLS mode; schema 14.
- **A DMARC record for the server's own name** (server-dmarc-hint; the
security audit's S3): Server › Settings › Notification mail gives
`v=DMARC1; p=reject` at `_dmarc.<hostname>` beside the DKIM and SPF
records, checks it, and Health's card shows it. Without one a receiver
does not refuse mail forged in the server's name, a false password-reset
message among it; `p=none` is shown as weak.
## [2.14.5] - 2026-10-18
A line less in the mail log. No migration; schema 13.
### Fixed
- **ETRN is discarded from the EHLO reply silently**: since `2.14.4` Postfix
logged "discarding EHLO keywords: ETRN" for every connection on every port
(seen on the test server after the rollout); `silent-discard` keeps the
discard and drops the line.
## [2.14.4] - 2026-10-17
The follow-ups of the security audit of 2026-10-17
([docs/security-audit-2026-10-17.md](docs/security-audit-2026-10-17.md)),
stages 0-6: its one high (A2), its seven mediums and the lows. No migration;
schema 13. Read *Upgrading* below: the TLS key's group, and copy
`antispam/` again where the sidecar runs.
### Upgrading
- **Give the TLS private key to group 10997** (audit I1): `chgrp 10997
certs/privkey.pem` - or certbot's `archive/<name>/privkey*.pem` - and the
same in whatever renews it. Group 10998, which the guide named until now,
has Postfix in it; the backup keeps working with either, but only 10997
keeps the key from Postfix's daemons.
- **With the antispam sidecar, copy `antispam/` again** (audit S4): rspamd
4.2.2, and a third file, `antispam/rspamd/worker-proxy.inc`, that the
fragment now mounts; `docker compose ... up -d` recreates the sidecar.
- **Behind Apache on the host, set `TRUSTED_PROXY_CIDR`** if it is not set
(audit A1): the guide's install step prints the gateway to put there; the
panel's log says so after the first requests when it is missing.
### Security
- **A stolen `global` session no longer takes a full backup** (security
audit 2026-10-17, A2, high): the user form set the signed-in user's own
password without the current one, which then passed the backup's password
check. Adding a user, setting a password, changing an e-mail or a role on
the user form, and deleting a user now ask for your own account password,
as a backup does, under the same limit on wrong guesses - which now
counts wrong passwords only.
- **One stranger behind a shared proxy no longer locks everyone out of
signing in** (audit A1, A8, A9): sign-in counts failures per address and
username (a hundred per address alone), a right password no longer
counts, a link's page is limited per link, a reset request per address
and name, and an IPv6 client is one per /64. With every request coming
from one private address with `X-Forwarded-For` and `TRUSTED_PROXY_CIDR`
empty, the log says what to set. Refused sign-ins are logged.
- **Reset links pause for a day after five** (audit A3): anyone who knew a
username could keep a live reset link arriving in that mailbox five times
an hour. After five in a day the owner is told once that further requests
are ignored for 24 hours.
- **`/healthz` is not answered through the reverse proxy** (audit I6):
only a request with no `X-Forwarded-For` from loopback or a private
address gets the liveness answer - the container's own check, the host,
a monitor next to it; the internet gets `404`.
- **Every container has a process ceiling** (audit I3): `pids_limit` 1024
for SelfPost, 256 for the proxies and rspamd, 64 for certbot.
- **A save that changes no map no longer reloads Postfix, and DNS
re-checks are limited** (audit W6/M9): every application, upstream,
recipient or report-address save reloaded Postfix, and *Re-check DNS*
bypassed the cache without limit. Now only a changed file is written and
reloaded (Health's *Reload configuration* always re-reads), and a user
re-checks twenty times in 15 minutes.
- **A domain import is bounded** (audit W4, W5, W8): a DKIM key over 4096
bits, more than 500 applications, and a domain named as the server
itself - whose key signs the panel's own mail - are refused.
- **The build is pinned and checked** (audit I4, I5): GitHub Actions by
commit, certbot and Caddy by release; govulncheck on the source in CI and
on the image's binaries before a release is pushed; a weekly check says
when a Debian package the mail path runs on has moved, so that a patch
release follows.
- **The signature covers `Content-Type`, `MIME-Version` and `Message-ID`**
when the message has them (audit M6), and a header line too long to be
delivered unbroken is named in the log as a body line is (M8).
- **A session made under a longer `PANEL_SESSION_IDLE_DAYS` is cut to the
current window** at its next request (audit A10), not kept until its old
expiry.
- **The antispam sidecar runs rspamd 4.2.2, hardened** (audit S4): from
3.11.1 (December 2024; the line between fixed a heap overflow, a
use-after-free on reload and parser crashes), with no Linux capability,
`no-new-privileges`, and only the milter on the compose network - the
scanner and the controller, whose default password is public, stay on the
sidecar's loopback. A third file, `antispam/rspamd/worker-proxy.inc`, is
mounted; copy it with the fragment.
- **The TLS private key no longer has to be readable by Postfix** (audit
I1): the guide gave it to group 10998 for the panel's backup, and
`postfix` is in that group, so every Postfix daemon - an smtpd talking to
the internet included - could read it. A new group, `backupkey` (10997),
holds the panel alone; the guide, Help and the backup's refusal name it.
- **A domain's rate limit is taken only by its own applications** (audit
M1): the level-2 domain limit was read from `MAIL FROM`, which reaches the
milter before Postfix holds it to the login, so a client that never
signed in, or another tenant's application, could use up a domain's
limit. It is now the signed-in application's domain.
- **One accepted message is one Inbound › Log row per recipient** (audit
M3): a recipient repeated in RCPT TO, up to Postfix's thousand, was a row
each, and the journal milter's rows did not count toward the 10 000-row
cut between the six-hourly prunes. Repeats are one row, a message's rows
are written in one transaction, and both writers count toward the cut.
- **A domain's custom DMARC report address may not be on the server's own
name** (audit W1): it opened a mailbox the whole internet could file
reports into, under any domain, or another domain's hosted one. Only the
hosted address of a domain that chose it is a mailbox now, and a report
must arrive at the address tagged with its domain.
- **A domain's DMARC reports are deleted with it** (audit A5/W2): reports
belong to a domain by name, and a domain deleted and added again showed
its old reports to whoever it was assigned to then.
- **The sender map is rebuilt one change at a time** (audit W3): two
application changes at once could leave the map without the newer one
until the next change.
- **A stranger's recipient no longer writes an Inbound › Log row** (audit
M2): Postfix quotes a refused recipient in its reply with the quotes
removed, so `RCPT TO:<"; from=<x> to=<y> …"@domain>` spelled a second
sender and recipient there, and the parser took the first. It now takes
Postfix's own tail at the end of the line.
- **The sender check keeps two `From:` and two `Sender:` lines** (audit M5),
not every one an application sends: the judgement needs no more, and a
hundred long lines were megabytes held per connection.
- **A link's token stays out of the duplicate-cookie log line** (audit A4),
as it already did out of the origin check's.
- **ETRN is refused and no longer offered** (audit S6): Postfix's default
let anyone on port 25 flush the queue toward an inbound domain's upstream.
- **SECURITY.md names the 2.x line as the supported one** (audit X1); its
table still said 1.x.
- **The entrypoint follows no link at the DMARC maps** (audit I2): the
three `dmarc_*` maps root creates at the start were missing from the list
of paths where a symbolic link is removed first.
### Documentation
- security.md says what the code does where it said more (audit A6, A7,
W7): a wrong method answers `405` before the role is looked at; the setup
token is made anew at the first request after it expired; two GETs make
the server's own DKIM key on first view. The README's local trial says
that the compose file is the hardened deployment.
- **The upstream's "Required" TLS checks no certificate**, and the guide and
Help now say so (audit M7): it guards against a listener, not against one
who answers in the upstream's place.
## [2.14.3] - 2026-10-17
A tag beside a label. No migration; schema 13.
### Fixed
- **A tag beside a label no longer squeezes the field under it**: the
e-mail's "not confirmed" on Account made its label taller and left less
room above the field than Username has (the owner's phone).
## [2.14.2] - 2026-10-17
The last small items of the day. No migration; schema 13.
### Added
- **Health dials the inbound journal's socket** with the inbound relay on:
the milter that writes Inbound › Log was the one the panel did not check.
The send log's socket is named as the mockup names it, `journal-milter`.
### Changed
- **A DMARC report says which day it is**: its day stays the reporter's, in
UTC (owner, 2026-10-17: accepted as it is), and the report's page now says
so under its window; Settings and Help name it as the one exception to
the panel's zone. A daily report from midnight to midnight is one day, not
two.
### Documentation
- The guide's gaps from the docs audit: `/license` and `/server/components`,
DMARC pages' paths, `selfpost-backup -o`, `-i` and `-version`, the
Inbound log's retention field, `PANEL_COOKIE_SECURE`.
## [2.14.1] - 2026-10-17
Overview's head in the mockup's words. No migration; schema 13.
### Changed
- **Overview's head says it as the mockup does** (overview-head): "Mail is
flowing. One thing needs attention." - or "Nothing needs attention", or
"Mail may not be flowing" when a check fails - and under it each check
that is not fine with its value ("TLS certificate: expires in 12 days.").
## [2.14.0] - 2026-10-17
Password reset by e-mail, to a confirmed address. Migration 0013; schema 13.
### Added
- **Password reset by e-mail** (password-reset): *Forgot your password?* on
the sign-in page sends a one-time link, for 30 minutes, to the account's
e-mail - once that address has been confirmed by a link sent to it. A new
address is sent its confirmation link when it is saved (Account, or the
user form); Account shows whether it is confirmed and can send the link
again. Notifications go to the address confirmed or not. Setting a new
password from a link signs every session of the account out. Migration
0013; schema 13.
### Changed
- **Changing your e-mail on Account asks for your password**, as changing
the password does: the address is where a reset link goes.
## [2.13.1] - 2026-10-17
The documents and the panel's words checked against the code. No migration;
schema 12.
### Fixed
- **A panel password's twelve characters are characters**: the minimum
counted bytes, so six letters of a non-Latin alphabet passed for twelve.
It counts characters now, as the backup password always did. Passwords
already set are not affected.
### Changed
- **The panel and its mockups say the same** (docs-audit): an inbound
domain's MX box says when it was checked and its side menu links the
domain's Inbound log; a deferred message's history ends with the wait in
the queue; Overview says it refreshes on its own; DMARC's hint for an
unaligned third party reads as the mockup. Help says what the code does:
an application's password is kept by the server so an export can carry
it, every password check of a backup counts, a password set for another
user signs them out everywhere, the limit mail is daily per sending
domain, and a domain administrator is no longer shown the Filter lists
topic (their page is the global role's).
### Documentation
- The documents checked against the code (docs-audit, 2026-10-17): the
guide, architecture, schema-migrations, security, development, README and
product say what the code does.
## [2.13.0] - 2026-10-17
The panel in the container's time zone. No migration; schema 12.
+10 -5
View File
@@ -27,11 +27,11 @@ send log and DNS checks in the panel, encrypted backups.
- Optional inbound relay on port 25 (backup-MX / forwarder; off by default)
- Optional DMARC aggregate report ingest on port 25 (off by default)
- In-panel Help: a page of topics, opened from the `?` in the head of a box
- Per-application client IP allow-list for SMTP AUTH (optional)
- Per-application client IP allow-list for submitting mail (optional; refused at MAIL FROM, not an authentication boundary)
- Web panel grouped by mail direction — Overview, Outbound (domains, log, queue, DMARC reports), Inbound, Server (health, system log, backup, users, settings)
- Multi-domain relay — each SASL application is bound to one sending domain
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
- Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides
- Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and per-application 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)
@@ -112,8 +112,8 @@ outbound mail will not reach the real internet without DNS, PTR, and port 25.
> local image and pulls nothing:
>
> ```sh
> docker build -f build/Dockerfile --build-arg VERSION=2.13.0 \
> -t ghcr.io/mixeme/selfpost:2.13.0 .
> docker build -f build/Dockerfile --build-arg VERSION=2.15.0 \
> -t ghcr.io/mixeme/selfpost:2.15.0 .
> ```
```sh
@@ -122,9 +122,14 @@ docker run --rm -d --name selfpost-try \
-e SELFPOST_HOSTNAME=mail.local.test \
-e PANEL_COOKIE_SECURE=false \
-v selfpost-try-data:/data \
ghcr.io/mixeme/selfpost:2.13.0
ghcr.io/mixeme/selfpost:2.15.0
```
This trial runs with Docker's default capabilities and no
`no-new-privileges`; the compose file of a real deployment
(`deploy/docker-compose.yml`) drops every capability but the few it names,
sets `no-new-privileges` and a process ceiling.
**Get the setup URL** (pick one):
```sh
+10 -3
View File
@@ -3,15 +3,22 @@
## 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
2.x line** only; there is no backporting to earlier minors or to 1.x. 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 |
| latest 2.x | yes |
| earlier 2.x | no — upgrade first |
| 1.x | no — upgrade to 2.x (guide § Upgrading) |
| 0.x | no (pre-release) |
A security update of a Debian package the image runs - Postfix, Cyrus SASL,
OpenSSL, Berkeley DB - arrives with the next patch release built after it:
published image tags are never rebuilt. A weekly check
(`.github/workflows/base-packages.yml`) says when one has moved, and a patch
release follows.
## Reporting a vulnerability
**Do not open a public issue.** Use GitHub's private vulnerability reporting:
+14 -3
View File
@@ -109,9 +109,10 @@ RUN groupadd --system --gid 10999 panel && useradd --system --uid 10999 --gid p
# (set up in entrypoint.sh) - lets Postfix read the sasldb2/sender map and
# connect to the panel's milters. The DKIM keys are the panel's alone. (The
# supervisor control socket has a group of its own, `supervisor`, below.)
# The id is fixed, because the host sees it: an operator gives the TLS
# private key on the host to this group by number, so that the panel can put
# it into a full backup (guide § Taking a full backup). It lies outside the
# The id is fixed, because the host saw it: until 2.14.4 an operator gave the
# TLS private key on the host to this group by number - now to backupkey,
# below, which Postfix is not in - and a key given then still names it. It
# lies outside the
# range the distribution hands out to system groups, 100-999 (adduser 3.152,
# /etc/adduser.conf: "Default: FIRST_SYSTEM_GID=100, LAST_SYSTEM_GID=999";
# passwd 1:4.17.4-2, /etc/login.defs: SYS_GID_MIN 101, SYS_GID_MAX 999 - both
@@ -127,6 +128,16 @@ RUN groupadd --system --gid 10998 selfpost \
&& usermod -aG selfpost panel \
&& usermod -aG selfpost postfix
# The host's TLS private key is given to this group, not to selfpost, so that
# the panel can put it into a full backup (guide § Taking a full backup) and
# no Postfix daemon can read it: postfix is in selfpost, and a memory-safety
# bug in an smtpd reached from the internet would otherwise yield the key of
# the mail and of the panel (security audit 2026-10-17, I1). Postfix itself
# serves a root-only copy (postfix-config.sh). The panel is the one member.
# Fixed for the same reason as selfpost's, next to it.
RUN groupadd --system --gid 10997 backupkey \
&& usermod -aG backupkey panel
# The panel reads the mail queue with `postqueue -p` (and the status page's
# queue card). postqueue is setgid postdrop, which normally gives it the group
# needed to reach Postfix's showq socket - but the documented deployment runs
+4 -3
View File
@@ -91,6 +91,7 @@ drop_links /data/dkim /data/dkim.new /data/opendkim /data/sasl /data/postfix /da
/data/sasl/sasldb2 /data/postfix/queue \
/data/postfix/sender_login_maps /data/postfix/relay_domains \
/data/postfix/transport /data/postfix/relay_recipients /data/postfix/tls_policy \
/data/postfix/dmarc_recipients /data/postfix/dmarc_transport /data/postfix/dmarc_relay_domains \
/data/log/mail.log
# Restored backups or previously-created state may contain panel-owned files
# under /data; make sure they stay writable without disturbing anything that a
@@ -149,11 +150,11 @@ rm -f /data/sasl/sasldb2.new
# Postfix state under /data (architecture.md § Persistence). The panel writes
# sender_login_maps; Postfix owns the on-disk queue tree under queue/.
mkdir -p /data/postfix/queue
[ -e /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps
[ -e /data/postfix/sender_login_maps ] || [ -L /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps
# Inbound relay maps (written by the panel when INBOUND_RELAY_ENABLE=true).
# Empty files keep postfix check happy if the flag is on before any domain exists.
for f in relay_domains transport relay_recipients tls_policy; do
[ -e "/data/postfix/$f" ] || : > "/data/postfix/$f"
[ -e "/data/postfix/$f" ] || [ -L "/data/postfix/$f" ] || : > "/data/postfix/$f"
done
# Every map here is the panel's to write and Postfix's to read - the sender
# map, the inbound and the DMARC maps alike - so each file beside queue/ is
@@ -195,7 +196,7 @@ chmod 0755 /data/antispam
# because parent directory has insecure permissions" when the directory's
# group is not root and has write permission.
mkdir -p /data/log
[ -e /data/log/mail.log ] || : > /data/log/mail.log
[ -e /data/log/mail.log ] || [ -L /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 {} +
+2 -1
View File
@@ -43,7 +43,8 @@ reload() {
# same_pair says whether a certificate file and a private key file belong
# together, by comparing the public key each one prints. openssl-x509(1) and
# openssl-pkey(1) do not say the two print it byte for byte alike; measured
# in the image, 2026-10-15, OpenSSL 3.0.22: identical for RSA-2048, P-256 and
# in the image, 2026-10-15, OpenSSL 3.0.x of bookworm, before trixie's 3.5
# (re-read on 3.5.7 is due with the next look at this script): identical for RSA-2048, P-256 and
# P-384 keys, and for a P-256 key written in SEC1 form ("EC PRIVATE KEY") -
# certbot 2.0 and later issue ECDSA keys by default (assumptions C-X1).
same_pair() {
+28 -4
View File
@@ -94,7 +94,7 @@ HOSTNAME_VALUE="${SELFPOST_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}"
# other user; Postfix does not enforce it (smtpd loads the key as root in
# pre_jail_init(), Postfix 3.10.13 src/smtpd/smtpd.c), but the mount cannot
# give that mode - it is :ro and owned by the host's user, often readable by
# group 10998 for the panel's backup. So the pair is copied into a directory of
# group 10997 (the panel's alone) for the panel's backup. So the pair is copied into a directory of
# Postfix's own, the key root:root 0600, before postconf and `postfix check`
# (assumptions D-arch:154).
TLS_CERT_SRC="${TLS_CERT_FILE:-/etc/postfix/tls/fullchain.pem}"
@@ -297,9 +297,19 @@ postconf -e \
"local_transport=error:local mail delivery is disabled"
# Outbound delivery: straight to the recipient MX, opportunistic TLS.
# The client trusts the authorities Debian's ca-certificates ships, for an
# inbound domain's upstream with Verified TLS (the "secure" level in the TLS
# policy map, upstream-tls-verify): Postfix trusts none by default - the
# *_tls_CAfile defaults are empty and tls_append_default_CA is "no" (Postfix
# 3.10.13, src/global/mail_params.h, DEF_SMTP_TLS_CA_FILE,
# DEF_TLS_APPEND_DEF_CA). The levels may and encrypt check nothing and only
# log the verdict; mail to the recipients' MX is unchanged. The file is the
# bundle update-ca-certificates writes (ca-certificates, installed in the
# image), read by smtp(8) unchrooted (chroot is off below).
postconf -e \
"smtp_tls_security_level=may" \
"smtp_tls_note_starttls_offer=yes"
"smtp_tls_note_starttls_offer=yes" \
"smtp_tls_CAfile=/etc/ssl/certs/ca-certificates.crt"
# TLS server material shared by every inbound service. auth_only guarantees
# credentials are never accepted before TLS is up on any port. Postfix 3.10.13,
@@ -323,11 +333,21 @@ postconf -e \
# and the banner names no distribution (Debian's adds "(Debian)": postfix
# 3.10.13-0+deb13u1, debian/main.cf.in:15 "(@DEB_VENDOR@)", filled in by
# debian/rules:118; the upstream default is "$myhostname ESMTP $mail_name").
# ETRN is refused to everyone and not offered: Postfix's default lets any
# client flush the queue toward an inbound domain's upstream
# (fast_flush_domains = $relay_domains), and nothing here needs it (security
# audit 2026-10-17, S6; Postfix 3.10.13, src/smtpd/smtpd.c, etrn_cmd() asks
# smtpd_check_etrn(), whose "reject" answers reject_code, 554). The discard is
# silent: without the pseudo keyword smtpd logs "discarding EHLO keywords:
# ETRN" for every connection (src/smtpd/smtpd.c:2016, EHLO_MASK_SILENT;
# src/global/ehlo_mask.c:88; seen on the test server after 2.14.4).
postconf -e \
"smtpd_tls_protocols=>=TLSv1.2" \
"smtpd_tls_mandatory_protocols=>=TLSv1.2" \
"smtp_tls_mandatory_protocols=>=TLSv1.2" \
"disable_vrfy_command=yes" \
"smtpd_etrn_restrictions=reject" \
"smtpd_discard_ehlo_keywords=silent-discard,etrn" \
'smtpd_banner=$myhostname ESMTP'
# SASL: Cyrus with the local sasldb2 the panel maintains. The realm is left
@@ -534,8 +554,12 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
TRANSPORT_SETTING=""
RECIPIENT_RESTRICTIONS="reject"
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
# [ -e ] is false for a dangling link and the redirect would follow
# it: the entrypoint's drop_links removes links at these paths, and
# this refuses to create through one all the same (audit 2026-10-17,
# I2).
for f in "$RELAY_DOMAINS_MAP" "$TRANSPORT_MAP" "$RELAY_RECIPIENTS_MAP" "$TLS_POLICY_MAP"; do
[ -e "$f" ] || : > "$f"
[ -e "$f" ] || [ -L "$f" ] || : > "$f"
done
RELAY_DOMAINS_SETTING="texthash:${RELAY_DOMAINS_MAP}"
TRANSPORT_SETTING="texthash:${TRANSPORT_MAP}"
@@ -543,7 +567,7 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
fi
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
for f in "$DMARC_RECIPIENTS_MAP" "$DMARC_TRANSPORT_MAP" "$DMARC_RELAY_DOMAINS_MAP"; do
[ -e "$f" ] || : > "$f"
[ -e "$f" ] || [ -L "$f" ] || : > "$f"
done
ln -sf /usr/local/bin/panel /usr/local/bin/dmarc-ingest
# flags=, user= and argv= are arguments of pipe(8), not -o parameters:
+10 -4
View File
@@ -14,13 +14,15 @@ import (
// are compared as they are, not as lists kept by hand beside them (audit
// 2026-10-12, stage 5 - the three lists this file held were compared with each
// other and with nothing else). Read: what the panel's code asks of the
// environment (cmd/panel and internal, tests aside) and what the start-up
// environment (cmd/panel, cmd/selfpost-backup and internal, tests aside) and what the start-up
// scripts take with a default (build/*.sh, "${VAR:-…}"). Documented: the
// operator's table in guide.md § Environment variables, with the paragraph
// under it, and the internal list in architecture.md § Configuration.
var (
goEnvRead = regexp.MustCompile(`(?:envDefault|envInt|envBool|envSeconds|envDuration|os\.Getenv|os\.LookupEnv)\("([A-Z][A-Z0-9_]+)"`)
goEnvRead = regexp.MustCompile(`(?:envDefault|envInt|envBool|envSeconds|envDuration|os\.Getenv|os\.LookupEnv)\("([A-Z][A-Z0-9_]+)"`)
// A name kept in a constant, as the backup CLI keeps its password's.
goEnvConst = regexp.MustCompile(`\w+Env\s*=\s*"([A-Z][A-Z0-9_]+)"`)
scriptEnvRead = regexp.MustCompile(`\$\{([A-Z][A-Z0-9_]+):[-?=]`)
docEnvName = regexp.MustCompile("`([A-Z][A-Z0-9_]+)`")
)
@@ -38,12 +40,16 @@ func readFile(t *testing.T, path string) string {
func envRead(t *testing.T) map[string]bool {
t.Helper()
keys := map[string]bool{}
for _, root := range []string{".", filepath.Join("..", "..", "internal")} {
for _, root := range []string{".", filepath.Join("..", "selfpost-backup"), filepath.Join("..", "..", "internal")} {
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return err
}
for _, m := range goEnvRead.FindAllStringSubmatch(readFile(t, path), -1) {
text := readFile(t, path)
for _, m := range goEnvRead.FindAllStringSubmatch(text, -1) {
keys[m[1]] = true
}
for _, m := range goEnvConst.FindAllStringSubmatch(text, -1) {
keys[m[1]] = true
}
return nil
+11
View File
@@ -118,6 +118,7 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
SignSocket: cfg.signSocket,
SignLocalSocket: cfg.signLocalSocket,
JournalSocket: cfg.journalSocket,
InboundJournalSocket: cfg.inboundJournalSocket,
SenderSocket: cfg.senderSocket,
SessionIdleDays: cfg.sessionIdleDays,
DNSResolvers: cfg.dnsResolvers,
@@ -169,7 +170,9 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
srv := newHTTPServer(cfg.httpAddr, srvApp.Handler())
// Shut the server down cleanly when the process is asked to stop.
shutDown := make(chan struct{})
go func() {
defer close(shutDown)
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -180,5 +183,13 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
// ListenAndServe returns at the stop, before its requests have
// (net/http, Server.Shutdown); Shutdown returns once they have. The links
// they mail after their answers go on after that, and end before the
// database closes.
<-shutDown
drainCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srvApp.Drain(drainCtx)
return nil
}
+9 -2
View File
@@ -318,6 +318,13 @@ func openData(cfg config, resync func(*store.Store) error) (st *store.Store, res
}
if restored {
log.Printf("restore manifest accepted; regenerating mail-path maps from SQLite")
// A link taken after the backup was made would work again from it
// (security review, 2026-10-17); a link sent before a restore is
// asked for again.
if err := st.DeleteAllUserLinks(); err != nil {
_ = st.Close()
return nil, false, err
}
if err := resync(st); err != nil {
_ = st.Close()
return nil, false, err
@@ -330,8 +337,8 @@ func openData(cfg config, resync func(*store.Store) error) (st *store.Store, res
return st, restored, nil
}
// run starts the panel's three roles and blocks until a shutdown signal or the
// first fatal error from any role. A signal triggers a clean stop of all
// run starts the panel's roles (the list below) 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).
+23 -9
View File
@@ -9,10 +9,11 @@
# Requires INBOUND_RELAY_ENABLE=true in .env. The milter sees the real client
# IP, HELO and PTR — unlike the upstream, which only sees SelfPost's address.
#
# The rspamd image is pinned to one build, 3.11.1-3 - "3.11" and "3.11.1"
# move with each rebuild (Docker Hub rspamd/rspamd tags 3.11.1-0 ... -3) -
# and it is the build the comments in antispam/rspamd/ were read against;
# bump it deliberately, and read them again. Default milter action is fail-open (accept) so a down sidecar
# The rspamd image is pinned to one build, 4.2.2-0 - "4.2", "4" and "latest"
# are rebuilt every night - and it is the build the comments in
# antispam/rspamd/ were read against (rspamd 4.2.2, rspamd-docker v4.2.2+0;
# 3.11.1-3 until 2.14.4, security audit 2026-10-17, S4); bump it
# deliberately, and read them again. Default milter action is fail-open (accept) so a down sidecar
# does not block backup-MX. Set INBOUND_ANTISPAM_MILTER_ACTION=tempfail to
# defer instead.
@@ -26,12 +27,23 @@ services:
- antispam
antispam:
# The milter: rspamd 3.11.1, conf/worker-proxy.inc, "milter = yes" for the
# rspamd_proxy worker, which the image binds to *:11332 (rspamd-docker
# v3.11.1+3, patches/rspamd-docker-config.diff), and runs as uid 11333
# (its Dockerfile, USER 11333:11333).
image: rspamd/rspamd:3.11.1-3
# The milter: rspamd 4.2.2, conf/worker-proxy.inc, "milter = yes" for the
# rspamd_proxy worker, bound to *:11332 by antispam/rspamd/worker-proxy.inc
# while every other worker stays on loopback (RSPAMD_LOCAL_ADDR below);
# it runs as uid 11333 (rspamd-docker v4.2.2+0, Dockerfile, USER
# 11333:11333) and needs no capability: it starts unprivileged, binds
# ports above 1024 and runs nothing setuid (src/rspamd.c, detect_priv()).
image: rspamd/rspamd:4.2.2-0
restart: unless-stopped
environment:
# Workers on loopback; the proxy alone is put back on every address.
RSPAMD_LOCAL_ADDR: localhost
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
# A ceiling on processes and threads (security audit 2026-10-17, I3).
pids_limit: 256
# No host ports: only SelfPost talks to the milter on the compose network.
volumes:
# The verdict headers SelfPost's inbound journal reads.
@@ -39,4 +51,6 @@ services:
# The allow and deny lists kept in the panel: the rules, and the files
# SelfPost writes them to.
- ./antispam/rspamd/multimap.conf:/etc/rspamd/local.d/multimap.conf:ro
# The proxy (the milter) on the compose network, nothing else.
- ./antispam/rspamd/worker-proxy.inc:/etc/rspamd/local.d/worker-proxy.inc:ro
- ./data/antispam:/etc/rspamd/selfpost:ro
+5 -3
View File
@@ -8,7 +8,7 @@
# rspamd removes headers of these names that a sender wrote himself before it
# adds its own - for every client, because of the two lines below. Without
# them it would do neither for a client it counts as local or one that
# authenticated: rspamd 3.11.1, src/plugins/lua/milter_headers.lua,
# authenticated: rspamd 4.2.2 (as 3.11.1), src/plugins/lua/milter_headers.lua,
# skip_wanted() - skip_local and skip_authenticated are on by default, and
# "local" is loopback, a unix socket, or an address in local_addrs, the
# private ranges (src/lua/lua_ip.c, lua_ip_is_local(); conf/options.inc). The
@@ -20,7 +20,9 @@
# message carrying its own X-Spamd-Result and X-Rspamd-Action): without the
# two lines, from 10.0.0.5 or as an authenticated user, the milter reply
# removed neither header, added neither, and added the signature; with
# them, both removed and rspamd's own added, as from a public address.
# them, both removed and rspamd's own added, as from a public address. The
# same again on 2026-10-17 in rspamd/rspamd:4.2.2-0 (from 10.0.0.5), and the
# signature equal to HMAC-SHA256 of the queue id computed with openssl.
#
# Without this file the journal still lists accepted mail, with no verdict
# next to it.
@@ -34,7 +36,7 @@
# signature travels on with the mail and is of no use to whoever sees it: it
# fits that one queue id.
#
# rspamd 3.11.1, src/plugins/lua/milter_headers.lua: a custom routine is called
# rspamd 4.2.2 (as 3.11.1), src/plugins/lua/milter_headers.lua: a custom routine is called
# as function(task, common_meta) and returns error, headers to add, headers to
# remove, metadata; a remove value of 0 removes every header of that name
# (docs.rspamd.com/modules/milter_headers, "Custom routines").
+3 -3
View File
@@ -3,7 +3,7 @@
# The allow and deny lists kept in the panel (Inbound > Filter lists). SelfPost
# writes them as six files under data/antispam/, which the fragment mounts at
# /etc/rspamd/selfpost; rspamd rereads a file when it changes, no restart.
# (rspamd 3.11.1, src/libserver/maps/map.c: a file map is watched with libev's
# (rspamd 4.2.2 (as 3.11.1), src/libserver/maps/map.c: a file map is watched with libev's
# ev_stat - inotify where the kernel has it, else a stat poll every
# map_watch_interval x map_file_watch_multiplier, 5min x 0.1, conf/options.inc;
# docs.rspamd.com/configuration/maps asks for an atomic rename, which
@@ -14,7 +14,7 @@
# allowed one is not accepted outright - rspamd would then let whichever of
# the two rules ran first decide - but given a weight no spam score
# outweighs, which a refusal by the deny list still overrides. (rspamd
# 3.11.1, src/plugins/lua/multimap.lua: every rule is registered as a
# 4.2.2 (as 3.11.1), src/plugins/lua/multimap.lua: every rule is registered as a
# "normal" symbol, one with an action with the "passthrough" augmentation;
# no "prefilter" setting is read. Measured with this file in a throwaway
# rspamd/rspamd:3.11.1-3, 2026-10-15: every mix of an allow and a deny entry
@@ -29,7 +29,7 @@
# bounce whose From: domain is on the deny list was refused by default and
# is not with this setting; a sender on the list is refused either way.
#
# A domain entry is that domain, not its subdomains. (rspamd 3.11.1,
# A domain entry is that domain, not its subdomains. (rspamd 4.2.2 (as 3.11.1),
# src/libserver/maps/map_helpers.c: a hash map is looked up by the whole key,
# case-insensitively - rspamd_icase_hash, rspamd_lc_cmp; "email:domain" gives
# the address's domain part, src/plugins/lua/multimap.lua, apply_addr_filter().)
+18
View File
@@ -0,0 +1,18 @@
# Mounted into the sidecar as /etc/rspamd/local.d/worker-proxy.inc.
#
# The milter - the proxy worker - is the one listener SelfPost needs on the
# compose network. The fragment sets RSPAMD_LOCAL_ADDR=localhost, which keeps
# every worker on the container's loopback: the normal worker (11333), whose
# scan accepts a client-named file or shared-memory input, and the controller
# (11334), whose default password is the well-known "q1" (rspamd 4.2.2,
# conf/rspamd.conf: each worker's bind_socket is the env variable LOCAL_ADDR,
# localhost by default, and its port; conf/worker-controller.inc: password =
# "q1"; the rspamd-docker v4.2.2+0 Dockerfile sets RSPAMD_LOCAL_ADDR=* for all
# three). This line puts the proxy alone back on every address: a local.d
# scalar at priority 1 replaces the default one (contrib/libucl/ucl_parser.c,
# UCL_DUPLICATE_MERGE). The proxy still reaches the normal worker on
# localhost, as before (security audit 2026-10-17, S4).
#
# rspamd expands its template syntax in these files, comments included:
# quote none of it here.
bind_socket = "*:11332";
+1 -1
View File
@@ -1,6 +1,6 @@
# Example host Apache vhost for the SelfPost panel (default scenario). Install
# on the host running Apache — NOT inside the SelfPost container. Requires
# mod_ssl and mod_proxy/mod_proxy_http enabled (`a2enmod ssl proxy proxy_http`).
# mod_ssl, mod_proxy/mod_proxy_http and mod_rewrite enabled (`a2enmod ssl proxy proxy_http rewrite`).
#
# Certificates: obtain with the Apache certbot plugin against THIS vhost
# (`certbot --apache -d mail.example.com`). Certbot edits this file in place to
+7 -2
View File
@@ -16,7 +16,8 @@
# the CA URL's host and path, "acme-v02.api.letsencrypt.org-directory" for Let's
# Encrypt). caddy/modules/caddytls/automation.go, DefaultIssuers(): ZeroSSL is a
# second issuer only when an email is configured, and the Caddyfile below sets
# none. Read at the projects' master of 2026-10-14: `caddy:2` is a moving tag.
# none. Read at the projects' master of 2026-10-14, when the image was the
# moving `caddy:2`; it is pinned below now, and a bump re-reads these.
#
# Merge with the base file — run this from the deploy/ directory (Compose
# resolves every relative path in both files against the directory of the
@@ -46,8 +47,12 @@ services:
- ./caddy/caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>:/etc/postfix/tls:ro
caddy:
image: caddy:2
# Pinned to a release: "2" moves under a running server (security audit
# 2026-10-17, I4). Bump it deliberately.
image: caddy:2.11.7
restart: unless-stopped
# A ceiling on processes and threads (security audit 2026-10-17, I3).
pids_limit: 256
depends_on:
- selfpost
ports:
+13 -5
View File
@@ -23,7 +23,7 @@
services:
selfpost:
image: ghcr.io/mixeme/selfpost:2.13.0
image: ghcr.io/mixeme/selfpost:2.15.0
restart: unless-stopped
environment:
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
@@ -99,10 +99,12 @@ services:
# when the variable is true.
- "25:25"
- "127.0.0.1:8080:8080"
# Hardening. SelfPost's entrypoint still needs to run as
# root very briefly to fix /data ownership and normalise permissions
# under the shared `selfpost` group (see build/entrypoint.sh) before
# supervisord drops the panel to an unprivileged user — so this cannot be
# Hardening. SelfPost's entrypoint runs as root 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; supervisord, Postfix's master, cert-reload and
# logrotate stay root for the container's life, which is inherent to
# them (security audit 2026-10-17, I3) — 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
@@ -151,3 +153,9 @@ services:
- SETGID
- DAC_OVERRIDE
- KILL
# A ceiling on processes and threads, so that a runaway or compromised
# program cannot use up the host's (security audit 2026-10-17, I3).
# Postfix starts up to 100 processes per service by default
# (default_process_limit), and a busy server runs three smtpd services,
# cleanup and smtp at once; 1024 leaves them room.
pids_limit: 1024
+6 -1
View File
@@ -47,6 +47,8 @@ services:
nginx:
image: nginx:1.27
restart: unless-stopped
# A ceiling on processes and threads (security audit 2026-10-17, I3).
pids_limit: 256
depends_on:
- selfpost
ports:
@@ -58,7 +60,10 @@ services:
- ./nginx/certbot-www:/var/www/certbot:ro
certbot:
image: certbot/certbot:latest
# Pinned like every image here: "latest" moves under a running server
# (security audit 2026-10-17, I4). Bump it deliberately.
image: certbot/certbot:v5.8.0
pids_limit: 64
volumes:
- ./nginx/certbot-etc:/etc/letsencrypt
- ./nginx/certbot-www:/var/www/certbot
@@ -40,6 +40,8 @@ services:
traefik:
image: traefik:v3.1
restart: unless-stopped
# A ceiling on processes and threads (security audit 2026-10-17, I3).
pids_limit: 256
depends_on:
- selfpost
command:
+1 -1
View File
@@ -59,7 +59,7 @@ grep -q -- '-----BEGIN CERTIFICATE-----' "$tmp_cert" || die "the certificate of
grep -q -- '-----BEGIN .*PRIVATE KEY-----' "$tmp_key" || die "the key of $DOMAIN is not PEM"
chmod 0640 "$tmp_cert" "$tmp_key"
# The group the files had stays theirs: the guide gives the key to group 10998
# The group the files had stays theirs: the guide gives the key to group 10997
# so the panel can read it for a full backup, and a run from root's cron would
# otherwise hand it to root's group (review of audit stage 5).
for f in fullchain privkey; do
+59 -37
View File
@@ -63,7 +63,7 @@ The panel runs `postfix-reload` through supervisord's XML-RPC interface and
waits for its exit status (`internal/supervisor`, `RunOnce`); the program
rests EXITED after a run.
**Liveness:** `GET /healthz` (unauthenticated) returns 200 when panel - whose
**Liveness:** `GET /healthz` (unauthenticated; answered only to a request with no `X-Forwarded-For` from loopback or a private address, else 404) returns 200 when panel - whose
roles include the DKIM signer - and postfix are RUNNING
(`internal/health`, `Liveness`); Docker `HEALTHCHECK` uses the same probe (its
90-second start period covers postfix's STARTING).
@@ -103,7 +103,10 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
`smtpd_milters` is always overridden on this service, because main.cf's
submission chain would otherwise let a signing outage defer relayed mail and
would file inbound mail into the outbound send log under the sender's
domain. With the inbound relay on the chain is the optional antispam milter,
domain. `ETRN` is refused on every listener and not offered in the EHLO
reply (`smtpd_etrn_restrictions=reject`, `smtpd_discard_ehlo_keywords=silent-discard,etrn`):
Postfix's default lets any client flush the queue toward an inbound
domain's upstream. With the inbound relay on the chain is the optional antispam milter,
then the panel's **inbound journal milter** (unix socket
`INBOUND_JOURNAL_MILTER_SOCKET`, default
`/run/selfpost/inbound-journal.sock`, `default_action=accept`); with DMARC
@@ -127,7 +130,7 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
rspamd removes a sender's own copies of the three headers and writes and
signs its own for every message it lets through: `skip_local` and
`skip_authenticated` are off in that file, so this holds whatever the
sender's address (rspamd 3.11.1, `src/plugins/lua/milter_headers.lua`,
sender's address (rspamd 4.2.2 (as 3.11.1), `src/plugins/lua/milter_headers.lua`,
`skip_wanted()`).
With the filter down - it is attached fail-open - a sender's own verdict
headers carry no signature and the row is that of mail nobody judged.
@@ -135,9 +138,10 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
Refusals come from `mail.log`: this listener logs as `postfix/inbound`
(`syslog_name`), and the log tailer turns a `reject:` line of
`postfix/inbound/smtpd` (Postfix's own policy) and a `milter-reject` /
`milter-hold` / `milter-discard` line of `cleanup` (the filter's verdict,
counted only for a queue id that listener opened) into a row, dated by the
line, when the recipient is at an inbound domain
`milter-discard` line of `cleanup` (the filter's verdict, counted only for
a queue id that listener opened) into a row, dated by the line, when the
recipient is at an inbound domain; a message the filter holds goes on to
the inbound journal milter, which writes its row as `held`
([internal/logtail/inbound.go](../internal/logtail/inbound.go)). A line
names one recipient (the last), so a message refused at end-of-message is
one row whatever its recipient count. The tailer saves its place every five seconds,
@@ -145,7 +149,8 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
start are written only if the same row is not there already. The
journal is pruned every six hours: rows
older than the `inbound_spam_log_retention_days` setting, then down to
10 000.
10 000 - and down to 10 000 every 500 rows either writer adds in between
(the store counts them, `countInboundWrites`).
**Filter lists** (`inbound_spam_list`): allow and deny entries for the
external filter - sender address, sender domain, client address or network -
validated and normalised by `internal/inbound` (`lists.go`) and written as
@@ -166,7 +171,7 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
allowed IP with a denied address were all `reject` (assumptions
D-arch:121). A sender entry is matched against
the envelope sender only (`extract_from = "smtp"`; by default rspamd takes
a bounce's `From:` header instead - rspamd 3.11.1,
a bounce's `From:` header instead - rspamd 4.2.2 (as 3.11.1),
`src/plugins/lua/multimap.lua`, the `from` callback).
How often an entry decided a message is not stored: `Lists.Usage` reads it
off the journal on each view of Filter lists - the rows a list decided (the
@@ -183,8 +188,9 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
an empty upstream host are omitted from the maps. Optional
`INBOUND_ANTISPAM_MILTER` only when inbound relay is enabled (not on
DMARC-only port 25); it is attached with its own `default_action`,
`INBOUND_ANTISPAM_MILTER_ACTION`, which is `accept` (fail-open) unless set
to `tempfail` (main.cf's `milter_default_action` is `tempfail`).
`INBOUND_ANTISPAM_MILTER_ACTION`, `accept` (fail-open, the default) or
`tempfail`, any other value stopping the start (main.cf's
`milter_default_action` is `tempfail`).
**DMARC ingest** (`DMARC_REPORTS_ENABLE=true`): allow-listed
report addresses via `check_recipient_access` on `postfix/dmarc_recipients`;
messages pipe to `dmarc-ingest` (symlink to `panel`). When both flags are
@@ -217,8 +223,8 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
and to smtpd's `client=` line in mail.log.
- **Chroot disabled** for all services (DNS/TLS inside container).
- **TLS certs** — read-only mount at `TLS_CERT_FILE` / `TLS_KEY_FILE`, copied
at start into `/etc/postfix/tls-internal`, owned by root and `0600` as
Postfix's TLS_README advises (Postfix itself reads the key as root and does
at start into `/etc/postfix/tls-internal`, owned by root, the key `0600`
(the certificate `0644`) as Postfix's TLS_README advises (Postfix itself reads the key as root and does
not check its mode), which the mount cannot give; Postfix reads the copy.
`cert-reload` compares the mount with the copy daily; when they differ and
the certificate and key belong together it renews the copy (each file
@@ -237,7 +243,9 @@ then OpenDKIM did, in a program of its own
(`internal/domain`, `Keys`; `DKIM_DIR`, default `/data/dkim`). A key is
made when a domain is added and an existing one is reused, never
overwritten: that would invalidate the record already published
(`EnsureKey`). A domain export carries the key and an import writes it. A
(`EnsureKey`). A domain export carries the key and an import writes it,
over whatever key the name had (`ImportKey`: the import is the domain
with exactly that key). A
deleted domain's key directory is removed once the selector table no
longer names it; one a deletion had to leave, Reload configuration removes
(`Service.Resync`).
@@ -288,8 +296,10 @@ then OpenDKIM did, in a program of its own
- **What is signed** — `relaxed/simple`, `rsa-sha256`, with
`github.com/emersion/go-msgauth` v0.7.0 (`dkim`). `h=`: every header of
the message whose name is in RFC 6376 § 5.4.1's list as OpenDKIM's
`dkim_should_signhdrs[]` held it, and `Sender` and `List-Unsubscribe-Post`
besides (`signHeaders`), each occurrence, in message order; then, once
`dkim_should_signhdrs[]` held it, and `Sender`, `List-Unsubscribe-Post`,
`Content-Type`, `MIME-Version` and `Message-ID` besides (`signHeaders`;
`Message-ID` is not over-signed: cleanup adds one a message lacks after the
milters), each occurrence, in message order; then, once
more, each of `Subject`, `Date`, `To`, `Cc`, `Reply-To` and `Sender` the
message has (`overSign` - only those present: Postfix's cleanup adds a
missing `Date:` after the milters, out of their sight, and a name in `h=`
@@ -310,8 +320,8 @@ then OpenDKIM did, in a program of its own
message has an hour from the end of its headers to its end - go-milter
v0.4.1 tells a session nothing of a dropped connection - after which it is
deferred, never signed over part of its body.
- **Logs** go to the panel's stdout, the container's log, as `dkim-signer:`
lines; nothing is written to `mail.log` (nor did OpenDKIM write there).
- **Logs** go to the panel's stderr, the container's log, as
`panel: … dkim-signer:` lines; nothing is written to `mail.log` (nor did OpenDKIM write there).
- **The old tree,** `/data/opendkim` - the keys, `KeyTable` and
`SigningTable` until `2.8.0`, shared with OpenDKIM through group
`selfpost`. `2.8.x` copied its keys to `/data/dkim/keys` at its first
@@ -574,9 +584,12 @@ and `MAIL_LOG` in [cmd/panel/main.go](../cmd/panel/main.go). Backups exclude
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 on its first line after the reload, as root and mode
`0600`, which the unprivileged panel user cannot read). `follow()` drains the
`queued`. After rename, logrotate runs `create 0640 postfix selfpost`, so the
file is there, readable by the panel through group `selfpost`, before postlogd
writes its first line after the reload; a file postlogd makes itself (one
removed while the container runs) is `0640` too, through
`maillog_file_permissions=0640` - Postfix's default is `0600`, which the
unprivileged panel user could not read. `follow()` drains the
old inode once more before switching descriptors, and keeps it open and reads
it for a minute after: postlogd keeps writing to the renamed file until the
reload makes it exit, seconds later (Postfix 3.10.13, src/postlogd/postlogd.c;
@@ -625,8 +638,9 @@ is mail of its own queue id and matches no row.
**Read offset is persisted** (`logtail_state` table, in the baseline schema): the
tailer stores its position plus a fingerprint of the log - the hash of its first
512 bytes and the file's device and inode, so two logs that begin alike are two
files and a renamed file keeps its fingerprint - and on start resumes from it,
512 bytes and the file's inode (not the device, which can change across a
host's reboot), so two logs that begin alike are two files and a renamed file
keeps its fingerprint - and on start resumes from it,
parsing the tail written while the panel was down. If the fingerprint no longer
matches, the log was rotated in the meantime: the tailer first opens
`mail.log.1` (left plain by `delaycompress`), and if that is the file the saved
@@ -635,7 +649,7 @@ start - what Postfix wrote between the stop and the rotation is not lost. A
`mail.log.1` that is another file (rotated twice) is logged with the time from
which nothing was read. Re-parsing lines is harmless because `UpdateStatus`
writes the same status onto the same row. Only a first-ever start, with nothing
stored, begins at end-of-file, so installing the panel does not replay a
stored (or a stored place that cannot be read), begins at end-of-file, so installing the panel does not replay a
pre-existing log.
**A refusal's line, and a refusal the panel did not write.** A message refused
@@ -815,7 +829,9 @@ building them only takes method values, so the route guard test can run it on
a bare `Server` and ask which pattern answers a path:
- **public** — `/healthz` (liveness), `/license` (the embedded `LICENSE`),
`/static/`, `/setup/`, `/login`, `/logout`;
`/static/`, `/setup/`, `/login`, `/logout`, and the links of
password-reset, opened from a mail signed in or not: `/login/reset`,
`/login/reset/<token>`, `/confirm/<token>` (`internal/web/auth/links.go`);
- **authenticated** — everything else, behind `RequireAuth`: a request without
a live session is redirected to `/login`.
@@ -838,8 +854,8 @@ only reads (the delete confirmation at `GET …/delete` renders a form).
| Outbound › DMARC reports | `GET /outbound/dmarc` · `…/dmarc/domains/{id}` · `…/dmarc/reports/{id}` — registered only with `DMARC_REPORTS_ENABLE=true` |
| Inbound › Domains | `GET, POST /inbound/domains` · `/inbound/domains/{id}` · `POST …/dns-recheck`, `…/upstream`, `…/recipients` · `GET, POST …/delete` — registered only with `INBOUND_RELAY_ENABLE=true` |
| Inbound › Log, Filter lists | `GET /inbound/log` · `GET, POST /inbound/filter-lists` · `POST /inbound/filter-lists/{id}/delete` — registered only with `INBOUND_RELAY_ENABLE=true` |
| Server | `GET /server/health` (+ `/fragment`) · `POST /server/health/recheck`, `/server/health/reload` · `GET /server/log` (+ `/fragment`) · `GET, POST /server/backup` · `POST /server/backup/import` · `/server/users`, `…/new`, `…/{uid}`, `…/{uid}/delete` · `GET, POST /server/settings` · `POST /server/settings/dns-recheck` · `GET /server/components` |
| Account | `GET /account` · `POST /account/profile`, `/account/password` |
| Server | `GET /server/health` (+ `/fragment`) · `POST /server/health/recheck`, `/server/health/reload`, `/server/health/test-message` · `GET /server/log` (+ `/fragment`) · `GET, POST /server/backup` · `POST /server/backup/import` · `/server/users`, `…/new`, `…/{uid}`, `…/{uid}/delete` · `GET, POST /server/settings` · `POST /server/settings/dns-recheck` · `GET /server/components` |
| Account | `GET /account` · `POST /account/profile`, `/account/password`, `/account/email/confirm` |
| User menu | `GET /help` · `POST /logout` (public mux) |
An application is addressed under its domain, and an application id that does
@@ -940,9 +956,8 @@ is one layout and one way to render.
package (`NewOutDomain`, `NewHelp`, …) that the handler fills. The shell is
derived from `Meta` and the feature flags (`SetInboundEnabled`,
`SetDMARCEnabled`) in one place, `Engine.shell`, so which entries the menu
has is decided once. No template reads a `map[string]any`; the one map left
in the handlers is the health sampler's, which is turned into a typed
`Health` before rendering.
has is decided once. No page's data is a map: the health sampler too
returns a typed `health.Machine`.
- **The component kit.** [components.html](../internal/web/view/templates/components.html)
holds one `{{define}}` per partial (page head, postmark, box and its head and
foot, flash, health card, DNS record, facts, side menu, timeline, log pane,
@@ -982,8 +997,8 @@ has the component skeleton of its mockup), `TestRedesignedPagesLoadOnlyTheKit`,
ratchet list `internal/web/view/legacy_pages.txt` is empty and can only shrink;
the CI step
[design-first](../.github/scripts/design-first.sh) rejects a commit that
changes a guard together with the code it judges, and a template that is a
byte-for-byte return of one from history. The design contract and the process
changes a guard together with the code it judges, and a template that is
the same as one from history, whitespace aside. The design contract and the process
are in [plans/panel-redesign.md](plans/panel-redesign.md) § Enforcement.
### Security headers, CSP and scripts
@@ -1018,7 +1033,8 @@ A validation refusal is shown as the text the validator wrote (on the form,
next to the field, or in a notice above the first box). Anything else — a
database error, `saslpasswd2`, a Postfix reload — is logged
and the page gets a fixed sentence saying so; the error text never reaches the
browser. A template error renders a bare `500` rather than a half-written page
browser - save on the global-only Backup page, where an import's refusal
names what is wrong with the file (security.md § Output and process). A template error renders a bare `500` rather than a half-written page
(`Engine.Render` buffers).
### Sessions
@@ -1151,8 +1167,9 @@ single-connection trade-off that follows from it.
| `postfix/dmarc_recipients` | Allow-listed DMARC aggregate report addresses |
| `postfix/dmarc_transport` | Pipe transport for report ingest |
| `postfix/dmarc_relay_domains` | Domains accepted for DMARC report delivery |
| `postfix/queue/` | Postfix transit mail (deferred/active); survives container recreate. Handed to `postfix` / `postdrop` by name at every start, before `postfix check` (`own_queue` in `build/entrypoint.sh`): the package accounts' numbers moved in `2.8.0` (postfix 101 → 100, postdrop 103 → 102) |
| `postfix/queue/` | Postfix transit mail (deferred/active); survives container recreate; excluded from backups. Handed to `postfix` / `postdrop` by name at every start, before `postfix check` (`own_queue` in `build/entrypoint.sh`): the package accounts' numbers moved in `2.8.0` (postfix 101 → 100, postdrop 103 → 102) |
| `log/mail.log` | Postfix delivery log + rotated copies (excluded from backups) |
| `antispam/` | The filter lists (six `.map` files) and the filter's key `filter.key`, which the spam filter's container mounts (`ANTISPAM_DIR`) |
| `manifest.json` | Backup version stamp; removed by the first start after a restore once its Resync is done |
| `contents.json` | A backup's last entry: the size and SHA-256 of each file under `data/`, compared by the first start after a restore before it opens the database (renamed `contents.checked.json` once compared, removed with the manifest) |
@@ -1221,8 +1238,9 @@ the stored log lines follow;
`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`,
`data/` (SQLite snapshot + tree minus `log/`, the Postfix queue
`postfix/queue`, the copy `sasl/sasldb2.new` a password change is made on, 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
@@ -1299,11 +1317,15 @@ unsupported rather than as a missing doc:
`POSTFIX_DMARC_RECIPIENTS` (`/data/postfix/dmarc_recipients`),
`POSTFIX_DMARC_TRANSPORT` (`/data/postfix/dmarc_transport`),
`POSTFIX_DMARC_RELAY_DOMAINS` (`/data/postfix/dmarc_relay_domains`),
`POSTFIX_QUEUE_DIR` (`/data/postfix/queue` — set in `build/postfix-config.sh`),
`POSTFIX_QUEUE_DIR` (`/data/postfix/queue` — read in `build/postfix-config.sh`
for `queue_directory`; `build/entrypoint.sh` hands `/data/postfix/queue` to
Postfix by that path, so overriding it desyncs the queue's ownership),
`SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` — operator project directory for
full backups; mount `.:/selfpost-deploy:ro` in compose), `ANTISPAM_DIR`
(`/data/antispam` — the filter lists and the filter's key, which the spam
filter's container mounts).
filter's container mounts). `SELFPOST_BACKUP_PASSWORD` is read by the
`selfpost-backup` CLI, not the panel: the encryption password, the other
way being `-password-file` (guide § Encrypting a backup).
- **Milter and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`),
`MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`),
`MILTER_WAIT_TIMEOUT` (`30` seconds).
@@ -80,12 +80,18 @@
<div class="sp-box-body">
<form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="admin" autocomplete="username" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div>
<div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
<p class="help">Your own address. A domain's report address can be filled with it from Domain settings.</p></div>
<div class="field"><label class="label" for="email">E-mail <span class="tag is-warning is-light">not confirmed</span></label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
<p class="help">Your own address. Saving a new one sends it a link to confirm it; once confirmed it is where a link to reset your password can go. A domain's report address can be filled with it from Domain settings.</p></div>
<div class="field"><label class="label" for="pcur">Current password</label><div class="control"><input class="input" id="pcur" name="current_password" type="password" autocomplete="current-password"></div>
<p class="help">Needed only to change the e-mail.</p></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="notify" checked> Send me notifications</label>
<p class="help">To this address, when something needs you: a DNS record of a domain stops matching, a rate limit refuses mail, the TLS certificate is about to expire, a message has waited in the queue for three days. <a href="help.html#notifications">What is sent and when</a></p></div>
<button type="submit" class="button is-primary">Save profile</button>
</form>
<form class="sp-form mt-4" method="post" action="#">
<p class="help mb-3">A link was sent to <span class="sp-mono">mix@example.org</span> at 14:02; it works once, for 24 hours.</p>
<button type="submit" class="button">Send the link again</button>
</form>
</div>
</div>
</div>
@@ -108,7 +114,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
+1 -1
View File
@@ -111,7 +111,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
+1 -1
View File
@@ -52,7 +52,7 @@ HEAD = """<!DOCTYPE html>
</head>
"""
FOOT = ('<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a>'
FOOT = ('<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a>'
' · <a href="#">Source</a> · No warranty</footer>')
@@ -292,7 +292,7 @@
<div class="sp-box-foot">Bulma's <code>is-small</code> and <code>is-size-7</code> are not used: a control is never small, and <code>sp-small</code> replaces the helper. The postmark's fine print is an imprint, not text.</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Confirm e-mail · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<div class="columns is-gapless sp-signin">
<div class="column is-half sp-signin-brand">
<div>
<img src="../../selfpost-stamp.svg" alt="SelfPost">
<p>YOUR SMTP. YOUR RULES.</p>
</div>
</div>
<div class="column is-half sp-signin-form">
<div class="sp-form">
<p class="sp-kicker">mail.example.org</p>
<h1 class="title is-3">Confirm e-mail</h1>
<div class="notification is-danger is-light"><i class="ti ti-alert-circle"></i>Sign in to the account in this browser, then open the link again.</div>
<p><a href="login.html">Sign in</a></p>
</div>
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
</body>
</html>
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Confirm e-mail · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<div class="columns is-gapless sp-signin">
<div class="column is-half sp-signin-brand">
<div>
<img src="../../selfpost-stamp.svg" alt="SelfPost">
<p>YOUR SMTP. YOUR RULES.</p>
</div>
</div>
<div class="column is-half sp-signin-form">
<form class="sp-form" method="post" action="account.html">
<p class="sp-kicker">mail.example.org · admin</p>
<h1 class="title is-3">Confirm e-mail</h1>
<p class="sp-muted mb-4"><span class="sp-mono">mix@example.org</span> becomes the confirmed address of this account: where a link to reset its password can be sent.</p>
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Confirm</button></div>
</form>
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
</body>
</html>
@@ -103,7 +103,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -68,7 +68,7 @@
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker"><a href="dmarc.html">DMARC reports</a> / <a href="dmarc-domain.html">shop.example.org</a> /</p>
<h1 class="title is-4">Outlook.com · 20 Sep 2026</h1>
<h1 class="title is-4">Outlook.com · 20 Sep</h1>
<p class="sp-route"><span>shop.example.org</span><span class="tag is-success is-light">1 903 pass</span><span class="tag is-warning is-light">7 fail</span></p>
</div>
</div>
@@ -81,7 +81,7 @@
<dl class="sp-facts">
<div><dt>Reporter</dt><dd>Outlook.com</dd></div>
<div><dt>Report id</dt><dd class="sp-mono sp-small">5f1c9a0e7b2d4e61a3</dd></div>
<div><dt>Window</dt><dd>20 Sep 00:00 – 21 Sep 00:00</dd></div>
<div><dt>Window</dt><dd>20 Sep 03:00 – 21 Sep 03:00</dd><dd class="sp-muted">The reporter's day, 20 Sep: reports count their days in UTC, and the title and the lists name that day. The times are this panel's zone.</dd></div>
<div><dt>Received</dt><dd>2026-09-21 04:40</dd></div>
</dl>
</div>
@@ -112,7 +112,7 @@
</table></div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
+1 -1
View File
@@ -98,7 +98,7 @@
</table></div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
+4 -2
View File
@@ -69,7 +69,7 @@
<div class="sp-grow">
<p class="sp-kicker">Server</p>
<h1 class="title is-3">Health</h1>
<p class="sp-lead">Everything behind the six cards on Overview. Refreshes every 10 s.</p>
<p class="sp-lead">Everything behind the six cards on Overview. Refreshes automatically.</p>
</div>
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form>
</div>
@@ -134,6 +134,7 @@
<tr><td>dkim-signer <span class="sp-muted">· notifications</span><br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/sign-local.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
<tr><td>journal-milter<br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/journal.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
<tr><td>sender-check<br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/sender.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
<tr><td>inbound-journal-milter <span class="sp-muted">· Inbound log</span><br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/inbound-journal.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
<tr><td>rspamd <span class="sp-muted">· inbound spam filter</span><br><span class="sp-muted sp-mono sp-small">inet:antispam:11332</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
</tbody>
</table></div>
@@ -157,6 +158,7 @@
<tbody>
<tr><td>DKIM<br><span class="sp-muted sp-mono sp-small">sp2026._domainkey.mail.example.org</span></td><td><span class="tag is-success is-light">published</span></td></tr>
<tr><td>SPF<br><span class="sp-muted sp-mono sp-small">mail.example.org</span></td><td><span class="tag is-warning is-light">missing</span></td></tr>
<tr><td>DMARC<br><span class="sp-muted sp-mono sp-small">_dmarc.mail.example.org</span></td><td><span class="tag is-warning is-light">missing</span></td></tr>
</tbody>
</table></div>
<div class="sp-box-body">
@@ -189,7 +191,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
+10 -10
View File
@@ -126,17 +126,17 @@
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Outbound</h2></div>
<div class="sp-help-topic" id="dns"><h3>DNS records</h3><p>DKIM proves the message was signed by this relay, SPF lists the addresses allowed to send for the domain, DMARC tells receivers what to do when both fail and where to report. Publish all three exactly as shown; merge SPF into an existing record rather than adding a second one. A domain's status is the worst of its three records, and results are cached for a few minutes, so use Re-check DNS after publishing. A record DNS already holds as shown is folded to its name and status; Show values opens it to copy them again. The SPF check is shallow: it looks for this server's address in the record itself and does not follow <code>include:</code> or <code>redirect=</code>, so a record that relies on them is reported as a warning, not a failure. <code>p=none</code> does not affect delivery; tighten to <code>quarantine</code>, then <code>reject</code>, once the reports look clean. A domain's report address is set in its Domain settings.</p></div>
<div class="sp-help-topic" id="apps"><h3>Applications</h3><p>An application is one SASL login, unique across all domains. It may send as any address of its domain or only as listed addresses, and, with a list of client IPs set, only from those addresses. The password is shown once at creation or regeneration and is not stored in readable form.</p></div>
<div class="sp-help-topic" id="apps"><h3>Applications</h3><p>An application is one SASL login, unique across all domains. It may send as any address of its domain or only as listed addresses, and, with a list of client IPs set, only from those addresses. The password is shown once, at creation or regeneration, and the panel does not show it again; the server keeps it, so a domain export can carry it.</p></div>
<div class="sp-help-topic" id="sender-check"><h3>Sender check</h3><p>An application sends only as itself, and that holds for every sender a message names. The envelope sender is held against the login by Postfix. The <code>From:</code> line, the one a mail program shows and the one DMARC holds the DKIM signature to, is held against it by the panel, under the same rule: an application set to any address of its domain may write any address of that domain, and one with a list of addresses only those. A message with any other <code>From:</code> - another of your domains, the server's own name, an address somewhere else, or none at all - is refused for good with <code>550 5.7.1</code>. The line must hold one address, read as a mail program reads it: what a display name around it says is not judged, and a line holding more than one address is refused. A <code>Sender:</code> line, which a mail program shows beside <code>From:</code> as "on behalf of", is held to the same rule when the message has one. A system that writes for people outside your domains writes its own address in <code>From:</code> with the person's name, and the person's address in <code>Reply-To:</code>: their domain in <code>From:</code> would fail DMARC at the receiver anyway. It is a rejected row of the Outbound log, and its page says which address was refused. This is what keeps one application from sending, under this server's signature, as another domain or another application.<br><br>The check is strict. While the panel does not answer, Postfix does not accept mail unchecked: it replies with a temporary error and the application sends again later. Server › Health counts what that costs: for how long the panel did not answer while Postfix itself was running; how many connections Postfix turned away because of it - it refuses from the connection on, before a message is offered, so a client that tries three times is three and a monitor that only connects is one; and how many messages were refused with a temporary error in the middle, when the panel stopped answering after a message had begun. A time marked ≈ includes a start at which supervisord could not say since when Postfix was running: the time since the panel's last sign of life is counted, which may be more than Postfix was up. The card warns when a connection was turned away or a message refused in the last 24 hours. If the numbers stay at nothing, the check is where it should be; if they do not, it belongs in a process of its own.</p></div>
<div class="sp-help-topic" id="limits"><h3>Rate limits</h3><p>Level 1 is a per-IP ceiling set in <code>.env</code>. Level 2 is optional, per domain and per application, entered by hand or computed from the last 30 days of sending, and can never exceed level 1. An application's own limit replaces its domain's.</p></div>
<div class="sp-help-topic" id="log"><h3>Outbound log</h3><p>One row per message and recipient, with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out; or closed by the panel when Postfix no longer held the message and <code>mail.log</code> never said what became of it) and rejected (refused before queueing, so it never reached the queue: under a level-2 rate limit, from a client address the application does not allow, or for a <code>From:</code> or <code>Sender:</code> line that is not the application's - the message's page says which). A message's page lists these steps and the lines Postfix wrote about it in <code>mail.log</code>, found by queue id - and for a message refused before it had one, Postfix's one line about the refusal. The lines are kept with the row: they stay on the page after that file has rotated, for as long as the row does. A message accepted before version 2.2.0 shows them only while they are still in the current file. Rows are deleted after the retention set in Server › Settings, 90 days by default, and their lines with them. A domain administrator sees only the rows of their own domains.</p></div>
<div class="sp-help-topic" id="log"><h3>Outbound log</h3><p>One row per message and recipient (a message refused for its From: or Sender: line is one row, however many recipients it had), with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out; or closed by the panel when Postfix no longer held the message and <code>mail.log</code> never said what became of it) and rejected (refused before queueing, so it never reached the queue: under a level-2 rate limit, from a client address the application does not allow, or for a <code>From:</code> or <code>Sender:</code> line that is not the application's - the message's page says which). A message's page lists these steps and the lines Postfix wrote about it in <code>mail.log</code>, found by queue id - and for a message refused before it had one, Postfix's one line about the refusal. The lines are kept with the row: they stay on the page after that file has rotated, for as long as the row does. A message accepted before version 2.2.0 shows them only while they are still in the current file. Rows are deleted after the retention set in Server › Settings, 90 days by default, and their lines with them. A domain administrator sees only the rows of their own domains.</p></div>
<div class="sp-help-topic" id="queue"><h3>Queue and retries</h3><p>The page lists, as <code>postqueue -p</code> prints it, the messages Postfix has accepted and not yet delivered: mail being sent right now and mail deferred for a later attempt. Only the global role can open it, and it only shows; nothing here deletes or forces a message. A deferred message is tried again after the first retry delay, then at gaps that double up to a cap, until the receiving server takes it or the queue lifetime runs out and it bounces; there is no attempt counter, and the box on the page shows this server's values. A queue that keeps growing means a receiver is refusing or not answering: open the message from the Outbound log to read the reason Postfix logged.</p></div>
<div class="sp-help-topic" id="dmarc"><h3>DMARC reports</h3><p>An aggregate report is the summary a receiving provider sends, usually daily, to the <code>rua=</code> address of your DMARC record: which servers sent mail as your domain and whether SPF or DKIM passed for it. A domain's report address is one of three choices, set in its Domain settings: no reports (the default for a new domain), the SelfPost hosted address (an address on this server whose reports are parsed and shown under DMARC reports; offered only while hosted reports are enabled on the server), or an address typed for the domain (an ordinary mailbox that receives the raw XML; a button fills in your own profile e-mail). When the typed address is on another domain than the one sending, that domain must publish a report-authorization record, or providers will not send; the domain's page shows it with the other DNS records and checks it. In the tables pass means SPF or DKIM passed and aligned, fail means neither did, and your own server shows as <em>this relay</em>. A fail from any other source is someone else sending as your domain — a forwarder, an application you forgot, or abuse — so do not tighten <code>p=</code> until that sender is fixed or removed. A domain administrator reads the reports of their own domains.</p></div>
<div class="sp-help-topic" id="dmarc"><h3>DMARC reports</h3><p>An aggregate report is the summary a receiving provider sends, usually daily, to the <code>rua=</code> address of your DMARC record - its day is the reporter's, counted in UTC, and that is the day the lists and a report's title name, while the report's page gives its window in this panel's zone: which servers sent mail as your domain and whether SPF or DKIM passed for it. A domain's report address is one of three choices, set in its Domain settings: no reports (the default for a new domain), the SelfPost hosted address (an address on this server whose reports are parsed and shown under DMARC reports; offered only while hosted reports are enabled on the server), or an address typed for the domain (an ordinary mailbox that receives the raw XML; a button fills in your own profile e-mail). When the typed address is on another domain than the one sending, that domain must publish a report-authorization record, or providers will not send; the domain's page shows it with the other DNS records and checks it. In the tables pass means SPF or DKIM passed and aligned, fail means neither did, and your own server shows as <em>this relay</em>. A fail from any other source is someone else sending as your domain — a forwarder, an application you forgot, or abuse — so do not tighten <code>p=</code> until that sender is fixed or removed. A domain administrator reads the reports of their own domains.</p></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Inbound</h2></div>
<div class="sp-help-topic" id="inbound"><h3>Backup-MX and forwarding</h3><p>An optional backup-MX or forwarder, switched on in the server's <code>.env</code>; the Inbound menu exists only then. Postfix accepts mail on port 25 for the listed domains only and hands each message on to that domain's upstream; there are no mailboxes. For each domain publish the MX record its page shows, and keep a primary MX the domain already has if this is a backup. The MX tag is green when at least one MX of the domain points at this server, and a domain accepts no mail until an upstream is saved on its page. Adding and deleting domains is for the global role.</p></div>
<div class="sp-help-topic" id="upstream"><h3>Upstream</h3><p>The upstream is the server that actually holds the mailboxes. Give its host and port — used exactly as written, with no MX lookup — and choose the TLS for the hop to it: Opportunistic uses TLS when the upstream offers it and otherwise sends in the clear; Required sends only over TLS, and the mail waits in the queue while the upstream does not offer it or the TLS handshake fails - an upstream that answers STARTTLS with a permanent error bounces it; Off never uses TLS. While the upstream does not answer, Postfix keeps the mail and retries as for any other mail.</p></div>
<div class="sp-help-topic" id="upstream"><h3>Upstream</h3><p>The upstream is the server that actually holds the mailboxes. Give its host and port — used exactly as written, with no MX lookup — and choose the TLS for the hop to it: Opportunistic uses TLS when the upstream offers it and otherwise sends in the clear; Required sends only over TLS, and the mail waits in the queue while the upstream does not offer it or the TLS handshake fails - an upstream that answers STARTTLS with a permanent error bounces it. Neither checks the upstream's certificate: TLS here keeps the mail from whoever only listens on the way, not from one who answers in the upstream's place. Verified is Required with that check too: the upstream's certificate has to be issued by a trusted authority for the host given here, so the host is given by name, not by address, and the mail waits in the queue while the check fails - a certificate for another name, or one the upstream signed itself, included. Off never uses TLS. While the upstream does not answer, Postfix keeps the mail and retries as for any other mail.</p></div>
<div class="sp-help-topic" id="recipients"><h3>Valid recipients</h3><p>Choose whom this domain accepts. Listed addresses only: the relay accepts exactly the addresses you list, one per line or comma-separated, and refuses any other while the sender is still connected, so it never has to send a bounce for them. Any recipient: everything at the domain is accepted and passed on, and an address the upstream then rejects comes back as a bounce from this server to a sender who is often forged — backscatter. Prefer a list, and keep it in step with the mailboxes upstream.</p></div>
<div class="sp-help-topic" id="filter"><h3>Spam filter</h3><p>The spam filter belongs to the server, not to a domain. The server's administrator attaches it in the server's <code>.env</code>, and from then on every inbound domain is screened by it before mail is handed on; the box shows on or off and has nothing to switch. Off means mail goes to the upstream unfiltered. While the filter is down, mail is either accepted unfiltered (the default) or refused with a temporary error, so the sending server tries again later, as the server's setting says; the box states which.</p></div>
<div class="sp-help-topic" id="inbound-log"><h3>Inbound log</h3><p>One row per recipient of a message offered on port 25 for an inbound domain, with what was decided: <span class="tag is-success is-light">accept</span> — taken for the upstream; <span class="tag is-danger is-light">reject</span> — refused for good; <span class="tag is-warning is-light">tempfail</span> — the sender was asked to come back later; <span class="tag is-primary is-light">quarantine</span> — held by the filter. <em>Why</em> names who decided. <code>rspamd</code> is the spam filter, with its action, the score against the reject threshold and the heaviest symbols; <code>postfix</code> is the relay's own rule — an unknown recipient, a sender domain that does not exist — with its reply; <code>selfpost</code> means nobody judged the message: it was relayed with no filter attached. <em>Upstream</em> says what became of an accepted message: <span class="tag is-light">queued</span> — not tried yet; <span class="tag is-primary is-light">held</span> — held in the queue by the filter, not tried until it is released; <span class="tag is-success is-light">sent</span> — the upstream took it; <span class="tag is-warning is-light">deferred</span> — the upstream put it off or could not be reached, and Postfix tries again; <span class="tag is-danger is-light">bounced</span> — the upstream refused it, or it waited in the queue too long, and the sender was sent a bounce, if it had a return address. It opens the last reply and the host that gave it. A refused message has no upstream result, and neither has a row logged before 2.10.0.<br><br>Accepted mail is recorded as it passes; refused mail is read from <code>mail.log</code>, so its row has no subject, and a message refused after its text was sent is one row however many recipients it had. A refusal to relay for a domain that is not an inbound one is every scanner on the internet and is not recorded. Rows are kept for the number of days set in Settings, and never more than 10 000.</p></div>
@@ -144,19 +144,19 @@
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div>
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys and application credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. Both it and a domain's export ask for your account password - the one you sign in with - at every download, so a session left open is not enough to take them; wrong guesses count against the same limit as a password change. The Postfix queue is not in it. The backup is taken while the server is running, and Postfix keeps moving messages through the queue, so a copy could hold a message twice or miss it; mail still waiting for delivery is therefore not carried over, and a restored server starts with an empty queue. To keep the queue, stop the container and archive the project directory instead. The private key in <code>certs/</code> has to be readable by the panel: give it to group 10998 on the host (<code>chgrp 10998</code>, <code>chmod 640</code>). Until then the panel refuses to make a backup and names the file, rather than hand over an archive without the key. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
<div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out of their other sessions.</p></div>
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. The time zone is the container's, <code>TZ</code> in <code>.env</code> (UTC when it is not set): every time the panel shows and every day it counts is that zone's, as are the stamps Postfix writes in <code>mail.log</code>, so the two always agree; changing it is a restart of the container. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. With the inbound relay on there is a second number, for the Inbound log, with the same range. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it. Notifications are turned on or off here for everyone.</p></div>
<div class="sp-help-topic" id="notifications"><h3>Notifications</h3><p>The panel writes to its users when something needs them, from <code>postmaster@</code> the server's hostname. Four things are reported. A sending domain's SPF, DKIM or DMARC record stops matching: sent once two hourly checks in a row have found it so, and once more when the record matches again. A level-2 rate limit refuses mail: sent at the first refusal, then at most once a day for the same limit. The TLS certificate is 14 days from expiry, and again at 3 days if it is still the same certificate. A message has waited in the queue for more than three days, with one more message when none is left. A global user is told all of it; a domain administrator is told about the DNS records and limits of their own domains.<br><br>Each user turns notifications on or off under Account and needs an e-mail there; Server › Settings turns them off for everyone. They are the panel's own mail: not rows of the Outbound log, and not counted against any limit.<br><br>For that mail to arrive, the server's own name needs two DNS records of its own, as each of your domains does: a DKIM key, which the panel generates and signs notifications with, and an SPF record, <code>v=spf1 a -all</code>. Server › Settings gives both with the values to publish and whether they are in place, and its Re-check DNS checks them again once they are published; Server › Health shows them too. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains does not depend on either.</p></div>
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys and application credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. Both it and a domain's export ask for your account password - the one you sign in with - at every download, so a session left open is not enough to take them; a wrong one counts against the limit of a password change, ten in 15 minutes. The Postfix queue is not in it. The backup is taken while the server is running, and Postfix keeps moving messages through the queue, so a copy could hold a message twice or miss it; mail still waiting for delivery is therefore not carried over, and a restored server starts with an empty queue. To keep the queue, stop the container and archive the project directory instead. The private key in <code>certs/</code> has to be readable by the panel: give it to group 10997 on the host (<code>chgrp 10997</code>, <code>chmod 640</code>), a group only the panel is in, so Postfix's daemons cannot read it. Until then the panel refuses to make a backup and names the file, rather than hand over an archive without the key. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
<div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out everywhere; set for yourself, it keeps the session you set it from. Adding a user, changing a user's password, e-mail or role, and deleting a user ask for your own account password, as a backup does, with the same limit on wrong guesses.</p></div>
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. The time zone is the container's, <code>TZ</code> in <code>.env</code> (UTC when it is not set): every time the panel shows and every day it counts is that zone's, as are the stamps Postfix writes in <code>mail.log</code>, so the two always agree; changing it is a restart of the container. The one exception is the day a DMARC report covers, which is the reporter's, counted in UTC. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. With the inbound relay on there is a second number, for the Inbound log, with the same range. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it. Notifications are turned on or off here for everyone.</p></div>
<div class="sp-help-topic" id="notifications"><h3>Notifications</h3><p>The panel writes to its users when something needs them, from <code>postmaster@</code> the server's hostname. Four things are reported. A sending domain's SPF, DKIM or DMARC record stops matching: sent once two hourly checks in a row have found it so, and once more when the record matches again. A level-2 rate limit refuses mail: sent at the first refusal, then at most once a day for the same sending domain. The TLS certificate is 14 days from expiry, and again at 3 days if it is still the same certificate. A message has waited in the queue for more than three days, with one more message when none is left. A global user is told all of it; a domain administrator is told about the DNS records and limits of their own domains.<br><br>Each user turns notifications on or off under Account and needs an e-mail there; Server › Settings turns them off for everyone. They are the panel's own mail: not rows of the Outbound log, and not counted against any limit.<br><br>For that mail to arrive, the server's own name needs DNS records of its own, as each of your domains does: a DKIM key, which the panel generates and signs notifications with, an SPF record, <code>v=spf1 a -all</code>, and a DMARC record, <code>v=DMARC1; p=reject</code>, which has receivers refuse mail forged in the server's name - a false password-reset message among it; only the panel sends as this name, signed, so reject is safe. Server › Settings gives all three with the values to publish and whether they are in place, and its Re-check DNS checks them again once they are published; Server › Health shows them too. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains depends on none of them.</p></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">05</span><h2>Account</h2></div>
<div class="sp-help-topic" id="account"><h3>Your account</h3><p>Account is your own, whatever your role: your username and e-mail, whether the panel sends you notifications, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected.</p></div>
<div class="sp-help-topic" id="account"><h3>Your account</h3><p>Account is your own, whatever your role: your username and e-mail, whether the panel sends you notifications, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected. A new e-mail is sent a link that confirms it, which works once, for 24 hours; changing the e-mail asks for your password. Notifications go to the address whether it is confirmed or not, but only a confirmed one can get a link to reset a forgotten password: <em>Forgot your password?</em> on the sign-in page sends it, for 30 minutes and once, and setting the new password signs every session of the account out. Without a confirmed e-mail the global administrator sets a new password under Users, and the only global administrator recovers from the container.</p></div>
</div>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -94,7 +94,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -70,7 +70,7 @@
<div class="sp-grow">
<p class="sp-kicker"><a href="in-domains.html">Inbound domains</a> /</p>
<h1 class="title is-3">lists.example.org</h1>
<p class="sp-lead">Accepted on port 25, handed to <span class="sp-mono">mx.internal:25</span> over required TLS · 4 recipients</p>
<p class="sp-lead">Accepted on port 25, handed to <span class="sp-mono">mx.internal:25</span> over verified TLS · 4 recipients</p>
</div>
<div class="buttons">
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form>
@@ -124,7 +124,8 @@
<div class="field"><label class="label" for="port">Port</label><div class="control"><input class="input sp-mono" id="port" name="port" inputmode="numeric" value="25" required></div></div>
</div>
<div class="field"><label class="label" for="tls">TLS to upstream</label>
<div class="control"><div class="select is-fullwidth"><select id="tls" name="tls_mode"><option>Opportunistic</option><option selected>Required</option><option>Off</option></select></div></div></div>
<div class="control"><div class="select is-fullwidth"><select id="tls" name="tls_mode"><option>Opportunistic</option><option>Required</option><option selected>Verified</option><option>Off</option></select></div></div>
<p class="help">Required encrypts the hop without checking who answers. Verified also checks the upstream's certificate: issued by a trusted authority for the host above, which is then given by name.</p></div>
<button type="submit" class="button is-primary">Save upstream</button>
</form>
</div>
@@ -142,7 +143,7 @@
dev@lists.example.org
owner@lists.example.org
security@lists.example.org</textarea></div>
<p class="help">Unknown recipients are rejected at RCPT, so this relay generates no backscatter. Prefer a list unless the upstream itself rejects unknowns.</p></div>
<p class="help">One per line or comma-separated. Unknown recipients are rejected at RCPT, so this relay generates no backscatter. Prefer a list unless the upstream itself rejects unknowns.</p></div>
<button type="submit" class="button is-primary">Save recipients</button>
</form>
</div>
@@ -154,17 +155,16 @@ security@lists.example.org</textarea></div>
<div class="sp-box-head"><span class="sp-no">04</span><h2>Spam filter</h2><div class="sp-end"><span class="tag is-success is-light">on</span></div><a class="sp-help" href="help.html#filter" title="A server setting"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Engine</dt><dd>rspamd, for every inbound domain of this server</dd></div>
<div><dt>Suspicious mail</dt><dd>Held — <a href="in-quarantine.html">1 message in quarantine</a> for this domain</dd></div>
<div><dt>Spam</dt><dd>Refused at the door; see <em>reject</em> in the <a href="in-log.html">log</a></dd></div>
<div><dt>Allow and deny lists</dt><dd class="sp-muted">Kept by the global administrator, one pair for the whole server</dd></div>
<div><dt>Applies to</dt><dd>Every inbound domain of this server</dd></div>
<div><dt>Filter</dt><dd class="sp-mono">inet:antispam:11332</dd></div>
<div><dt>If the filter is down</dt><dd>Mail is accepted unfiltered</dd></div>
</dl>
</div>
</div>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -91,14 +91,14 @@
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Domain</th><th>DNS</th><th>Upstream</th><th>Recipients</th><th>TLS to upstream</th><th></th></tr></thead>
<tbody>
<tr><td><a href="in-domain.html"><strong>lists.example.org</strong></a></td><td><span class="tag is-success is-light">MX</span></td><td class="sp-mono sp-muted">mx.internal:25</td><td>4 addresses</td><td><span class="tag is-success is-light">required</span></td><td class="sp-actions"><a href="in-domain.html">Open</a><a class="has-text-danger" href="in-domain-delete.html">Delete</a></td></tr>
<tr><td><a href="in-domain.html"><strong>lists.example.org</strong></a></td><td><span class="tag is-success is-light">MX</span></td><td class="sp-mono sp-muted">mx.internal:25</td><td>4 addresses</td><td><span class="tag is-success is-light">verified</span></td><td class="sp-actions"><a href="in-domain.html">Open</a><a class="has-text-danger" href="in-domain-delete.html">Delete</a></td></tr>
<tr><td><a href="in-domain.html"><strong>acme.io</strong></a></td><td><span class="tag is-success is-light">MX</span></td><td class="sp-mono sp-muted">10.0.4.12:2525</td><td>any address</td><td><span class="tag is-light">opportunistic</span></td><td class="sp-actions"><a href="in-domain.html">Open</a><a class="has-text-danger" href="in-domain-delete.html">Delete</a></td></tr>
</tbody>
</table></div>
<div class="sp-box-foot">The MX tag is green when at least one MX of the domain points at this server.</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -138,7 +138,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
+1 -1
View File
@@ -100,7 +100,7 @@
<div class="sp-box-foot"><span>Page 1 of 38</span><a href="#">Older →</a><span class="sp-end">A decision opens its reason, an upstream result its last reply · Allow / Deny sender opens <a href="in-filter-lists.html">Filter lists</a> with the entry filled in</span></div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -143,7 +143,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -92,7 +92,7 @@
<div class="sp-box-foot"><span>3 messages · 3.0 MB of 500 MB</span><span class="sp-end">A domain administrator sees only their assigned inbound domains</span></div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -74,7 +74,7 @@
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Waiting</h2><div class="sp-end">Refreshes every 10 s</div></div>
<div class="sp-box-head"><span class="sp-no">01</span><h2>Waiting</h2><div class="sp-end">Refreshes on its own</div></div>
<div class="sp-empty"><i class="ti ti-stack-2"></i>Nothing is waiting. Every accepted message has reached its upstream.</div>
</div>
@@ -90,7 +90,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -23,7 +23,7 @@
<h1 class="title is-3">No administrator yet</h1>
<p class="sp-muted">Open the one-time setup link printed in the container log to create one.</p>
</div>
<p class="sp-foot">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<div class="columns is-gapless sp-signin">
<div class="column is-half sp-signin-brand">
<div>
<img src="../../selfpost-stamp.svg" alt="SelfPost">
<p>YOUR SMTP. YOUR RULES.</p>
</div>
</div>
<div class="column is-half sp-signin-form">
<form class="sp-form" method="post" action="overview.html">
<p class="sp-kicker">mail.example.org</p>
<h1 class="title is-3">Sign in</h1>
<div class="notification is-success is-light"><i class="ti ti-circle-check"></i>If that account has a confirmed e-mail, the link is on its way. It works once, for 30 minutes.</div>
<div class="field">
<label class="label" for="username">Username</label>
<div class="control has-icons-left"><input class="input" id="username" name="username" autocomplete="username" required><span class="icon is-left"><i class="ti ti-user"></i></span></div>
</div>
<div class="field">
<label class="label" for="password">Password</label>
<div class="control has-icons-left"><input class="input" id="password" name="password" type="password" autocomplete="current-password" required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
</div>
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Sign in</button></div>
<p class="mt-4"><a href="login-reset.html">Forgot your password?</a></p>
</form>
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
</body>
</html>
@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>New password · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<div class="columns is-gapless sp-signin">
<div class="column is-half sp-signin-brand">
<div>
<img src="../../selfpost-stamp.svg" alt="SelfPost">
<p>YOUR SMTP. YOUR RULES.</p>
</div>
</div>
<div class="column is-half sp-signin-form">
<form class="sp-form" method="post" action="login.html">
<p class="sp-kicker">mail.example.org · admin</p>
<h1 class="title is-3">New password</h1>
<p class="sp-muted mb-4">Setting it signs every session of this account out; sign in with it afterwards.</p>
<div class="field">
<label class="label" for="new_password">New password</label>
<div class="control has-icons-left"><input class="input" id="new_password" name="new_password" type="password" autocomplete="new-password" required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
</div>
<div class="field">
<label class="label" for="new_password_confirm">Repeat</label>
<div class="control has-icons-left"><input class="input" id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password" required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
</div>
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Set password</button></div>
</form>
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
</body>
</html>
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Reset password · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<div class="columns is-gapless sp-signin">
<div class="column is-half sp-signin-brand">
<div>
<img src="../../selfpost-stamp.svg" alt="SelfPost">
<p>YOUR SMTP. YOUR RULES.</p>
</div>
</div>
<div class="column is-half sp-signin-form">
<form class="sp-form" method="post" action="login-reset-sent.html">
<p class="sp-kicker">mail.example.org</p>
<h1 class="title is-3">Reset password</h1>
<p class="sp-muted mb-4">A link to set a new password goes to the e-mail of the account, once that address is confirmed under Account. It works once, for 30 minutes.</p>
<div class="field">
<label class="label" for="username">Username</label>
<div class="control has-icons-left"><input class="input" id="username" name="username" autocomplete="username" required><span class="icon is-left"><i class="ti ti-user"></i></span></div>
</div>
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Send the link</button></div>
<p class="mt-4"><a href="login.html">Back to sign in</a></p>
</form>
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
</body>
</html>
+2 -1
View File
@@ -30,8 +30,9 @@
<div class="control has-icons-left"><input class="input" id="password" name="password" type="password" autocomplete="current-password" required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
</div>
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Sign in</button></div>
<p class="mt-4"><a href="login-reset.html">Forgot your password?</a></p>
</form>
<p class="sp-foot">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
@@ -100,7 +100,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -69,7 +69,7 @@
<div class="sp-grow">
<p class="sp-kicker"><a href="out-domains.html">Outbound domains</a> / <a href="out-domain.html">example.org</a> / Applications /</p>
<h1 class="title is-3 sp-mono">prod-server</h1>
<p class="sp-lead">1 102 messages in 30 days · peak 96 msg/h · average 1.5 msg/h. The same form, empty, adds a new application.</p>
<p class="sp-lead">1 102 messages in 30 days · peak 96 msg/h · average 1.5 msg/h.</p>
</div>
<div class="buttons">
<a class="button" href="out-app-created.html"><i class="ti ti-key"></i>New password</a>
@@ -85,7 +85,7 @@
<div class="sp-box-body">
<div class="field"><label class="label" for="login">Login</label>
<div class="control"><input class="input sp-mono" id="login" name="login" value="prod-server" autocomplete="off" spellcheck="false" required></div>
<p class="help">Unique across domains; letters, digits, <code>.</code> <code>-</code> <code>_</code>.</p></div>
<p class="help">Fixed: add a new application to use another login.</p></div>
<div class="field"><label class="label" for="amode">May send as</label>
<div class="control"><div class="select is-fullwidth"><select id="amode" name="mode"><option selected>Any address of the domain</option><option>Specific addresses (list)</option></select></div></div></div>
<div class="field"><label class="label" for="addresses">Addresses</label>
@@ -129,7 +129,7 @@
</div>
</form>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -81,6 +81,7 @@
<ul class="sp-list-check">
<li><i class="ti ti-x"></i>permanently delete its DKIM signing key;</li>
<li><i class="ti ti-x"></i>delete <strong>both applications</strong> (<span class="sp-mono">prod-server</span>, <span class="sp-mono">alerts</span>) with their credentials and sender bindings;</li>
<li><i class="ti ti-x"></i>delete the <strong>12 DMARC reports</strong> received for it;</li>
<li><i class="ti ti-x"></i>stop signing mail as the domain.</li>
</ul>
<p class="sp-muted mb-4">The published DKIM record becomes invalid — remove it from DNS unless you plan to re-add the domain. Inbound domains are not touched.</p>
@@ -103,7 +104,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -119,7 +119,7 @@
<form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="mode">Limit mode</label>
<div class="control"><div class="select is-fullwidth"><select id="mode" name="mode"><option>Manual</option><option selected>Auto (from statistics)</option></select></div></div></div>
<div class="field"><label class="label" for="mult">Multiplier (1.5–10, default 3)</label>
<div class="field"><label class="label" for="mult">Multiplier (1.5–5, default 2.5)</label>
<div class="control"><input class="input" id="mult" name="auto_multiplier" type="number" min="1.5" max="10" step="0.1" value="3"></div>
<p class="help">Computed limit: <strong>288</strong> messages / 3600 s. Peak was 96 msg/h. Last recalculated yesterday 03:00.</p></div>
<div class="buttons">
@@ -142,7 +142,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -172,7 +172,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -100,7 +100,7 @@
<div class="sp-box-foot">Each tag is one check. Green — published and correct; amber — published but weak; red — missing or wrong.</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -101,7 +101,7 @@
<div class="sp-box-foot"><span>Page 1 of 412</span><a href="#">Older →</a><span class="sp-end">New rows appear on their own</span></div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -94,9 +94,9 @@
<div class="sp-box-head"><span class="sp-no">02</span><h2>History</h2><a class="sp-help" href="help.html#log" title="What the statuses mean"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body">
<ol class="sp-timeline">
<li><time>13:58:07</time><strong>Accepted</strong> from <span class="sp-mono">alerts</span> at 198.51.100.7</li>
<li class="sp-warn"><time>13:58:09</time><strong>Deferred</strong> — the receiving server asked to retry later</li>
<li class="sp-pending"><time>next attempt ≈ 14:08</time>Still in the <a href="out-queue.html">queue</a>; kept for 5 days, then bounced</li>
<li><time>2026-09-21 13:58:07</time><strong>Accepted and queued</strong> — Postfix accepted the message over an authenticated submission and the journal-milter recorded it.</li>
<li class="sp-warn"><time>2026-09-21 13:58:09</time><strong>Deferred, will be retried</strong> — the receiving server could not take the message yet. Postfix retries: first after 5 minutes, then with increasing gaps up to 1 hour 7 minutes, for up to 5 days.</li>
<li class="sp-pending"><time>not yet</time>Still in the queue; the next attempt is Postfix's own, and after 5 days it is bounced.</li>
</ol>
</div>
</div>
@@ -112,7 +112,7 @@
</span></pre>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -69,7 +69,7 @@
<div class="sp-grow">
<p class="sp-kicker">Outbound</p>
<h1 class="title is-3">Outbound queue</h1>
<p class="sp-lead">Messages accepted from applications and still waiting for the receiving server. Refreshes every 10 s.</p>
<p class="sp-lead">Messages accepted from applications and still waiting for the receiving server. Refreshes on its own.</p>
</div>
</div>
@@ -95,7 +95,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -402,7 +402,7 @@
},
{
"is-6": [
"box[Notification mail]:record×2",
"box[Notification mail]:record×3",
"box[Sending rate limits]:body",
"box[Time zone]:body"
]
@@ -70,9 +70,9 @@
<div class="sp-grow">
<p class="sp-kicker">mail.example.org</p>
<h1 class="title is-3">Mail is flowing. One thing needs attention.</h1>
<p class="sp-lead">The TLS certificate expires in 12 days and has not renewed yet.</p>
<p class="sp-lead"><a href="health.html">TLS certificate</a>: expires in 12 days.</p>
</div>
<div class="sp-muted sp-small">Updated 14:02:31<br>refreshes every 10 s</div>
<div class="sp-muted sp-small">Updated 14:02:31<br>refreshes on its own</div>
</div>
<div class="box">
@@ -82,7 +82,7 @@
<a href="health.html"><span class="sp-h-icon"><i class="ti ti-server-cog"></i></span><span><span class="sp-h-name">Processes</span><span class="sp-h-value">5 of 5 running</span><span class="sp-h-sub">up 18 days</span></span></a>
<a href="health.html" class="sp-warn"><span class="sp-h-icon"><i class="ti ti-certificate"></i></span><span><span class="sp-h-name">TLS certificate</span><span class="sp-h-value">Expires in 12 days</span><span class="sp-h-sub">renewal has not run</span></span></a>
<a href="out-queue.html"><span class="sp-h-icon"><i class="ti ti-stack-2"></i></span><span><span class="sp-h-name">Queues</span><span class="sp-h-value">Both empty</span><span class="sp-h-sub">outbound and inbound</span></span></a>
<a href="health.html"><span class="sp-h-icon"><i class="ti ti-plug-connected"></i></span><span><span class="sp-h-name">Milter sockets</span><span class="sp-h-value">5 of 5 answering</span><span class="sp-h-sub">incl. the spam filter</span></span></a>
<a href="health.html"><span class="sp-h-icon"><i class="ti ti-plug-connected"></i></span><span><span class="sp-h-name">Milter sockets</span><span class="sp-h-value">6 of 6 answering</span><span class="sp-h-sub">incl. the spam filter</span></span></a>
<a href="health.html"><span class="sp-h-icon"><i class="ti ti-arrows-exchange"></i></span><span><span class="sp-h-name">Reverse DNS</span><span class="sp-h-value">Forward = reverse</span><span class="sp-h-sub">203.0.113.25</span></span></a>
</div>
</div>
@@ -115,7 +115,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -117,7 +117,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
+14 -2
View File
@@ -128,6 +128,18 @@
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=spf1 a -all"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<p class="help">Notifications are sent without it, and a receiver may file them as spam.</p>
</div>
<div class="sp-record">
<div class="sp-record-head"><h3>DMARC</h3><span class="tag is-warning is-light">missing</span><small>For the server's own name, not for your domains</small></div>
<div class="sp-row">
<div class="field"><label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="_dmarc.mail.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div>
<div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=DMARC1; p=reject"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<p class="help">Without it a receiver does not refuse mail forged in the server's name - a false password-reset message included. Only the panel sends as this name, signed, so reject is safe.</p>
</div>
</div>
<div class="box" id="rate-limits">
@@ -146,13 +158,13 @@
<dl class="sp-facts">
<div><dt>Zone</dt><dd class="sp-mono">Europe/Moscow</dd><dd class="sp-muted">UTC+03:00. Set in <code>.env</code> (<code>TZ</code>); restart to change.</dd></div>
</dl>
<p class="mt-4 sp-muted">Every time the panel shows, and every day it counts - today on Health, the days of the statistics - is this zone's, and so are the stamps of <code>mail.log</code>.</p>
<p class="mt-4 sp-muted">Every time the panel shows, and every day it counts - today on Health, the days of the statistics - is this zone's, and so are the stamps of <code>mail.log</code>. One exception: the day a DMARC report covers is the reporter's, in UTC.</p>
</div>
</div>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
+1 -1
View File
@@ -36,7 +36,7 @@
</div>
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Create administrator</button></div>
</form>
<p class="sp-foot">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
@@ -14,12 +14,18 @@
<div class="sp-box-body">
<form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="admin" autocomplete="username" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div>
<div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
<p class="help">Your own address. A domain's report address can be filled with it from Domain settings.</p></div>
<div class="field"><label class="label" for="email">E-mail <span class="tag is-warning is-light">not confirmed</span></label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
<p class="help">Your own address. Saving a new one sends it a link to confirm it; once confirmed it is where a link to reset your password can go. A domain's report address can be filled with it from Domain settings.</p></div>
<div class="field"><label class="label" for="pcur">Current password</label><div class="control"><input class="input" id="pcur" name="current_password" type="password" autocomplete="current-password"></div>
<p class="help">Needed only to change the e-mail.</p></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="notify" checked> Send me notifications</label>
<p class="help">To this address, when something needs you: a DNS record of a domain stops matching, a rate limit refuses mail, the TLS certificate is about to expire, a message has waited in the queue for three days. <a href="help.html#notifications">What is sent and when</a></p></div>
<button type="submit" class="button is-primary">Save profile</button>
</form>
<form class="sp-form mt-4" method="post" action="#">
<p class="help mb-3">A link was sent to <span class="sp-mono">mix@example.org</span> at 14:02; it works once, for 24 hours.</p>
<button type="submit" class="button">Send the link again</button>
</form>
</div>
</div>
</div>
@@ -0,0 +1,18 @@
<!-- title: Confirm e-mail | shell: bare -->
<div class="columns is-gapless sp-signin">
<div class="column is-half sp-signin-brand">
<div>
<img src="../../selfpost-stamp.svg" alt="SelfPost">
<p>YOUR SMTP. YOUR RULES.</p>
</div>
</div>
<div class="column is-half sp-signin-form">
<div class="sp-form">
<p class="sp-kicker">mail.example.org</p>
<h1 class="title is-3">Confirm e-mail</h1>
<div class="notification is-danger is-light"><i class="ti ti-alert-circle"></i>Sign in to the account in this browser, then open the link again.</div>
<p><a href="login.html">Sign in</a></p>
</div>
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
@@ -0,0 +1,18 @@
<!-- title: Confirm e-mail | shell: bare -->
<div class="columns is-gapless sp-signin">
<div class="column is-half sp-signin-brand">
<div>
<img src="../../selfpost-stamp.svg" alt="SelfPost">
<p>YOUR SMTP. YOUR RULES.</p>
</div>
</div>
<div class="column is-half sp-signin-form">
<form class="sp-form" method="post" action="account.html">
<p class="sp-kicker">mail.example.org · admin</p>
<h1 class="title is-3">Confirm e-mail</h1>
<p class="sp-muted mb-4"><span class="sp-mono">mix@example.org</span> becomes the confirmed address of this account: where a link to reset its password can be sent.</p>
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Confirm</button></div>
</form>
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
@@ -2,7 +2,7 @@
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker"><a href="dmarc.html">DMARC reports</a> / <a href="dmarc-domain.html">shop.example.org</a> /</p>
<h1 class="title is-4">Outlook.com · 20 Sep 2026</h1>
<h1 class="title is-4">Outlook.com · 20 Sep</h1>
<p class="sp-route"><span>shop.example.org</span><span class="tag is-success is-light">1 903 pass</span><span class="tag is-warning is-light">7 fail</span></p>
</div>
</div>
@@ -15,7 +15,7 @@
<dl class="sp-facts">
<div><dt>Reporter</dt><dd>Outlook.com</dd></div>
<div><dt>Report id</dt><dd class="sp-mono sp-small">5f1c9a0e7b2d4e61a3</dd></div>
<div><dt>Window</dt><dd>20 Sep 00:00 – 21 Sep 00:00</dd></div>
<div><dt>Window</dt><dd>20 Sep 03:00 – 21 Sep 03:00</dd><dd class="sp-muted">The reporter's day, 20 Sep: reports count their days in UTC, and the title and the lists name that day. The times are this panel's zone.</dd></div>
<div><dt>Received</dt><dd>2026-09-21 04:40</dd></div>
</dl>
</div>
@@ -3,7 +3,7 @@
<div class="sp-grow">
<p class="sp-kicker">Server</p>
<h1 class="title is-3">Health</h1>
<p class="sp-lead">Everything behind the six cards on Overview. Refreshes every 10 s.</p>
<p class="sp-lead">Everything behind the six cards on Overview. Refreshes automatically.</p>
</div>
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form>
</div>
@@ -68,6 +68,7 @@
<tr><td>dkim-signer <span class="sp-muted">· notifications</span><br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/sign-local.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
<tr><td>journal-milter<br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/journal.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
<tr><td>sender-check<br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/sender.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
<tr><td>inbound-journal-milter <span class="sp-muted">· Inbound log</span><br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/inbound-journal.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
<tr><td>rspamd <span class="sp-muted">· inbound spam filter</span><br><span class="sp-muted sp-mono sp-small">inet:antispam:11332</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
</tbody>
</table></div>
@@ -91,6 +92,7 @@
<tbody>
<tr><td>DKIM<br><span class="sp-muted sp-mono sp-small">sp2026._domainkey.mail.example.org</span></td><td><span class="tag is-success is-light">published</span></td></tr>
<tr><td>SPF<br><span class="sp-muted sp-mono sp-small">mail.example.org</span></td><td><span class="tag is-warning is-light">missing</span></td></tr>
<tr><td>DMARC<br><span class="sp-muted sp-mono sp-small">_dmarc.mail.example.org</span></td><td><span class="tag is-warning is-light">missing</span></td></tr>
</tbody>
</table></div>
<div class="sp-box-body">
@@ -60,17 +60,17 @@
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Outbound</h2></div>
<div class="sp-help-topic" id="dns"><h3>DNS records</h3><p>DKIM proves the message was signed by this relay, SPF lists the addresses allowed to send for the domain, DMARC tells receivers what to do when both fail and where to report. Publish all three exactly as shown; merge SPF into an existing record rather than adding a second one. A domain's status is the worst of its three records, and results are cached for a few minutes, so use Re-check DNS after publishing. A record DNS already holds as shown is folded to its name and status; Show values opens it to copy them again. The SPF check is shallow: it looks for this server's address in the record itself and does not follow <code>include:</code> or <code>redirect=</code>, so a record that relies on them is reported as a warning, not a failure. <code>p=none</code> does not affect delivery; tighten to <code>quarantine</code>, then <code>reject</code>, once the reports look clean. A domain's report address is set in its Domain settings.</p></div>
<div class="sp-help-topic" id="apps"><h3>Applications</h3><p>An application is one SASL login, unique across all domains. It may send as any address of its domain or only as listed addresses, and, with a list of client IPs set, only from those addresses. The password is shown once at creation or regeneration and is not stored in readable form.</p></div>
<div class="sp-help-topic" id="apps"><h3>Applications</h3><p>An application is one SASL login, unique across all domains. It may send as any address of its domain or only as listed addresses, and, with a list of client IPs set, only from those addresses. The password is shown once, at creation or regeneration, and the panel does not show it again; the server keeps it, so a domain export can carry it.</p></div>
<div class="sp-help-topic" id="sender-check"><h3>Sender check</h3><p>An application sends only as itself, and that holds for every sender a message names. The envelope sender is held against the login by Postfix. The <code>From:</code> line, the one a mail program shows and the one DMARC holds the DKIM signature to, is held against it by the panel, under the same rule: an application set to any address of its domain may write any address of that domain, and one with a list of addresses only those. A message with any other <code>From:</code> - another of your domains, the server's own name, an address somewhere else, or none at all - is refused for good with <code>550 5.7.1</code>. The line must hold one address, read as a mail program reads it: what a display name around it says is not judged, and a line holding more than one address is refused. A <code>Sender:</code> line, which a mail program shows beside <code>From:</code> as "on behalf of", is held to the same rule when the message has one. A system that writes for people outside your domains writes its own address in <code>From:</code> with the person's name, and the person's address in <code>Reply-To:</code>: their domain in <code>From:</code> would fail DMARC at the receiver anyway. It is a rejected row of the Outbound log, and its page says which address was refused. This is what keeps one application from sending, under this server's signature, as another domain or another application.<br><br>The check is strict. While the panel does not answer, Postfix does not accept mail unchecked: it replies with a temporary error and the application sends again later. Server › Health counts what that costs: for how long the panel did not answer while Postfix itself was running; how many connections Postfix turned away because of it - it refuses from the connection on, before a message is offered, so a client that tries three times is three and a monitor that only connects is one; and how many messages were refused with a temporary error in the middle, when the panel stopped answering after a message had begun. A time marked ≈ includes a start at which supervisord could not say since when Postfix was running: the time since the panel's last sign of life is counted, which may be more than Postfix was up. The card warns when a connection was turned away or a message refused in the last 24 hours. If the numbers stay at nothing, the check is where it should be; if they do not, it belongs in a process of its own.</p></div>
<div class="sp-help-topic" id="limits"><h3>Rate limits</h3><p>Level 1 is a per-IP ceiling set in <code>.env</code>. Level 2 is optional, per domain and per application, entered by hand or computed from the last 30 days of sending, and can never exceed level 1. An application's own limit replaces its domain's.</p></div>
<div class="sp-help-topic" id="log"><h3>Outbound log</h3><p>One row per message and recipient, with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out; or closed by the panel when Postfix no longer held the message and <code>mail.log</code> never said what became of it) and rejected (refused before queueing, so it never reached the queue: under a level-2 rate limit, from a client address the application does not allow, or for a <code>From:</code> or <code>Sender:</code> line that is not the application's - the message's page says which). A message's page lists these steps and the lines Postfix wrote about it in <code>mail.log</code>, found by queue id - and for a message refused before it had one, Postfix's one line about the refusal. The lines are kept with the row: they stay on the page after that file has rotated, for as long as the row does. A message accepted before version 2.2.0 shows them only while they are still in the current file. Rows are deleted after the retention set in Server › Settings, 90 days by default, and their lines with them. A domain administrator sees only the rows of their own domains.</p></div>
<div class="sp-help-topic" id="log"><h3>Outbound log</h3><p>One row per message and recipient (a message refused for its From: or Sender: line is one row, however many recipients it had), with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out; or closed by the panel when Postfix no longer held the message and <code>mail.log</code> never said what became of it) and rejected (refused before queueing, so it never reached the queue: under a level-2 rate limit, from a client address the application does not allow, or for a <code>From:</code> or <code>Sender:</code> line that is not the application's - the message's page says which). A message's page lists these steps and the lines Postfix wrote about it in <code>mail.log</code>, found by queue id - and for a message refused before it had one, Postfix's one line about the refusal. The lines are kept with the row: they stay on the page after that file has rotated, for as long as the row does. A message accepted before version 2.2.0 shows them only while they are still in the current file. Rows are deleted after the retention set in Server › Settings, 90 days by default, and their lines with them. A domain administrator sees only the rows of their own domains.</p></div>
<div class="sp-help-topic" id="queue"><h3>Queue and retries</h3><p>The page lists, as <code>postqueue -p</code> prints it, the messages Postfix has accepted and not yet delivered: mail being sent right now and mail deferred for a later attempt. Only the global role can open it, and it only shows; nothing here deletes or forces a message. A deferred message is tried again after the first retry delay, then at gaps that double up to a cap, until the receiving server takes it or the queue lifetime runs out and it bounces; there is no attempt counter, and the box on the page shows this server's values. A queue that keeps growing means a receiver is refusing or not answering: open the message from the Outbound log to read the reason Postfix logged.</p></div>
<div class="sp-help-topic" id="dmarc"><h3>DMARC reports</h3><p>An aggregate report is the summary a receiving provider sends, usually daily, to the <code>rua=</code> address of your DMARC record: which servers sent mail as your domain and whether SPF or DKIM passed for it. A domain's report address is one of three choices, set in its Domain settings: no reports (the default for a new domain), the SelfPost hosted address (an address on this server whose reports are parsed and shown under DMARC reports; offered only while hosted reports are enabled on the server), or an address typed for the domain (an ordinary mailbox that receives the raw XML; a button fills in your own profile e-mail). When the typed address is on another domain than the one sending, that domain must publish a report-authorization record, or providers will not send; the domain's page shows it with the other DNS records and checks it. In the tables pass means SPF or DKIM passed and aligned, fail means neither did, and your own server shows as <em>this relay</em>. A fail from any other source is someone else sending as your domain — a forwarder, an application you forgot, or abuse — so do not tighten <code>p=</code> until that sender is fixed or removed. A domain administrator reads the reports of their own domains.</p></div>
<div class="sp-help-topic" id="dmarc"><h3>DMARC reports</h3><p>An aggregate report is the summary a receiving provider sends, usually daily, to the <code>rua=</code> address of your DMARC record - its day is the reporter's, counted in UTC, and that is the day the lists and a report's title name, while the report's page gives its window in this panel's zone: which servers sent mail as your domain and whether SPF or DKIM passed for it. A domain's report address is one of three choices, set in its Domain settings: no reports (the default for a new domain), the SelfPost hosted address (an address on this server whose reports are parsed and shown under DMARC reports; offered only while hosted reports are enabled on the server), or an address typed for the domain (an ordinary mailbox that receives the raw XML; a button fills in your own profile e-mail). When the typed address is on another domain than the one sending, that domain must publish a report-authorization record, or providers will not send; the domain's page shows it with the other DNS records and checks it. In the tables pass means SPF or DKIM passed and aligned, fail means neither did, and your own server shows as <em>this relay</em>. A fail from any other source is someone else sending as your domain — a forwarder, an application you forgot, or abuse — so do not tighten <code>p=</code> until that sender is fixed or removed. A domain administrator reads the reports of their own domains.</p></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Inbound</h2></div>
<div class="sp-help-topic" id="inbound"><h3>Backup-MX and forwarding</h3><p>An optional backup-MX or forwarder, switched on in the server's <code>.env</code>; the Inbound menu exists only then. Postfix accepts mail on port 25 for the listed domains only and hands each message on to that domain's upstream; there are no mailboxes. For each domain publish the MX record its page shows, and keep a primary MX the domain already has if this is a backup. The MX tag is green when at least one MX of the domain points at this server, and a domain accepts no mail until an upstream is saved on its page. Adding and deleting domains is for the global role.</p></div>
<div class="sp-help-topic" id="upstream"><h3>Upstream</h3><p>The upstream is the server that actually holds the mailboxes. Give its host and port — used exactly as written, with no MX lookup — and choose the TLS for the hop to it: Opportunistic uses TLS when the upstream offers it and otherwise sends in the clear; Required sends only over TLS, and the mail waits in the queue while the upstream does not offer it or the TLS handshake fails - an upstream that answers STARTTLS with a permanent error bounces it; Off never uses TLS. While the upstream does not answer, Postfix keeps the mail and retries as for any other mail.</p></div>
<div class="sp-help-topic" id="upstream"><h3>Upstream</h3><p>The upstream is the server that actually holds the mailboxes. Give its host and port — used exactly as written, with no MX lookup — and choose the TLS for the hop to it: Opportunistic uses TLS when the upstream offers it and otherwise sends in the clear; Required sends only over TLS, and the mail waits in the queue while the upstream does not offer it or the TLS handshake fails - an upstream that answers STARTTLS with a permanent error bounces it. Neither checks the upstream's certificate: TLS here keeps the mail from whoever only listens on the way, not from one who answers in the upstream's place. Verified is Required with that check too: the upstream's certificate has to be issued by a trusted authority for the host given here, so the host is given by name, not by address, and the mail waits in the queue while the check fails - a certificate for another name, or one the upstream signed itself, included. Off never uses TLS. While the upstream does not answer, Postfix keeps the mail and retries as for any other mail.</p></div>
<div class="sp-help-topic" id="recipients"><h3>Valid recipients</h3><p>Choose whom this domain accepts. Listed addresses only: the relay accepts exactly the addresses you list, one per line or comma-separated, and refuses any other while the sender is still connected, so it never has to send a bounce for them. Any recipient: everything at the domain is accepted and passed on, and an address the upstream then rejects comes back as a bounce from this server to a sender who is often forged — backscatter. Prefer a list, and keep it in step with the mailboxes upstream.</p></div>
<div class="sp-help-topic" id="filter"><h3>Spam filter</h3><p>The spam filter belongs to the server, not to a domain. The server's administrator attaches it in the server's <code>.env</code>, and from then on every inbound domain is screened by it before mail is handed on; the box shows on or off and has nothing to switch. Off means mail goes to the upstream unfiltered. While the filter is down, mail is either accepted unfiltered (the default) or refused with a temporary error, so the sending server tries again later, as the server's setting says; the box states which.</p></div>
<div class="sp-help-topic" id="inbound-log"><h3>Inbound log</h3><p>One row per recipient of a message offered on port 25 for an inbound domain, with what was decided: <span class="tag is-success is-light">accept</span> — taken for the upstream; <span class="tag is-danger is-light">reject</span> — refused for good; <span class="tag is-warning is-light">tempfail</span> — the sender was asked to come back later; <span class="tag is-primary is-light">quarantine</span> — held by the filter. <em>Why</em> names who decided. <code>rspamd</code> is the spam filter, with its action, the score against the reject threshold and the heaviest symbols; <code>postfix</code> is the relay's own rule — an unknown recipient, a sender domain that does not exist — with its reply; <code>selfpost</code> means nobody judged the message: it was relayed with no filter attached. <em>Upstream</em> says what became of an accepted message: <span class="tag is-light">queued</span> — not tried yet; <span class="tag is-primary is-light">held</span> — held in the queue by the filter, not tried until it is released; <span class="tag is-success is-light">sent</span> — the upstream took it; <span class="tag is-warning is-light">deferred</span> — the upstream put it off or could not be reached, and Postfix tries again; <span class="tag is-danger is-light">bounced</span> — the upstream refused it, or it waited in the queue too long, and the sender was sent a bounce, if it had a return address. It opens the last reply and the host that gave it. A refused message has no upstream result, and neither has a row logged before 2.10.0.<br><br>Accepted mail is recorded as it passes; refused mail is read from <code>mail.log</code>, so its row has no subject, and a message refused after its text was sent is one row however many recipients it had. A refusal to relay for a domain that is not an inbound one is every scanner on the internet and is not recorded. Rows are kept for the number of days set in Settings, and never more than 10 000.</p></div>
@@ -78,14 +78,14 @@
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div>
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys and application credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. Both it and a domain's export ask for your account password - the one you sign in with - at every download, so a session left open is not enough to take them; wrong guesses count against the same limit as a password change. The Postfix queue is not in it. The backup is taken while the server is running, and Postfix keeps moving messages through the queue, so a copy could hold a message twice or miss it; mail still waiting for delivery is therefore not carried over, and a restored server starts with an empty queue. To keep the queue, stop the container and archive the project directory instead. The private key in <code>certs/</code> has to be readable by the panel: give it to group 10998 on the host (<code>chgrp 10998</code>, <code>chmod 640</code>). Until then the panel refuses to make a backup and names the file, rather than hand over an archive without the key. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
<div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out of their other sessions.</p></div>
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. The time zone is the container's, <code>TZ</code> in <code>.env</code> (UTC when it is not set): every time the panel shows and every day it counts is that zone's, as are the stamps Postfix writes in <code>mail.log</code>, so the two always agree; changing it is a restart of the container. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. With the inbound relay on there is a second number, for the Inbound log, with the same range. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it. Notifications are turned on or off here for everyone.</p></div>
<div class="sp-help-topic" id="notifications"><h3>Notifications</h3><p>The panel writes to its users when something needs them, from <code>postmaster@</code> the server's hostname. Four things are reported. A sending domain's SPF, DKIM or DMARC record stops matching: sent once two hourly checks in a row have found it so, and once more when the record matches again. A level-2 rate limit refuses mail: sent at the first refusal, then at most once a day for the same limit. The TLS certificate is 14 days from expiry, and again at 3 days if it is still the same certificate. A message has waited in the queue for more than three days, with one more message when none is left. A global user is told all of it; a domain administrator is told about the DNS records and limits of their own domains.<br><br>Each user turns notifications on or off under Account and needs an e-mail there; Server › Settings turns them off for everyone. They are the panel's own mail: not rows of the Outbound log, and not counted against any limit.<br><br>For that mail to arrive, the server's own name needs two DNS records of its own, as each of your domains does: a DKIM key, which the panel generates and signs notifications with, and an SPF record, <code>v=spf1 a -all</code>. Server › Settings gives both with the values to publish and whether they are in place, and its Re-check DNS checks them again once they are published; Server › Health shows them too. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains does not depend on either.</p></div>
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys and application credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. Both it and a domain's export ask for your account password - the one you sign in with - at every download, so a session left open is not enough to take them; a wrong one counts against the limit of a password change, ten in 15 minutes. The Postfix queue is not in it. The backup is taken while the server is running, and Postfix keeps moving messages through the queue, so a copy could hold a message twice or miss it; mail still waiting for delivery is therefore not carried over, and a restored server starts with an empty queue. To keep the queue, stop the container and archive the project directory instead. The private key in <code>certs/</code> has to be readable by the panel: give it to group 10997 on the host (<code>chgrp 10997</code>, <code>chmod 640</code>), a group only the panel is in, so Postfix's daemons cannot read it. Until then the panel refuses to make a backup and names the file, rather than hand over an archive without the key. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
<div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out everywhere; set for yourself, it keeps the session you set it from. Adding a user, changing a user's password, e-mail or role, and deleting a user ask for your own account password, as a backup does, with the same limit on wrong guesses.</p></div>
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. The time zone is the container's, <code>TZ</code> in <code>.env</code> (UTC when it is not set): every time the panel shows and every day it counts is that zone's, as are the stamps Postfix writes in <code>mail.log</code>, so the two always agree; changing it is a restart of the container. The one exception is the day a DMARC report covers, which is the reporter's, counted in UTC. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. With the inbound relay on there is a second number, for the Inbound log, with the same range. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it. Notifications are turned on or off here for everyone.</p></div>
<div class="sp-help-topic" id="notifications"><h3>Notifications</h3><p>The panel writes to its users when something needs them, from <code>postmaster@</code> the server's hostname. Four things are reported. A sending domain's SPF, DKIM or DMARC record stops matching: sent once two hourly checks in a row have found it so, and once more when the record matches again. A level-2 rate limit refuses mail: sent at the first refusal, then at most once a day for the same sending domain. The TLS certificate is 14 days from expiry, and again at 3 days if it is still the same certificate. A message has waited in the queue for more than three days, with one more message when none is left. A global user is told all of it; a domain administrator is told about the DNS records and limits of their own domains.<br><br>Each user turns notifications on or off under Account and needs an e-mail there; Server › Settings turns them off for everyone. They are the panel's own mail: not rows of the Outbound log, and not counted against any limit.<br><br>For that mail to arrive, the server's own name needs DNS records of its own, as each of your domains does: a DKIM key, which the panel generates and signs notifications with, an SPF record, <code>v=spf1 a -all</code>, and a DMARC record, <code>v=DMARC1; p=reject</code>, which has receivers refuse mail forged in the server's name - a false password-reset message among it; only the panel sends as this name, signed, so reject is safe. Server › Settings gives all three with the values to publish and whether they are in place, and its Re-check DNS checks them again once they are published; Server › Health shows them too. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains depends on none of them.</p></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">05</span><h2>Account</h2></div>
<div class="sp-help-topic" id="account"><h3>Your account</h3><p>Account is your own, whatever your role: your username and e-mail, whether the panel sends you notifications, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected.</p></div>
<div class="sp-help-topic" id="account"><h3>Your account</h3><p>Account is your own, whatever your role: your username and e-mail, whether the panel sends you notifications, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected. A new e-mail is sent a link that confirms it, which works once, for 24 hours; changing the e-mail asks for your password. Notifications go to the address whether it is confirmed or not, but only a confirmed one can get a link to reset a forgotten password: <em>Forgot your password?</em> on the sign-in page sends it, for 30 minutes and once, and setting the new password signs every session of the account out. Without a confirmed e-mail the global administrator sets a new password under Users, and the only global administrator recovers from the container.</p></div>
</div>
</div>
</div>
@@ -4,7 +4,7 @@
<div class="sp-grow">
<p class="sp-kicker"><a href="in-domains.html">Inbound domains</a> /</p>
<h1 class="title is-3">lists.example.org</h1>
<p class="sp-lead">Accepted on port 25, handed to <span class="sp-mono">mx.internal:25</span> over required TLS · 4 recipients</p>
<p class="sp-lead">Accepted on port 25, handed to <span class="sp-mono">mx.internal:25</span> over verified TLS · 4 recipients</p>
</div>
<div class="buttons">
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form>
@@ -58,7 +58,8 @@
<div class="field"><label class="label" for="port">Port</label><div class="control"><input class="input sp-mono" id="port" name="port" inputmode="numeric" value="25" required></div></div>
</div>
<div class="field"><label class="label" for="tls">TLS to upstream</label>
<div class="control"><div class="select is-fullwidth"><select id="tls" name="tls_mode"><option>Opportunistic</option><option selected>Required</option><option>Off</option></select></div></div></div>
<div class="control"><div class="select is-fullwidth"><select id="tls" name="tls_mode"><option>Opportunistic</option><option>Required</option><option selected>Verified</option><option>Off</option></select></div></div>
<p class="help">Required encrypts the hop without checking who answers. Verified also checks the upstream's certificate: issued by a trusted authority for the host above, which is then given by name.</p></div>
<button type="submit" class="button is-primary">Save upstream</button>
</form>
</div>
@@ -76,7 +77,7 @@
dev@lists.example.org
owner@lists.example.org
security@lists.example.org</textarea></div>
<p class="help">Unknown recipients are rejected at RCPT, so this relay generates no backscatter. Prefer a list unless the upstream itself rejects unknowns.</p></div>
<p class="help">One per line or comma-separated. Unknown recipients are rejected at RCPT, so this relay generates no backscatter. Prefer a list unless the upstream itself rejects unknowns.</p></div>
<button type="submit" class="button is-primary">Save recipients</button>
</form>
</div>
@@ -88,10 +89,9 @@ security@lists.example.org</textarea></div>
<div class="sp-box-head"><span class="sp-no">04</span><h2>Spam filter</h2><div class="sp-end"><span class="tag is-success is-light">on</span></div><a class="sp-help" href="help.html#filter" title="A server setting"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Engine</dt><dd>rspamd, for every inbound domain of this server</dd></div>
<div><dt>Suspicious mail</dt><dd>Held — <a href="in-quarantine.html">1 message in quarantine</a> for this domain</dd></div>
<div><dt>Spam</dt><dd>Refused at the door; see <em>reject</em> in the <a href="in-log.html">log</a></dd></div>
<div><dt>Allow and deny lists</dt><dd class="sp-muted">Kept by the global administrator, one pair for the whole server</dd></div>
<div><dt>Applies to</dt><dd>Every inbound domain of this server</dd></div>
<div><dt>Filter</dt><dd class="sp-mono">inet:antispam:11332</dd></div>
<div><dt>If the filter is down</dt><dd>Mail is accepted unfiltered</dd></div>
</dl>
</div>
</div>
@@ -25,7 +25,7 @@
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Domain</th><th>DNS</th><th>Upstream</th><th>Recipients</th><th>TLS to upstream</th><th></th></tr></thead>
<tbody>
<tr><td><a href="in-domain.html"><strong>lists.example.org</strong></a></td><td><span class="tag is-success is-light">MX</span></td><td class="sp-mono sp-muted">mx.internal:25</td><td>4 addresses</td><td><span class="tag is-success is-light">required</span></td><td class="sp-actions"><a href="in-domain.html">Open</a><a class="has-text-danger" href="in-domain-delete.html">Delete</a></td></tr>
<tr><td><a href="in-domain.html"><strong>lists.example.org</strong></a></td><td><span class="tag is-success is-light">MX</span></td><td class="sp-mono sp-muted">mx.internal:25</td><td>4 addresses</td><td><span class="tag is-success is-light">verified</span></td><td class="sp-actions"><a href="in-domain.html">Open</a><a class="has-text-danger" href="in-domain-delete.html">Delete</a></td></tr>
<tr><td><a href="in-domain.html"><strong>acme.io</strong></a></td><td><span class="tag is-success is-light">MX</span></td><td class="sp-mono sp-muted">10.0.4.12:2525</td><td>any address</td><td><span class="tag is-light">opportunistic</span></td><td class="sp-actions"><a href="in-domain.html">Open</a><a class="has-text-danger" href="in-domain-delete.html">Delete</a></td></tr>
</tbody>
</table></div>
@@ -8,7 +8,7 @@
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Waiting</h2><div class="sp-end">Refreshes every 10 s</div></div>
<div class="sp-box-head"><span class="sp-no">01</span><h2>Waiting</h2><div class="sp-end">Refreshes on its own</div></div>
<div class="sp-empty"><i class="ti ti-stack-2"></i>Nothing is waiting. Every accepted message has reached its upstream.</div>
</div>
@@ -12,6 +12,6 @@
<h1 class="title is-3">No administrator yet</h1>
<p class="sp-muted">Open the one-time setup link printed in the container log to create one.</p>
</div>
<p class="sp-foot">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
@@ -0,0 +1,27 @@
<!-- title: Sign in | shell: bare -->
<div class="columns is-gapless sp-signin">
<div class="column is-half sp-signin-brand">
<div>
<img src="../../selfpost-stamp.svg" alt="SelfPost">
<p>YOUR SMTP. YOUR RULES.</p>
</div>
</div>
<div class="column is-half sp-signin-form">
<form class="sp-form" method="post" action="overview.html">
<p class="sp-kicker">mail.example.org</p>
<h1 class="title is-3">Sign in</h1>
<div class="notification is-success is-light"><i class="ti ti-circle-check"></i>If that account has a confirmed e-mail, the link is on its way. It works once, for 30 minutes.</div>
<div class="field">
<label class="label" for="username">Username</label>
<div class="control has-icons-left"><input class="input" id="username" name="username" autocomplete="username" required><span class="icon is-left"><i class="ti ti-user"></i></span></div>
</div>
<div class="field">
<label class="label" for="password">Password</label>
<div class="control has-icons-left"><input class="input" id="password" name="password" type="password" autocomplete="current-password" required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
</div>
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Sign in</button></div>
<p class="mt-4"><a href="login-reset.html">Forgot your password?</a></p>
</form>
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
@@ -0,0 +1,26 @@
<!-- title: New password | shell: bare -->
<div class="columns is-gapless sp-signin">
<div class="column is-half sp-signin-brand">
<div>
<img src="../../selfpost-stamp.svg" alt="SelfPost">
<p>YOUR SMTP. YOUR RULES.</p>
</div>
</div>
<div class="column is-half sp-signin-form">
<form class="sp-form" method="post" action="login.html">
<p class="sp-kicker">mail.example.org · admin</p>
<h1 class="title is-3">New password</h1>
<p class="sp-muted mb-4">Setting it signs every session of this account out; sign in with it afterwards.</p>
<div class="field">
<label class="label" for="new_password">New password</label>
<div class="control has-icons-left"><input class="input" id="new_password" name="new_password" type="password" autocomplete="new-password" required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
</div>
<div class="field">
<label class="label" for="new_password_confirm">Repeat</label>
<div class="control has-icons-left"><input class="input" id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password" required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
</div>
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Set password</button></div>
</form>
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
@@ -0,0 +1,23 @@
<!-- title: Reset password | shell: bare -->
<div class="columns is-gapless sp-signin">
<div class="column is-half sp-signin-brand">
<div>
<img src="../../selfpost-stamp.svg" alt="SelfPost">
<p>YOUR SMTP. YOUR RULES.</p>
</div>
</div>
<div class="column is-half sp-signin-form">
<form class="sp-form" method="post" action="login-reset-sent.html">
<p class="sp-kicker">mail.example.org</p>
<h1 class="title is-3">Reset password</h1>
<p class="sp-muted mb-4">A link to set a new password goes to the e-mail of the account, once that address is confirmed under Account. It works once, for 30 minutes.</p>
<div class="field">
<label class="label" for="username">Username</label>
<div class="control has-icons-left"><input class="input" id="username" name="username" autocomplete="username" required><span class="icon is-left"><i class="ti ti-user"></i></span></div>
</div>
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Send the link</button></div>
<p class="mt-4"><a href="login.html">Back to sign in</a></p>
</form>
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
@@ -19,7 +19,8 @@
<div class="control has-icons-left"><input class="input" id="password" name="password" type="password" autocomplete="current-password" required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
</div>
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Sign in</button></div>
<p class="mt-4"><a href="login-reset.html">Forgot your password?</a></p>
</form>
<p class="sp-foot">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
@@ -3,7 +3,7 @@
<div class="sp-grow">
<p class="sp-kicker"><a href="out-domains.html">Outbound domains</a> / <a href="out-domain.html">example.org</a> / Applications /</p>
<h1 class="title is-3 sp-mono">prod-server</h1>
<p class="sp-lead">1 102 messages in 30 days · peak 96 msg/h · average 1.5 msg/h. The same form, empty, adds a new application.</p>
<p class="sp-lead">1 102 messages in 30 days · peak 96 msg/h · average 1.5 msg/h.</p>
</div>
<div class="buttons">
<a class="button" href="out-app-created.html"><i class="ti ti-key"></i>New password</a>
@@ -19,7 +19,7 @@
<div class="sp-box-body">
<div class="field"><label class="label" for="login">Login</label>
<div class="control"><input class="input sp-mono" id="login" name="login" value="prod-server" autocomplete="off" spellcheck="false" required></div>
<p class="help">Unique across domains; letters, digits, <code>.</code> <code>-</code> <code>_</code>.</p></div>
<p class="help">Fixed: add a new application to use another login.</p></div>
<div class="field"><label class="label" for="amode">May send as</label>
<div class="control"><div class="select is-fullwidth"><select id="amode" name="mode"><option selected>Any address of the domain</option><option>Specific addresses (list)</option></select></div></div></div>
<div class="field"><label class="label" for="addresses">Addresses</label>
@@ -15,6 +15,7 @@
<ul class="sp-list-check">
<li><i class="ti ti-x"></i>permanently delete its DKIM signing key;</li>
<li><i class="ti ti-x"></i>delete <strong>both applications</strong> (<span class="sp-mono">prod-server</span>, <span class="sp-mono">alerts</span>) with their credentials and sender bindings;</li>
<li><i class="ti ti-x"></i>delete the <strong>12 DMARC reports</strong> received for it;</li>
<li><i class="ti ti-x"></i>stop signing mail as the domain.</li>
</ul>
<p class="sp-muted mb-4">The published DKIM record becomes invalid — remove it from DNS unless you plan to re-add the domain. Inbound domains are not touched.</p>
@@ -53,7 +53,7 @@
<form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="mode">Limit mode</label>
<div class="control"><div class="select is-fullwidth"><select id="mode" name="mode"><option>Manual</option><option selected>Auto (from statistics)</option></select></div></div></div>
<div class="field"><label class="label" for="mult">Multiplier (1.5–10, default 3)</label>
<div class="field"><label class="label" for="mult">Multiplier (1.5–5, default 2.5)</label>
<div class="control"><input class="input" id="mult" name="auto_multiplier" type="number" min="1.5" max="10" step="0.1" value="3"></div>
<p class="help">Computed limit: <strong>288</strong> messages / 3600 s. Peak was 96 msg/h. Last recalculated yesterday 03:00.</p></div>
<div class="buttons">
@@ -28,9 +28,9 @@
<div class="sp-box-head"><span class="sp-no">02</span><h2>History</h2><a class="sp-help" href="help.html#log" title="What the statuses mean"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body">
<ol class="sp-timeline">
<li><time>13:58:07</time><strong>Accepted</strong> from <span class="sp-mono">alerts</span> at 198.51.100.7</li>
<li class="sp-warn"><time>13:58:09</time><strong>Deferred</strong> — the receiving server asked to retry later</li>
<li class="sp-pending"><time>next attempt ≈ 14:08</time>Still in the <a href="out-queue.html">queue</a>; kept for 5 days, then bounced</li>
<li><time>2026-09-21 13:58:07</time><strong>Accepted and queued</strong> — Postfix accepted the message over an authenticated submission and the journal-milter recorded it.</li>
<li class="sp-warn"><time>2026-09-21 13:58:09</time><strong>Deferred, will be retried</strong> — the receiving server could not take the message yet. Postfix retries: first after 5 minutes, then with increasing gaps up to 1 hour 7 minutes, for up to 5 days.</li>
<li class="sp-pending"><time>not yet</time>Still in the queue; the next attempt is Postfix's own, and after 5 days it is bounced.</li>
</ol>
</div>
</div>
@@ -3,7 +3,7 @@
<div class="sp-grow">
<p class="sp-kicker">Outbound</p>
<h1 class="title is-3">Outbound queue</h1>
<p class="sp-lead">Messages accepted from applications and still waiting for the receiving server. Refreshes every 10 s.</p>
<p class="sp-lead">Messages accepted from applications and still waiting for the receiving server. Refreshes on its own.</p>
</div>
</div>
@@ -4,9 +4,9 @@
<div class="sp-grow">
<p class="sp-kicker">mail.example.org</p>
<h1 class="title is-3">Mail is flowing. One thing needs attention.</h1>
<p class="sp-lead">The TLS certificate expires in 12 days and has not renewed yet.</p>
<p class="sp-lead"><a href="health.html">TLS certificate</a>: expires in 12 days.</p>
</div>
<div class="sp-muted sp-small">Updated 14:02:31<br>refreshes every 10 s</div>
<div class="sp-muted sp-small">Updated 14:02:31<br>refreshes on its own</div>
</div>
<div class="box">
@@ -16,7 +16,7 @@
<a href="health.html"><span class="sp-h-icon"><i class="ti ti-server-cog"></i></span><span><span class="sp-h-name">Processes</span><span class="sp-h-value">5 of 5 running</span><span class="sp-h-sub">up 18 days</span></span></a>
<a href="health.html" class="sp-warn"><span class="sp-h-icon"><i class="ti ti-certificate"></i></span><span><span class="sp-h-name">TLS certificate</span><span class="sp-h-value">Expires in 12 days</span><span class="sp-h-sub">renewal has not run</span></span></a>
<a href="out-queue.html"><span class="sp-h-icon"><i class="ti ti-stack-2"></i></span><span><span class="sp-h-name">Queues</span><span class="sp-h-value">Both empty</span><span class="sp-h-sub">outbound and inbound</span></span></a>
<a href="health.html"><span class="sp-h-icon"><i class="ti ti-plug-connected"></i></span><span><span class="sp-h-name">Milter sockets</span><span class="sp-h-value">5 of 5 answering</span><span class="sp-h-sub">incl. the spam filter</span></span></a>
<a href="health.html"><span class="sp-h-icon"><i class="ti ti-plug-connected"></i></span><span><span class="sp-h-name">Milter sockets</span><span class="sp-h-value">6 of 6 answering</span><span class="sp-h-sub">incl. the spam filter</span></span></a>
<a href="health.html"><span class="sp-h-icon"><i class="ti ti-arrows-exchange"></i></span><span><span class="sp-h-name">Reverse DNS</span><span class="sp-h-value">Forward = reverse</span><span class="sp-h-sub">203.0.113.25</span></span></a>
</div>
</div>
@@ -62,6 +62,18 @@
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=spf1 a -all"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<p class="help">Notifications are sent without it, and a receiver may file them as spam.</p>
</div>
<div class="sp-record">
<div class="sp-record-head"><h3>DMARC</h3><span class="tag is-warning is-light">missing</span><small>For the server's own name, not for your domains</small></div>
<div class="sp-row">
<div class="field"><label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="_dmarc.mail.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div>
<div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=DMARC1; p=reject"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<p class="help">Without it a receiver does not refuse mail forged in the server's name - a false password-reset message included. Only the panel sends as this name, signed, so reject is safe.</p>
</div>
</div>
<div class="box" id="rate-limits">
@@ -80,7 +92,7 @@
<dl class="sp-facts">
<div><dt>Zone</dt><dd class="sp-mono">Europe/Moscow</dd><dd class="sp-muted">UTC+03:00. Set in <code>.env</code> (<code>TZ</code>); restart to change.</dd></div>
</dl>
<p class="mt-4 sp-muted">Every time the panel shows, and every day it counts - today on Health, the days of the statistics - is this zone's, and so are the stamps of <code>mail.log</code>.</p>
<p class="mt-4 sp-muted">Every time the panel shows, and every day it counts - today on Health, the days of the statistics - is this zone's, and so are the stamps of <code>mail.log</code>. One exception: the day a DMARC report covers is the reporter's, in UTC.</p>
</div>
</div>
</div>
@@ -25,6 +25,6 @@
</div>
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Create administrator</button></div>
</form>
<p class="sp-foot">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
@@ -3,12 +3,12 @@
<div class="sp-grow">
<p class="sp-kicker">Server</p>
<h1 class="title is-3">System log</h1>
<p class="sp-lead">The tail of <code>mail.log</code>, the log Postfix writes. For one message, open it from a log page instead.</p>
<p class="sp-lead">The tail of <code>mail.log</code>, the log Postfix writes. For one message, open it from the <a href="out-log.html">Outbound log</a> instead.</p>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Recent lines</h2><div class="sp-end">Last 200 · refreshes every 10 s</div></div>
<div class="sp-box-head"><span class="sp-no">01</span><h2>Recent lines</h2><div class="sp-end">Last 200 lines · refreshes on its own</div></div>
<pre class="sp-log"><span class="sp-ll"><span class="sp-d">Sep 21 14:01:52</span> postfix/smtp[2131]: 4XcB9q0Lw2z1: to=&lt;m.keller@gmx.de&gt;, relay=mx00.gmx.net[212.227.15.9]:25, delay=0.8, status=sent (250 Requested mail action okay)
</span><span class="sp-ll"><span class="sp-d">Sep 21 14:01:52</span> postfix/qmgr[58]: 4XcB9q0Lw2z1: removed
</span><span class="sp-ll"><span class="sp-d">Sep 21 14:00:18</span> postfix/smtpd[2129]: connect from mail.uni.example[192.0.2.77]
@@ -14,10 +14,12 @@
<p class="mb-3">Deleting the panel user <strong>acme-ops</strong> will:</p>
<ul class="sp-list-check">
<li><i class="ti ti-x"></i>end any signed-in session of this user at once;</li>
<li><i class="ti ti-x"></i>remove the assignments to <strong>all outbound domains</strong> and to <strong>acme.io</strong> (inbound).</li>
<li><i class="ti ti-x"></i>remove the assignments to <strong>all outbound domains</strong> (outbound) and to <strong>acme.io</strong> (inbound).</li>
</ul>
<p class="sp-muted mb-4">The domains, their applications and passwords, upstreams and recipients are not affected.</p>
<p class="sp-muted mb-4">Applications and their passwords, upstreams and recipients are not affected.</p>
<form method="post" action="users.html">
<div class="field"><label class="label" for="acpw">Your account password</label><div class="control"><input class="input" id="acpw" name="account_password" type="password" autocomplete="current-password" required></div>
<p class="help">The one you sign in with: a session left open must not be enough to remove an administrator.</p></div>
<div class="buttons">
<button type="submit" class="button is-danger"><i class="ti ti-trash"></i>Delete acme-ops</button>
<a class="button is-text" href="user.html">Keep it</a>
@@ -3,7 +3,6 @@
<div class="sp-grow">
<p class="sp-kicker"><a href="users.html">Users</a> /</p>
<h1 class="title is-3">acme-ops</h1>
<p class="sp-lead">The same form, empty, creates a new user.</p>
</div>
<a class="button is-danger is-light" href="user-delete.html"><i class="ti ti-trash"></i>Delete user</a>
</div>
@@ -15,13 +14,15 @@
<div class="sp-box-head"><span class="sp-no">01</span><h2>Account</h2><a class="sp-help" href="help.html#users" title="Roles and domain assignments"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body">
<div class="field"><label class="label" for="username">Username</label><div class="control"><input class="input" id="username" name="username" value="acme-ops" autocomplete="username" spellcheck="false" required></div></div>
<div class="field"><label class="label" for="email">E-mail</label><div class="control"><input class="input" id="email" name="email" type="email" value="ops@acme.io" autocomplete="off"></div>
<p class="help">For notifications and a password-reset link. The user can change it under Account.</p></div>
<div class="field"><label class="label" for="email">E-mail <span class="tag is-success is-light">confirmed</span></label><div class="control"><input class="input" id="email" name="email" type="email" value="ops@acme.io" autocomplete="off"></div>
<p class="help">The user's own address. They can change it under Account. A new one is sent a link to confirm it; until it is confirmed no reset link goes to it.</p></div>
<div class="field"><label class="label" for="password">Password</label><div class="control"><input class="input" id="password" name="password" type="password" autocomplete="new-password"></div>
<p class="help">Leave empty to keep the current one.</p></div>
<div class="field"><label class="label" for="role">Role</label>
<div class="control"><div class="select is-fullwidth"><select id="role" name="role"><option selected>Domain — the domains assigned below</option><option>Global — the whole server</option></select></div></div>
<p class="help">Everyone here is an administrator; the role is the reach. <em>Global</em> also sees Server and is the only one who adds or deletes domains. The only global user cannot be changed to <em>domain</em> or deleted.</p></div>
<div class="field"><label class="label" for="acpw">Your account password</label><div class="control"><input class="input" id="acpw" name="account_password" type="password" autocomplete="current-password"></div>
<p class="help">Yours, the one you sign in with, not this user's. Asked when you add a user, or change a password, an e-mail or a role here: a session left open must not be enough to hand out an account.</p></div>
</div>
</div>
</div>
@@ -33,7 +34,7 @@
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids" checked disabled> example.org</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids" checked disabled> shop.example.org</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids" checked disabled> notify.acme.io</label></div>
<p class="help">With <em>All</em> ticked the list below it is ignored — and greyed out where scripts run.</p>
<p class="help">With <em>All</em> ticked the single domains are ignored.</p>
</div>
</div>
<div class="box">
@@ -69,12 +69,12 @@
<div class="sp-grow">
<p class="sp-kicker">Server</p>
<h1 class="title is-3">System log</h1>
<p class="sp-lead">The tail of <code>mail.log</code>, the log Postfix writes. For one message, open it from a log page instead.</p>
<p class="sp-lead">The tail of <code>mail.log</code>, the log Postfix writes. For one message, open it from the <a href="out-log.html">Outbound log</a> instead.</p>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Recent lines</h2><div class="sp-end">Last 200 · refreshes every 10 s</div></div>
<div class="sp-box-head"><span class="sp-no">01</span><h2>Recent lines</h2><div class="sp-end">Last 200 lines · refreshes on its own</div></div>
<pre class="sp-log"><span class="sp-ll"><span class="sp-d">Sep 21 14:01:52</span> postfix/smtp[2131]: 4XcB9q0Lw2z1: to=&lt;m.keller@gmx.de&gt;, relay=mx00.gmx.net[212.227.15.9]:25, delay=0.8, status=sent (250 Requested mail action okay)
</span><span class="sp-ll"><span class="sp-d">Sep 21 14:01:52</span> postfix/qmgr[58]: 4XcB9q0Lw2z1: removed
</span><span class="sp-ll"><span class="sp-d">Sep 21 14:00:18</span> postfix/smtpd[2129]: connect from mail.uni.example[192.0.2.77]
@@ -87,7 +87,7 @@
</span></pre>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -224,6 +224,12 @@ code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; pad
/* A label may carry a tag, the verdict on its value (the certificate's
names against the hostname); it keeps the tag's own type. */
.sp-facts dt .tag { margin-left: .4rem; text-transform: none; letter-spacing: 0; vertical-align: 1px; }
/* A tag beside a label's text (a field's or a fact's) is no taller than the
text's line: Bulma's tag is 2em high, which made the line taller and left
less room under the label than under one without a tag (an e-mail's
"not confirmed" on Account, the owner's phone, 2026-10-17). */
.label .tag, .sp-facts dt .tag { height: auto; padding-top: 0; padding-bottom: 0; line-height: 1.5; }
.label .tag { margin-left: .25rem; vertical-align: 1px; }
/* A fact that takes the whole row: a line a server wrote, read as one. */
.sp-facts .sp-wide { grid-column: 1 / -1; }
.sp-facts dd.sp-big { font-size: 1.5rem; font-weight: 600; line-height: 1.2; }
@@ -80,10 +80,12 @@
<p class="mb-3">Deleting the panel user <strong>acme-ops</strong> will:</p>
<ul class="sp-list-check">
<li><i class="ti ti-x"></i>end any signed-in session of this user at once;</li>
<li><i class="ti ti-x"></i>remove the assignments to <strong>all outbound domains</strong> and to <strong>acme.io</strong> (inbound).</li>
<li><i class="ti ti-x"></i>remove the assignments to <strong>all outbound domains</strong> (outbound) and to <strong>acme.io</strong> (inbound).</li>
</ul>
<p class="sp-muted mb-4">The domains, their applications and passwords, upstreams and recipients are not affected.</p>
<p class="sp-muted mb-4">Applications and their passwords, upstreams and recipients are not affected.</p>
<form method="post" action="users.html">
<div class="field"><label class="label" for="acpw">Your account password</label><div class="control"><input class="input" id="acpw" name="account_password" type="password" autocomplete="current-password" required></div>
<p class="help">The one you sign in with: a session left open must not be enough to remove an administrator.</p></div>
<div class="buttons">
<button type="submit" class="button is-danger"><i class="ti ti-trash"></i>Delete acme-ops</button>
<a class="button is-text" href="user.html">Keep it</a>
@@ -94,7 +96,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
+6 -5
View File
@@ -69,7 +69,6 @@
<div class="sp-grow">
<p class="sp-kicker"><a href="users.html">Users</a> /</p>
<h1 class="title is-3">acme-ops</h1>
<p class="sp-lead">The same form, empty, creates a new user.</p>
</div>
<a class="button is-danger is-light" href="user-delete.html"><i class="ti ti-trash"></i>Delete user</a>
</div>
@@ -81,13 +80,15 @@
<div class="sp-box-head"><span class="sp-no">01</span><h2>Account</h2><a class="sp-help" href="help.html#users" title="Roles and domain assignments"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body">
<div class="field"><label class="label" for="username">Username</label><div class="control"><input class="input" id="username" name="username" value="acme-ops" autocomplete="username" spellcheck="false" required></div></div>
<div class="field"><label class="label" for="email">E-mail</label><div class="control"><input class="input" id="email" name="email" type="email" value="ops@acme.io" autocomplete="off"></div>
<p class="help">For notifications and a password-reset link. The user can change it under Account.</p></div>
<div class="field"><label class="label" for="email">E-mail <span class="tag is-success is-light">confirmed</span></label><div class="control"><input class="input" id="email" name="email" type="email" value="ops@acme.io" autocomplete="off"></div>
<p class="help">The user's own address. They can change it under Account. A new one is sent a link to confirm it; until it is confirmed no reset link goes to it.</p></div>
<div class="field"><label class="label" for="password">Password</label><div class="control"><input class="input" id="password" name="password" type="password" autocomplete="new-password"></div>
<p class="help">Leave empty to keep the current one.</p></div>
<div class="field"><label class="label" for="role">Role</label>
<div class="control"><div class="select is-fullwidth"><select id="role" name="role"><option selected>Domain — the domains assigned below</option><option>Global — the whole server</option></select></div></div>
<p class="help">Everyone here is an administrator; the role is the reach. <em>Global</em> also sees Server and is the only one who adds or deletes domains. The only global user cannot be changed to <em>domain</em> or deleted.</p></div>
<div class="field"><label class="label" for="acpw">Your account password</label><div class="control"><input class="input" id="acpw" name="account_password" type="password" autocomplete="current-password"></div>
<p class="help">Yours, the one you sign in with, not this user's. Asked when you add a user, or change a password, an e-mail or a role here: a session left open must not be enough to hand out an account.</p></div>
</div>
</div>
</div>
@@ -99,7 +100,7 @@
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids" checked disabled> example.org</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids" checked disabled> shop.example.org</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids" checked disabled> notify.acme.io</label></div>
<p class="help">With <em>All</em> ticked the list below it is ignored — and greyed out where scripts run.</p>
<p class="help">With <em>All</em> ticked the single domains are ignored.</p>
</div>
</div>
<div class="box">
@@ -119,7 +120,7 @@
</div>
</form>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
+1 -1
View File
@@ -88,7 +88,7 @@
</table></div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
+34 -5
View File
@@ -107,7 +107,7 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see
[§ Release image](#release-image)).
**Planned version cuts** (from pin `2.13.0`; adjust if semver changes mid-track):
**Planned version cuts** (from pin `2.15.0`; adjust if semver changes mid-track):
| Stage | ID | Cut |
|---|---|---|
@@ -150,6 +150,14 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
| 37 | health-setup-checks | `2.11.0` |
| 38 | test-email | `2.12.0` |
| 39 | local-time | `2.13.0` |
| 40 | docs-audit | `2.13.1` |
| 41 | password-reset | `2.14.0` |
| 42 | overview-head | `2.14.1` |
| 43 | dmarc-day, health-sockets | `2.14.2` |
| 44 | label-tag | `2.14.3` |
| 45 | security-audit-2026-10-17, stages 0-6 | `2.14.4` |
| 46 | etrn-silent | `2.14.5` |
| 47 | server-dmarc-hint, upstream-tls-verify | `2.15.0` |
Docs-only prep (checklists in plans, no product code) uses the same per-step
commit rule but **no** version cut until the next product stage ships.
@@ -275,6 +283,26 @@ re-read what changed, update the citations, then this table:
| `logrotate` | `3.22.0-1` |
<!-- /cited-package-versions -->
A security update of one of these, or of OpenSSL or Berkeley DB, reaches
operators only with the next release: published tags are never rebuilt.
`.github/workflows/base-packages.yml` compares every Monday what
`debian:trixie-slim` would install with the versions the last release was
built with, below, and fails when one has moved - the sign to cut a patch
release. Cutting a release updates this table from the image
(`dpkg-query -W` of these names; security audit 2026-10-17, I5):
<!-- base-package-versions -->
| Package | Built with |
|---|---|
| `postfix` | `3.10.13-0+deb13u1` |
| `libsasl2-2` | `2.1.28+dfsg1-9` |
| `libsasl2-modules-db` | `2.1.28+dfsg1-9` |
| `sasl2-bin` | `2.1.28+dfsg1-9` |
| `openssl` | `3.5.7-1~deb13u3` |
| `libssl3t64` | `3.5.7-1~deb13u3` |
| `libdb5.3t64` | `5.3.28+dfsg2-9` |
<!-- /base-package-versions -->
A document instead of source: its URL and section. Where neither answers the
question, the comment says so and what was measured instead - "measured on
the e2e stand, 2026-10-14: ..." - and a measurement is never written as if
@@ -483,7 +511,8 @@ make e2e
**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,
(`20465`/`20587`/`20080`, port 25 at `20025`, the stand's CoreDNS at
`20053/udp`), test hostname, self-signed TLS,
`PANEL_COOKIE_SECURE=false`, isolated compose project, and the image's
`VERSION` from `SELFPOST_E2E_VERSION` (unset locally: `e2e`). Mail is hermetic:
CoreDNS (fake zone) + Postfix `smtp-sink` as sink-MX; DKIM TXT is scraped from
@@ -536,10 +565,10 @@ Workflows in [.github/workflows/](../.github/workflows/). What each job runs —
### `test.yml` — every push and PR to `main`
`design-first` (needs the full history) → `gofmt -l` → `go vet ./...` →
`go test ./...` (main module, no e2e; includes the panel guard tests, see
Staticcheck 2026.1 → `go test ./...` (main module, no e2e; includes the panel guard tests, see
[§ Panel pages](#panel-pages-the-component-kit-and-its-guards)) → the e2e
module compiled, not run (`go vet ./...` and `go test -c -o /dev/null .` in
`test/e2e`).
module compiled, not run (`go vet ./...`, Staticcheck and
`go test -c -o /dev/null .` in `test/e2e`).
### `release.yml` — published GitHub Release, or `workflow_dispatch` with SemVer
+198 -71
View File
@@ -84,8 +84,9 @@ Since `2.7.0` a message sent on 465 or 587 does not tell its recipients
where the application runs: the `Received:` header SelfPost adds reads
`Received: by mail.example.com (Postfix) id 4AbC12xyz for <...>; <date>`,
without the application's address, host name or login. To trace a message
someone complains about, take that queue id to *Outbound › Log*; mail.log
keeps the client address on smtpd's `client=` line for the same id.
someone complains about, find its row in *Outbound › Log* (by domain,
application and day; the message's page shows its queue id); mail.log keeps
the client address on smtpd's `client=` line for the same id.
### Local trial
@@ -182,6 +183,19 @@ docker compose up -d
The nginx/Caddy/Traefik fragments already include `docker compose up -d` —
skip this if you ran one of those.
With Apache on the host, the panel sees every visitor at the Docker
network's gateway. Tell it that address is your proxy, so that each visitor
has a sign-in limit of their own, and recreate the container:
```sh
docker network inspect "$(basename "$PWD")_default" -f '{{(index .IPAM.Config 0).Gateway}}'
```
Put what it prints into `.env` as `TRUSTED_PROXY_CIDR=` and run `docker
compose up -d` again. Until then the panel's log says, after the first
requests, that every visitor shares one limit and what to set; Server ›
Health shows the same under *Your address*.
**Get the setup URL** — open it in a browser to create the admin account (see
[Initial setup](#initial-setup)):
@@ -204,14 +218,14 @@ cat ./data/setup-token
#### Fixed image tag
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
deliberately never `:latest`. The current pin is `2.13.0`. **No 2.x image is
deliberately never `:latest`. The current pin is `2.15.0`. **No 2.x image is
published on ghcr.io yet** (the newest there is `1.3.0`); until one is, build
it from the checkout under the pinned name, and Compose uses the local image
instead of pulling:
```sh
docker build -f build/Dockerfile --build-arg VERSION=2.13.0 \
-t ghcr.io/mixeme/selfpost:2.13.0 .
docker build -f build/Dockerfile --build-arg VERSION=2.15.0 \
-t ghcr.io/mixeme/selfpost:2.15.0 .
```
`VERSION` must equal the tag: it is the version the backup check compares
@@ -250,7 +264,12 @@ expected to set; defaults match the code exactly.
| `PANEL_SESSION_IDLE_DAYS` | Sliding idle timeout for the panel login session, in days. There is no absolute cap: a user who keeps coming back stays signed in indefinitely. | `7` | `.env` |
| `SELFPOST_DNS_RESOLVERS` | Comma-separated recursive resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly (so they report what the internet sees, not what this host's stub resolver synthesises). Each is given 2 seconds to answer before the next is asked, and the one that answered last is asked first; a lookup none of them answered shows as not known, never as a wrong record. | `1.1.1.1:53`, `8.8.8.8:53`, `9.9.9.9:53` when unset | `.env` |
| `TZ` | Time zone of the container, an IANA name such as `Europe/Moscow`: every time the panel shows, every day it counts (today on Health, the days of the statistics, the days of the log filters) and the stamps Postfix writes in `mail.log`, so the two agree. Stored times stay UTC, so changing it is only a restart. *Server › Settings* shows the zone in use, and says so when the image does not know the name (the times are then UTC). | UTC | `.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. With Apache on the host (the default), the panel sees the proxy's connections come from the Docker network's gateway, e.g. `172.18.0.1` (`docker network inspect` shows it; measured 2026-10-15: the panel's `127.0.0.1:8080` is published through Docker's proxy, and a connection to such a port from the host arrives from the gateway). | *(empty — XFF ignored)* | `.env` |
| `TRUSTED_PROXY_CIDR` | Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to supply `X-Forwarded-For` for sign-in and setup rate-limiting (account-change attempts are counted per user, whatever the address). **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. With Apache on the host (the default), the panel sees the proxy's connections come from the Docker network's gateway, e.g. `172.18.0.1` (`docker network inspect` shows it; measured 2026-10-15: the panel's `127.0.0.1:8080` is published through Docker's proxy, and a connection to such a port from the host arrives from the gateway). | *(empty — XFF ignored)* | `.env` |
`PANEL_COOKIE_SECURE` is not in the table: the session cookie is `Secure`
unless it is `false`, which only a trial over plain HTTP wants, and the
compose file does not pass it - the local trial in the README sets it on
`docker run` (`-e PANEL_COOKIE_SECURE=false`).
TLS certificate paths (`TLS_CERT_FILE`, `TLS_KEY_FILE`) are fixed in
[deploy/docker-compose.yml](../deploy/docker-compose.yml) to match the `./certs`
@@ -330,13 +349,13 @@ ln -s /etc/letsencrypt/live/mail.example.com/privkey.pem certs/privkey.pem
```
The panel reads them as its own user (Health shows the certificate, a full
backup carries both files), through the container's group 10998. certbot's
backup carries both files), through the container's group 10997. certbot's
documentation gives the way for a server that is not root, and certbot keeps
the key's group and mode at every renewal:
```sh
sudo chmod 0755 /etc/letsencrypt/live /etc/letsencrypt/archive
sudo chgrp 10998 /etc/letsencrypt/archive/mail.example.com/privkey*.pem
sudo chgrp 10997 /etc/letsencrypt/archive/mail.example.com/privkey*.pem
sudo chmod 0640 /etc/letsencrypt/archive/mail.example.com/privkey*.pem
```
@@ -357,7 +376,7 @@ docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml \
The fragment mounts certbot's directory into both nginx and SelfPost, at
`/etc/letsencrypt`; `./certs` holds two links to
`/etc/letsencrypt/live/mail.example.com/` — a path that exists inside the
container, not on the host — and the panel reads the key through group 10998,
container, not on the host — and the panel reads the key through group 10997,
as in the Apache case. Then start everything:
```sh
@@ -365,7 +384,7 @@ mkdir certs
ln -s /etc/letsencrypt/live/mail.example.com/fullchain.pem certs/fullchain.pem
ln -s /etc/letsencrypt/live/mail.example.com/privkey.pem certs/privkey.pem
sudo chmod 0755 nginx/certbot-etc/live nginx/certbot-etc/archive
sudo chgrp 10998 nginx/certbot-etc/archive/mail.example.com/privkey*.pem
sudo chgrp 10997 nginx/certbot-etc/archive/mail.example.com/privkey*.pem
sudo chmod 0640 nginx/certbot-etc/archive/mail.example.com/privkey*.pem
docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d
@@ -388,6 +407,12 @@ 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.
With Caddy the certificate lives in Caddy's own data, not in `certs/`, and
Caddy writes its keys readable by itself alone: the panel's full backup then
refuses with "certs/ holds no certificate". Back up `./caddy/caddy-data`
with the project directory instead, or keep a copy of the pair in `certs/`,
the key given to group 10997 (`chgrp 10997`, `chmod 640`), refreshed after
each renewal.
**Traefik (containerised).** Edit the `Host(...)` label and ACME email in
[deploy/traefik/docker-compose.traefik.yml](../deploy/traefik/docker-compose.traefik.yml),
@@ -422,7 +447,7 @@ changes.
|---|---|---|
| Overview | Overview | `/overview` |
| Outbound | Domains · Log · Queue · DMARC reports | `/outbound/domains`, `/outbound/log`, `/outbound/queue`, `/outbound/dmarc` |
| Inbound | Domains | `/inbound/domains` |
| Inbound | Domains · Log · Filter lists (global role) | `/inbound/domains`, `/inbound/log`, `/inbound/filter-lists` |
| Server | Health · System log · Backup · Users · Settings | `/server/health`, `/server/log`, `/server/backup`, `/server/users`, `/server/settings` |
| User menu (your name) | Account · Help · Sign out | `/account`, `/help`, `POST /logout` |
@@ -433,6 +458,11 @@ menu: a group with nothing in it is absent. *Inbound* appears only with
queue, answer 404 to a user with the *domain* role — the same answer a missing
page gets.
Two pages are outside the menu: `/license`, the AGPL-3.0 text the footer links
to, open to anyone; and `/server/components` (global role), the panel's
component kit - every building block of its pages on one page, a reference
for whoever works on the panel.
Pages that used to be one are now several. The old Status page is **Overview**
(one verdict per check) and **Server › Health** (the tables behind them). The
old Settings page is **Account** (your own name, e-mail and password) and
@@ -454,28 +484,21 @@ assumptions stay here in `docs/guide.md`.
### Overview and Health
**Overview** (`/overview`, global role) shows the stamp — the worst of its
checks — and one card per check: machine (processor and memory), supervised
checks — with a sentence beside it ("Mail is flowing. One thing needs
attention.", "Mail may not be flowing." when a check fails) and, under it,
each check that is not fine with its value; then one card per check: machine (processor and memory), supervised
processes (Postfix, the panel - whose roles include the DKIM signer - and
their helpers), TLS certificate, queue, milter sockets and reverse DNS. A
check the panel could not run shows as a warning, never as fine. Below the cards are the outbound domain list and, with the inbound relay
on, the inbound one. Each card leads to its table on **Server › Health**
(`/server/health`) — the queue card to the Outbound queue — and Health has the
readings, in the order of what stops mail first:
readings, numbered in the order of what stops mail first:
- **Machine** — processor (core and thread counts), memory and swap of the
host (a memory limit set on the container is not reflected), and
per-interface network throughput and totals of the container's own network
interfaces, read from the kernel's counters; CPU and throughput are measured between refreshes, so they appear
one refresh after the page opens. A fully busy processor or a machine out of
memory is a warning, because both delay or kill the mail path; throughput is
only reported.
- **Processes**, **TLS certificate** (validity and expiry, and whether its
names cover `SELFPOST_HOSTNAME` - the tag beside *Names*; a certificate for
another name fails, as a client that checks the name refuses it) and
**Milter sockets**: the DKIM signer's two - `dkim-signer · applications` for what
- **Processes** and **Milter sockets**: the DKIM signer's two - `dkim-signer · applications` for what
applications send on 465/587, `dkim-signer · notifications` for the
panel's own mail - the sender check, the send log (the journal-milter)
and, with one configured, the spam filter. Postfix attaches the signer
panel's own mail - the sender check, the send log (the journal-milter),
with the inbound relay on the Inbound log's (the inbound-journal-milter,
fail-open like the send log's), and, with one configured, the spam filter. Postfix attaches the signer
strictly, so either of its rows down means the mail that goes through it
waits.
- **Sender check** — what the strict check of the `From:` line has cost
@@ -493,12 +516,25 @@ readings, in the order of what stops mail first:
a connection was turned away or a message refused in the last 24 hours. If these numbers do not stay
at nothing, the panel is in the way of your mail more than it should be -
report it.
- **TLS certificate** — validity and expiry, and whether its names cover
`SELFPOST_HOSTNAME` (the tag beside *Names*); a certificate for another
name fails, as a client that checks the name refuses it.
- **Hostname and reverse DNS** — compares `SELFPOST_HOSTNAME` against the PTR
record the internet publishes for this server's IP (forward-confirmed
reverse DNS), see [Server-level DNS](#server-level-dns-ptrrdns); use
*Re-check DNS* after changing DNS. *Greeting* is the `220` line Postfix
*Re-check DNS* after changing DNS (twenty re-checks in 15 minutes per user;
past that the page shows the last result). *Greeting* is the `220` line Postfix
opens every connection with; it must name the hostname, and another name
there is a warning.
- **Notification mail** — whether the DNS records the panel's own mail is
signed and authorised with are in place (see [Notifications](#notifications)).
- **Machine** — processor (core and thread counts), memory and swap of the
host (a memory limit set on the container is not reflected), and
per-interface network throughput and totals of the container's own network
interfaces, read from the kernel's counters; CPU and throughput are measured between refreshes, so they appear
one refresh after the page opens. A fully busy processor or a machine out of
memory is a warning, because both delay or kill the mail path; throughput is
only reported.
- **Your address** (in the Configuration card) — the address the panel sees
your browser at, and whether it took it from `X-Forwarded-For`. Behind a
reverse proxy that the panel does not trust it is the proxy's own: every
@@ -509,7 +545,8 @@ readings, in the order of what stops mail first:
selector table (`/data/dkim/selectors`), the Postfix sender map, the
inbound relay maps and the filter lists (with `INBOUND_RELAY_ENABLE=true`),
the DMARC report maps (with `DMARC_REPORTS_ENABLE=true`) - removes the keys
of deleted domains, and has Postfix re-read them; the signer needs no
of deleted domains, and has Postfix re-read them (a save elsewhere that
changes no file asks no reload; this button always does); the signer needs no
reload, it reads the table for every message. This is
what the first start after a restore does (see [Restore](#restore)). Use it
if the daemons drifted from what the panel shows after manual edits under
@@ -555,14 +592,37 @@ readings, in the order of what stops mail first:
### Account
**Account** (`/account`, every role) is the signed-in user's own: **Profile**
(username and e-mail) and **Password**. Saving the profile does not ask for the
password; changing the password asks for the current one and is rate-limited
like a sign-in. The e-mail is the user's own address: it is where the panel
(username and e-mail) and **Password**. Changing the username or the
notifications switch does not ask for the password; changing the e-mail does,
and so does changing the password, both on the limit of a sign-in's guesses
(ten in 15 minutes per user). The e-mail is the user's own address: it is where the panel
sends [notifications](#notifications) while *Send me notifications* is ticked
beside it, and what a domain's *Use my e-mail* button fills in (see
[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc)). Application SASL logins
are separate and are not changed here.
**A confirmed e-mail and a forgotten password.** A new address - saved under
Account, or typed for a user by the global role on the user form - is sent a
link that confirms it (`https://<SELFPOST_HOSTNAME>/confirm/…`), which works
once, for 24 hours, opened in a browser signed in as that account; its page
has a button, and only the button confirms. The mail does not name the
account, so a mistyped address cannot be confirmed by whoever reads it.
Account tags the address *confirmed* or *not confirmed* and, while it is not,
offers *Send the link again* (at most five links an hour to one address).
Notifications go to the address either way. Only a confirmed address can be
sent a reset link: *Forgot your password?* on the sign-in page asks for the
username and always answers the same - whether the name exists, and whether
its address is confirmed, is not said; another link is not sent within ten
minutes of the last, and after five in a day the owner is mailed once that
further requests are ignored for 24 hours - and the link
(`https://<SELFPOST_HOSTNAME>/login/reset/…`) works once, for 30 minutes, and
only while the account's address is still the one it was sent to. Setting the
new password signs every session of the account out. The links name the
server by `SELFPOST_HOSTNAME`, so the panel must be served at that name. An
account without a confirmed address recovers as before: the global role sets
a new password on the user form, and the only global user from the container
(see [Users](#users)).
### Settings
**Server › Settings** (`/server/settings`, global role) holds what is true for
@@ -586,7 +646,10 @@ the `mail.log` lines on Server › System log and on a message's page read the
same clock as the rest of the page. The database keeps its times in UTC, so
changing the zone is a restart and nothing more; the days counted before it
stay days of the old zone, a shift of a few hours in the statistics. A name
the image does not know leaves everything in UTC, and the box says so.
the image does not know leaves everything in UTC, and the box says so. One
thing keeps UTC on purpose: the day a DMARC report covers is its reporter's,
who counts days in UTC, so the lists and a report's title name that day; the
report's page gives its window's times in this zone and says which day it is.
### Notifications
@@ -606,7 +669,9 @@ assigned to them. A message goes to the e-mail on the user's
[Account](#account) page and only while *Send me notifications* is ticked
there; a user without an e-mail gets none. **Server › Settings** has one switch
that turns notifications off for everyone. With it off nothing is checked, so
what is wrong when it is turned on again is reported then.
what is wrong when it is turned on again is reported then. The first check runs
three minutes after the panel starts, then every hour; the panel sends at
most 60 notifications an hour, a ceiling nothing should come near.
Notifications are the panel's own mail. They are sent from
`postmaster@<SELFPOST_HOSTNAME>`, one message per recipient, are not rows of
@@ -614,18 +679,24 @@ the Outbound log and count against no rate limit. They name the page of the
panel to open and carry no link: the panel does not know the address its
reverse proxy serves it under.
**Two DNS records make them arrive.** The server's own name is not one of your
**Three DNS records make them arrive.** The server's own name is not one of your
sending domains, so it needs records of its own, shown with the values to
publish under **Server › Settings › Notification mail**:
- a **DKIM** key for the hostname, which the panel generates and signs
notifications with (`<selector>._domainkey.<hostname>`, TXT);
- an **SPF** record for the hostname: `v=spf1 a -all` (TXT).
- an **SPF** record for the hostname: `v=spf1 a -all` (TXT);
- a **DMARC** record for the hostname: `v=DMARC1; p=reject` at
`_dmarc.<hostname>` (TXT). It has receivers refuse mail forged in the
server's name - a false "reset your password" message among it. Only the
panel sends as this name, signed with its key, from the address its SPF
names, so reject costs nothing; `p=quarantine` is accepted too, `p=none`
is shown as weak, since it refuses nothing.
**Server › Health** shows whether each is in place in the *Notification mail*
card. Without them notifications are still sent, and a receiver may file them
as spam or, if the parent domain publishes a strict DMARC policy, refuse them.
Mail of your sending domains depends on neither. If the hostname is itself one
Mail of your sending domains depends on none of them. If the hostname is itself one
of your sending domains, the DKIM record is that domain's and is already
published - and whoever administers that domain can then send mail that
looks like the panel's; a hostname that is nobody's sending domain is the
@@ -662,7 +733,8 @@ What the *domain* role reaches inside what is assigned:
| Overview, Server, the Outbound queue | yes | no (404) |
| Outbound: domain page, applications, Domain settings, log | every domain | assigned domains |
| Outbound › DMARC reports | every report, with the ingest statistics | reports of assigned domains, without the ingest box |
| Inbound: domain page, upstream, recipients | every domain | assigned inbound domains |
| Inbound: domain page, upstream, recipients, log | every domain | assigned inbound domains |
| Inbound › Filter lists | yes | no (404) |
| Add or delete a domain, either direction | yes | no |
| Export a domain (Domain settings) | yes | assigned domains |
| Import a domain (Server › Backup) | yes | no |
@@ -675,6 +747,12 @@ The panel refuses to remove or demote the **last** global user, so it can never
end up with none. A user cannot delete the account they are signed in with.
The user form also sets the user's e-mail, which the users list shows.
Adding a user, setting a user's password, changing their e-mail or role -
your own account's included - and deleting a user ask for **your account
password**, the one you sign in with, as a backup does and under the same
limit on wrong guesses: a session left open is not enough to hand out an
account. Saving only the name or the domain assignments is not asked.
### Sessions
A login survives a container restart: sessions live in SQLite, not in
@@ -702,6 +780,13 @@ published, then `docker compose up -d`. The backup version check requires the ru
to match the version that created a full backup — see [Fixed image
tag](#fixed-image-tag).
**Base-package fixes come with releases.** The image is Debian trixie, and a
security update of Postfix, Cyrus SASL, OpenSSL or Berkeley DB reaches you with
the next SelfPost release built after it: a published image tag is never
rebuilt. The project checks every week whether one of those has moved and cuts
a patch release when it has; a release whose notes say "base packages" is one
of those.
**To 2.8.0** there is nothing to do. From 2.8.0 the panel signs outgoing
mail itself, in a milter of its own, and OpenDKIM is no longer in the image;
the keys, the selectors and the DNS records stay as they are. The keys move
@@ -755,15 +840,18 @@ again whole and loses the keys of domains added under 2.8.x.
### Container health
The image declares a Docker `HEALTHCHECK` that probes `GET /healthz` on port
8080 (unauthenticated). It returns `200 ok` when the panel - whose roles
8080 (unauthenticated, and answered only to a request that did not come
through a reverse proxy - no `X-Forwarded-For` - from loopback or a private
address; anyone else gets `404`). It returns `200 ok` when the panel - whose roles
include the DKIM signer - and Postfix are both `RUNNING` under supervisord;
otherwise `503 unhealthy`. This
catches a dead mail path that would still leave the HTTP server up, but it
does **not** verify TLS certificates, DNS records, or end-to-end delivery —
use the authenticated [Overview and Health](#overview-and-health) pages for
that. External monitoring
can use the same endpoint through the reverse proxy if you expose it, or poll
`docker inspect` health state on the host.
that. External monitoring runs on the host - `curl -fsS
http://127.0.0.1:8080/healthz` - or polls `docker inspect` health state;
through the reverse proxy the endpoint is not found, so its answer tells a
stranger nothing.
### Server-level DNS (PTR/rDNS)
@@ -837,11 +925,13 @@ instance keeps running, what goes into the archive, and what happens on restore.
→ *Full backup*. *Encrypt with a password* is ticked; untick it for a plain
archive. Same archive as the CLI below. The panel runs as its own user, not as
root, so **the private key in `certs/` has to be readable by it**: give the
file to the container's `selfpost` group, which has the fixed id 10998 and holds
both the panel and Postfix —
file to the container's `backupkey` group, which has the fixed id 10997 and
holds the panel alone - not Postfix, whose daemons face the internet (until
`2.14.4` the guide said group 10998, `selfpost`, which Postfix is in; a key
given to it then is to be given to 10997 now) —
```sh
chgrp 10998 certs/privkey.pem && chmod 640 certs/privkey.pem
chgrp 10997 certs/privkey.pem && chmod 640 certs/privkey.pem
```
— and have whatever renews the certificate do the same; certbot does it by
@@ -918,7 +1008,7 @@ methods avoid it by stopping first.
| SQLite consistency | `VACUUM INTO` snapshot | Safe when stopped | Safe when stopped |
| `data/log/` | Excluded | Included (optional exclude) | Included (optional exclude) |
| Postfix queue | No | Yes | Yes |
| `certs/` private key | Yes (the panel needs it readable by group 10998; links are followed) | Yes when `certs/` holds the files; links are stored as links unless `tar -h` | No — deploy files separate |
| `certs/` private key | Yes (the panel needs it readable by group 10997; links are followed) | Yes when `certs/` holds the files; links are stored as links unless `tar -h` | No — deploy files separate |
| `data/manifest.json` | Written (version stamp) | Absent | Absent |
| Restore version check | Yes — must match image tag | No | No |
| Post-restore *Resync* | Yes, on first boot | No | No |
@@ -983,7 +1073,7 @@ 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 2.12.0 but this image is 2.13.0 — restore into the matching image (selfpost:2.12.0)
backup: this backup was created by SelfPost 2.14.5 but this image is 2.15.0 — restore into the matching image (selfpost:2.14.5)
```
Fix the tag in `docker-compose.yml` (build that tag as under [Fixed image
@@ -1088,7 +1178,10 @@ 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.
needs no password. An import is refused, and says why, for a DKIM key outside
1024-4096 bits, more than 500 applications, a domain named as the server
itself (its key signs the panel's own mail), and a report address on the
server's name other than the domain's own hosted one.
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:
@@ -1112,7 +1205,10 @@ The CLI also *writes* encrypted backups for scripted/cron use. The password
must be at least 12 characters, as on the panel's forms (decrypting takes
any), and it 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.
the process list. Its other flags: `-o <file>` writes the output to a file
instead of standard output, `-i <file>` reads the encrypted archive from a
file instead of standard input (with `-decrypt`), and `-version` prints the
version.
`-e` with the name only takes the value from your shell's environment, so it is
not written on the command line:
@@ -1141,7 +1237,21 @@ for such a sender.
**Panel** (`/inbound/domains`): the global role adds and deletes domains. On a
domain's page, set the upstream host/port and TLS to that hop (opportunistic /
required / off), and choose recipients. An upstream has one TLS mode: Postfix
required / verified / off), and choose recipients. *Required* is Postfix's `encrypt`
level: the hop is encrypted, but the upstream's certificate is not checked,
so it guards against someone who listens on the way, not against one who
answers in the upstream's place. *Verified* is Postfix's `secure` level: the
certificate must be issued by an authority Debian's `ca-certificates`
trusts, for the upstream's host name - so the upstream is given by name,
not by address (the panel refuses an address with it), and mail waits in
the queue while the check fails, a self-signed certificate or one for
another name included. To try an upstream before choosing it:
```sh
docker exec <container> posttls-finger -c -l secure -F /etc/ssl/certs/ca-certificates.crt "[mx.example.com]:25"
```
"Verified TLS connection established" means it will pass. An upstream has one TLS mode: Postfix
keeps the TLS policy per next hop, not per domain, so the mode saved for one
domain is the mode of every domain forwarded to the same host and port, and
they are changed with it; a *domain* user who does not manage all of them is
@@ -1207,6 +1317,13 @@ rereads the list files by itself and reads the key anew for every message. A
restart is needed only for a change of its own configuration - the files of
`antispam/rspamd/`.
The third file there, [worker-proxy.inc](../deploy/antispam/rspamd/worker-proxy.inc),
keeps rspamd's scanner and its controller - whose default password is public -
on the sidecar's own loopback and puts only the milter on the compose network;
the fragment runs rspamd with no Linux capability. Upgrading SelfPost to
`2.14.4` brings rspamd 4.2.2 with it: copy `antispam/` again, then
`docker compose ... up -d` recreates the sidecar.
The same directory holds
[multimap.conf](../deploy/antispam/rspamd/multimap.conf), the rules for the
**allow and deny lists** kept in the panel. SelfPost does not apply the lists
@@ -1256,7 +1373,8 @@ inbound mail is being refused with a temporary error (`tempfail`) or going throu
(`accept`).
**Inbound mail is not in the Outbound log.** Port 25 runs its own milter chain:
the optional anti-spam milter, and nothing else. The DKIM signer does not sign relayed
the optional anti-spam milter and the panel's inbound journal (Inbound › Log,
fail-open), not the signer, the sender check or the outbound journal. The DKIM signer does not sign relayed
mail (and a signing outage therefore cannot hold it up), and the journal-milter
does not file it — the [Outbound log](#outbound-log) stays a record of what
*you* sent.
@@ -1289,7 +1407,8 @@ settings) and nowhere else:
server whose reports are parsed and shown under *DMARC reports*. Offered only
while `DMARC_REPORTS_ENABLE=true`.
- **A specific address** — any mailbox, typed for this domain; it receives the
raw XML. Beside the field, *Use my e-mail* fills it with the e-mail of your
raw XML. Not one on `SELFPOST_HOSTNAME`: to have this server receive the
reports, choose *SelfPost hosted*. Beside the field, *Use my e-mail* fills it with the e-mail of your
profile (Account); it only fills the field, and saving is the form's own
button. The button is absent when your profile has no e-mail.
@@ -1297,8 +1416,8 @@ Changing the choice changes the DMARC record to publish; the domain page shows
the new value. There is no panel-wide or per-user default. Only addresses on
`SELFPOST_HOSTNAME` that a domain has chosen are accepted on port 25 - the
hosted address `dmarc-reports+<domain>@<hostname>` of a domain set to
*SelfPost hosted*, or a typed address that happens to be on this host. There
is no mailbox for the server as a whole.
*SelfPost hosted* - and a report is kept only when it is for the domain the
address is tagged with. There is no mailbox for the server as a whole.
**DNS.** Publish the usual `_dmarc` TXT on each sending domain with
`rua=mailto:…` pointing at the address the domain shows. Receivers deliver to the
@@ -1313,7 +1432,11 @@ sees the *Ingest* box — whether ingest works, the last report received, report
kept this week and parse failures. Open a domain's roll-up from the list or
from *DMARC reports* in the domain page's menu. A user with the *domain* role
sees the reports of the domains assigned to them, without the *Ingest* box.
Summaries are pruned (500 kept for each domain, 90 days max).
Summaries are pruned (500 kept for each domain, 90 days max). A report's
page is `/outbound/dmarc/reports/<id>` and a domain's roll-up
`/outbound/dmarc/domains/<id>`. A report's day - in the lists and its title -
is the reporter's, counted in UTC as reports count their days; its page shows
the window's times in the panel's zone, with a line naming that day.
A message that is not a report the relay keeps — unreadable as an aggregate
report, about a domain not sent from here, or delivered to another domain's
@@ -1436,12 +1559,13 @@ DMARC counts that signature only when its domain aligns with the domain of
signature as another of its domains, or as the server itself.
**Lines of more than 998 bytes.** The signer signs the message as the
application handed it over. Postfix's SMTP client breaks a body line longer
than 998 bytes when it delivers the message (`smtp_line_length_limit`),
application handed it over. Postfix's SMTP client breaks a line longer
than 998 bytes, in the body or in a header, when it delivers the message (`smtp_line_length_limit`),
after the signature was made, and the receiver's check of the signature
then fails. Such a message is still sent; the container log names its
domain and login (`dkim-signer: example.com (login "shop"): a body line
longer than 998 bytes - ...`). Mail libraries keep lines short with
longer than 998 bytes - ...`, or `a line of the Subject header is longer
than 998 bytes - ...`). Mail libraries keep lines short with
quoted-printable or base64; an application that writes its own body has to
as well. A message whose header section is over 1 MiB is refused,
`552 5.3.4`.
@@ -1521,9 +1645,10 @@ gateway, not its own address (see [Rate limiting — level
1](#rate-limiting--level-1-ip-backstop)).
It is **not an authentication boundary.** The SASL login itself still succeeds;
the check runs in the journal-milter, on the same fail-open path as level 2
(below), so if that milter does not answer the restriction is skipped rather
than enforced. Use it to narrow where an application may send from, not to contain a
the check runs in the journal-milter and is fail-open like level 2 (below):
if that milter does not answer, or its lookup hits a store error, the
restriction is skipped rather than enforced. A message whose client address
Postfix did not pass is refused, not let through. Use it to narrow where an application may send from, not to contain a
leaked password — for that, regenerate the password.
**Level 2 — application** — optional override of the domain limit for one
@@ -1544,8 +1669,9 @@ hours and on demand via **Recalculate now**.
**Level 2 is best-effort, not a guarantee.** It runs inside the
journal-milter and is deliberately fail-open: if the rate-limit lookup hits
a store error, or the connecting client's IP is not available to the
milter, level 2 is skipped and the message is accepted rather than held up.
a store error, level 2 is skipped and the message is accepted rather than
held up. The client address plays no part: a ceiling is on a domain or an
application, wherever its mail comes from.
The same applies if the journal-milter itself does not answer — Postfix is
configured to accept rather than refuse it when it cannot reach it. (Since
`2.4.0` a panel that is down altogether makes Postfix refuse mail with a
@@ -1556,9 +1682,9 @@ the only one of the two that does not depend on the panel process.
### Outbound log
**Outbound › Log** (`/outbound/log`) is a searchable send log with server-side
**Outbound › Log** (`/outbound/log`) is the send log, with server-side
filters by domain, application, status and day (from and to, days of the container's zone, as on
the Inbound log); the status *rejected* is the three kinds of refusal
the Inbound log); the status *rejected* is the four kinds of refusal
together, which the table tells apart by their tags. A user with the *domain*
role sees only the rows of their domains. The filters are kept by the pages,
the refresh and the way back from a message, and the log refreshes its rows
@@ -1575,19 +1701,20 @@ the row as a failure rather than show a delivery it cannot evidence, and
counts that it did. A
refused row says why in its tag: `rate-limited` (a [level-2 rate
limit](#rate-limiting--level-2-domain-and-application)), `wrong client` (the
application's client IP list) or `wrong From` (see [Who an application may
send as](#who-an-application-may-send-as)); a row refused before `2.4.0`
application's client IP list), `wrong From` or `wrong Sender` (see [Who an
application may send as](#who-an-application-may-send-as)); a row refused before `2.4.0`
says `rejected`. A refused message's page shows what Postfix wrote about the
refusal: for `wrong From` the lines of the queue id the message had, for the
other two the one `NOQUEUE` line; a row refused before `2.5.1` says the line
refusal: for `wrong From` and `wrong Sender` the lines of the queue id the
message had, for the other two the one `NOQUEUE` line; a row refused before `2.5.1` says the line
was not kept, and one the panel wrote from `mail.log` after a restart - it
had stopped before writing the refusal itself - says so;
*Details* opens that row's own page (`/outbound/log/{id}`). That page carries
the sending domain, the application it was submitted under, the Postfix
queue id and the journal id, beside the message's history — when it was
accepted and what Postfix later reported for the recipient. A `deferred`
or `bounced` row includes this Postfix's retry intervals (first delay,
backoff cap, queue lifetime), the same numbers the Outbound queue shows; users
row includes this Postfix's retry intervals (first delay, backoff cap, queue
lifetime), a `bounced` one the queue lifetime, the same numbers the Outbound
queue shows; users
with the *domain* role see them here because they cannot open the queue. Under
both sit the `mail.log` lines for its queue id: the connection to the
receiving server, the server's reply, and the status that reply was filed
@@ -1597,7 +1724,7 @@ the row is deleted by retention, and not while another row carries the same
queue id (Postfix reuses ids); a
message accepted before `2.2.0` shows them only while they are still in the
current file, and the page says so when there are none. Retention is set under **Server › Settings** (global
role); `SEND_LOG_RETENTION_DAYS` in `.env` is only the initial default until it
role; with the inbound relay on, the Inbound log's has a field of its own there); `SEND_LOG_RETENTION_DAYS` in `.env` is only the initial default until it
is changed there.
### Exporting and importing a single domain
+3 -2
View File
@@ -62,8 +62,9 @@ Explicitly excluded to prevent scope creep:
- Organisations / tenancy beyond the `global` and `domain` roles; managing
**multiple sending domains** under one global administrator is in scope (see
below)
- Inbound antispam/antivirus engines (rspamd, ClamAV, etc.) — SelfPost may
expose a milter hook; it does not ship or start a filter
- Inbound antispam/antivirus engines (rspamd, ClamAV, etc.) — the image
ships no filter, only a milter hook; `deploy/antispam` offers an optional
rspamd sidecar that the panel's filter lists feed
- A custom MTA — Postfix is used as-is
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only
+28 -2
View File
@@ -28,6 +28,9 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| ID | Topic | Status | Progress | Plan |
|---|---|---|---|---|
| security-audit-2026-10-17 | Follow-ups of the 2026-10-17 security audit: A2 (high), seven mediums, the lows | stages 0-6 shipped `2.14.4` (2026-10-17); stage H (the host, S1-S3, S5) the owner's | 27/29 | [security-audit-2026-10-17.md](security-audit-2026-10-17.md) |
| upstream-tls-verify | Inbound: offer "Verified" TLS to the upstream (Postfix `secure`, the upstream's name checked), beside Required, which checks no certificate (audit M7) | shipped `2.15.0` | — | — |
| server-dmarc-hint | Server › Settings lists a DMARC record for the server's own name beside its DKIM and SPF (audit S3, product side) | shipped `2.15.0` | — | — |
| inbound-upstream-result | Inbound › Log shows what the upstream answered: sent, deferred, bounced, with its last reply | shipped `2.10.0` | 7/7 | [plans/inbound-upstream-result.md](plans/inbound-upstream-result.md) |
| health-setup-checks | Server › Health also checks the certificate's name, Postfix's greeting and the trusted proxy (was preflight) | shipped `2.11.0` | — | — |
| test-email | Send a test message from the panel (was preflight) | shipped `2.12.0` | — | — |
@@ -37,7 +40,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| review-2026-08-followups | Remaining findings of the 2026-08-19 code review | closed 2026-10-14 (rows placed in [audit-2026-10-12](audit-2026-10-12.md)) | — | — |
| panel-notifications | E-mail notifications about important events | shipped `2.3.0` | 10/10 | [plans/panel-notifications.md](plans/panel-notifications.md) |
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | shipped `2.2.0` | 8/8 | [plans/delivery-log-storage.md](plans/delivery-log-storage.md) |
| password-reset | Password reset by e-mail | agreed (2026-10-13) | — | — |
| password-reset | Password reset by e-mail | shipped `2.14.0` | — | — |
| local-time | The panel shows time, and counts its days, in a time zone other than UTC | shipped `2.13.0` | — | — |
| notification-mail-recheck | The Notification mail records on Server › Settings can be checked again from where they are shown | shipped `2.9.0` | — | — |
| settings-save-row | Server › Settings: the Save button no longer stands alone under a long right column | shipped `2.9.0` | — | — |
@@ -65,7 +68,7 @@ stages 1 and 2 as `2.5.0` the same day, stage 2b as `2.5.1`, stage 2c as
`2.6.0` the same day; stage 6 (documents and tests, no cut) closed the
audit. Of the three rows from [own-mta-builds](#own-mta-builds),
**port25-bare-newline** shipped as `2.6.1` the same day and
**base-trixie** as `2.7.0`, and **panel-dkim-signing** as `2.8.0` (2026-10-16). **dns-record-collapse**, **settings-save-row** and **notification-mail-recheck** shipped together as `2.9.0` the same day, with `/data/opendkim` removed as panel-dkim-signing planned, then `2.9.1` (log panes on a phone) and **inbound-upstream-result** as `2.10.0`. What is agreed after it is marked so in the index; every other
**base-trixie** as `2.7.0`, and **panel-dkim-signing** as `2.8.0` (2026-10-16). **dns-record-collapse**, **settings-save-row** and **notification-mail-recheck** shipped together as `2.9.0` the same day, with `/data/opendkim` removed as panel-dkim-signing planned, then `2.9.1` (log panes on a phone) and **inbound-upstream-result** as `2.10.0`. The follow-ups of the [security audit of 2026-10-17](security-audit-2026-10-17.md) shipped together as `2.14.4`, stages 0-6 in one cut; its stage H is the owner's host. What is agreed after it is marked so in the index; every other
remaining item is a candidate. panel-docs shipped in
[CHANGELOG.md](../CHANGELOG.md) `[1.8.0]`; dmarc-reports in `[1.7.0]`
(its ingest path first ran in `2.0.0` and was reviewed on 2026-10-11, see
@@ -459,6 +462,22 @@ to come need it - they are not the owner and should not have to ask him for
a new password). Mockup-first; changing the account e-mail asks for the
current password in the same cut.
**Notifications to an unconfirmed address (owner, 2026-10-17):** "Без
подтверждения слать" - they go to it confirmed or not; only a reset link
needs it confirmed. Every address saved before the release is unconfirmed,
and cutting them off would have stopped every warning at once.
**Done (2026-10-17):** design 2f5f5b2, route guard b7bbb2c, panel guard
0774da2. Migration 0013 (`users.email_confirmed_at`, `user_links`). Links
are 256 bits, kept as SHA-256, confirmation 24 h and reset 30 min, taken
once (`DELETE … RETURNING`), built on `SELFPOST_HOSTNAME` (never `Host`);
the request answers the same for every name and mails after answering;
limits per client address and per account; a link's GET page has a button
(mail scanners open links). A reset ends every session. Changing the
e-mail on Account asks for the password. The global role's user form sends
a typed address its link. e2e `password_reset_by_e_mail` runs it through
the sink.
---
## local-time
@@ -545,6 +564,13 @@ panel). The year question settled as the mockups have it. `tzdata` is named
in the image. Tests run in UTC (a `TestMain` in each package that formats
or counts days) and in Moscow where the zone is the point.
**The one exception (owner, 2026-10-17):** "Показ времени отчёта по UTC
принимаем. Это не проблема. Пояснения добавь." The day a DMARC report
covers stays the reporter's, in UTC (RFC 7489 counts its date range in UTC):
the lists and a report's title name it, the report's page gives its window
in the panel's zone with a line naming that day, and Settings and Help say
it is the exception (2.14.2).
---
## phone-desktop-mode
+7 -5
View File
@@ -17,7 +17,7 @@ When you add or review a migration, update this file in the same change.
| Field | Value |
|---|---|
| Chain head | `user_version = 12` |
| Chain head | `user_version = 13` |
| Files | `0001_init.sql` (the 2.0 baseline), `0002_inbound_spam_log.sql`, `0003_send_log_lines.sql`, `0004_notifications.sql`, `0005_send_log_rejected_index.sql`, `0006_sender_check.sql`, `0007_delivery_log_days.sql`, `0008_tenant_boundary.sql`, `0009_refusal_line.sql`, `0010_send_log_indexes.sql`, `0011_notify_addressees.sql`, `0012_inbound_upstream.sql` |
| File mark | `PRAGMA application_id = 0x53503230` ("SP20") |
| Database file | `/data/selfpost.db` (bind mount) |
@@ -86,10 +86,11 @@ audit (`2.5.0`), `0009_refusal_line.sql` (`2.5.1`) and
| 6 | `0006_sender_check.sql` | 2.4.0 | DDL | `send_log.reason` and `send_log.header_from` (both default `''`: a row refused earlier keeps no reason), and `sender_check_days` |
| 7 | `0007_delivery_log_days.sql` | 2.5.0 | DDL | `delivery_log_days`: what the reconcile sweep and the log tailer did, a row per UTC day, kept a year, shown nowhere - the measurement that decides the sweep's fate (audit 2026-10-12, stage 1). `send_log.reason` gains the values `reconcile` and `reconcile-contradicted` without a schema change |
| 8 | `0008_tenant_boundary.sql` | 2.5.0 | DDL + data | `idx_applications_login_nocase`: an application login is unique without regard to case (audit 2026-10-12, H4). `send_log.domain_id` and `inbound_spam_log.inbound_domain_id` (`ON DELETE SET NULL`), each with an index, filled once by name from the registry as it was then: a row belongs to a domain by id, and a domain deleted and added again starts with an empty log (L12) |
| 9 | `0009_refusal_line.sql` | 2.5.1 | DDL | `send_log.refusal_line`: Postfix's line about a refusal at MAIL FROM (a level-2 limit, a client address off the list), which has no queue id; the log tailer puts it on the row; `send_log.recovered`, a refusal row the tailer wrote from `mail.log` because the panel stopped before writing it; `sender_check_days.messages` (messages deferred mid-message, counted apart from the connections turned away) and `sender_check_days.estimated` (a day whose downtime is an upper bound) (audit 2026-10-12, stage 2b) |
| 9 | `0009_refusal_line.sql` | 2.5.1 | DDL | `send_log.refusal_line`: Postfix's line about a refusal at MAIL FROM (a level-2 limit, a client address off the list), which has no queue id; the log tailer puts it on the row, found by `send_log.client_ip`, the client the refusal came from; `send_log.recovered`, a refusal row the tailer wrote from `mail.log` because the panel stopped before writing it; `sender_check_days.messages` (messages deferred mid-message, counted apart from the connections turned away) and `sender_check_days.estimated` (a day whose downtime is an upper bound) (audit 2026-10-12, stage 2b) |
| 10 | `0010_send_log_indexes.sql` | 2.5.3 | DDL | Four indexes of `send_log`, each for a question that walked the table or sorted a domain's log (audit 2026-10-12, M2, stage 3): `idx_send_log_open`, partial, the queued and deferred rows by time (the tailer's sweep every 5 min); `idx_send_log_domain_id` and `idx_send_log_app_login`, a page of one domain's or one application's log in id order; `idx_send_log_refusal_line`, partial, whether a refusal line is already on a row. `TestSendLogQueriesUseTheirIndexes` checks the plans of these and of the other by-time and by-scope statements the store runs |
| 11 | `0011_notify_addressees.sql` | 2.5.4 | DDL | `notify_addressees (key, address, told_at)`: who a subject's notification is owed to and who was handed it, so a copy that failed or was held back by the hourly ceiling goes out at a later check and a subject's end goes to those told (audit 2026-10-12, stage 4); deleted with its `notify_state` row |
| 12 | `0012_inbound_upstream.sql` | 2.10.0 | DDL | `inbound_spam_log` gains `queue_id`, `upstream_status` (`''`, `queued`, `held`, `sent`, `deferred`, `bounced`; CHECK), `upstream_relay`, `upstream_reply` and `upstream_at`, and the partial index `idx_inbound_spam_log_queue_id` on rows that have a queue id: what the upstream answered about mail accepted on port 25, put on the row by the log tailer (inbound-upstream-result). Rows from before have none |
| 13 | `0013_email_links.sql` | 2.14.0 | DDL | `users.email_confirmed_at` (`''` for every address saved before it: all unconfirmed) and `user_links`, the one-time links that confirm an address and reset a password, with `idx_user_links_user` (password-reset; owner, 2026-10-13 and 2026-10-17) |
**Kind:** *DDL* — schema only; *data* — `INSERT`/`UPDATE` that must stay correct
for operators upgrading from older 2.x images.
@@ -101,14 +102,15 @@ for operators upgrading from older 2.x images.
| Table | Role |
|-------|------|
| `settings` | Key/value settings of the instance — what is true whoever is signed in (send-log retention). Nothing that belongs to a user |
| `users` | Panel logins. `role` is `global` or `domain`; `email` is the user's own address; `notify` is the user's own switch for notification e-mails (v4); `all_domains` / `all_inbound_domains` widen a `domain` user to every domain of that direction |
| `users` | Panel logins. `role` is `global` or `domain`; `email` is the user's own address; `notify` is the user's own switch for notification e-mails (v4); `all_domains` / `all_inbound_domains` widen a `domain` user to every domain of that direction; `email_confirmed_at` is when `email` was confirmed, `''` while it is not, cleared when it changes (v13) |
| `user_links` | The one-time links of password-reset (v13): the SHA-256 of the token, the user, `purpose` (`confirm`, `reset`; CHECK), the address it was sent to, when, and until when; one live link per user and purpose |
| `sessions` | Panel login sessions (token hash, owning user by id and deleted with them, sliding idle expiry) |
| `domains` | Sending (outbound) domains. `dmarc_rua` is where aggregate reports go (`''` = no reports) |
| `user_domains` | Outbound domains assigned to a `domain` user |
| `applications` | SASL applications per domain, with the client-IP restriction (`auth_ip_restrict`, `auth_allowed_ips`) |
| `application_addresses` | Explicit From addresses (`list` mode) |
| `rate_limits` | Level-2 limits per domain / application, manual or auto |
| `send_log` | Delivery journal. `reason` says why a row was refused (`limit`, `client`, `from`) or that the sweep closed it (`reconcile`, `reconcile-contradicted`), `header_from` what the From: line of a message refused for it said (v6), `refusal_line`, `client_ip` and `recovered` (v9), and `domain_id` (v8: the row belongs to a domain by id, `ON DELETE SET NULL`) |
| `send_log` | Delivery journal. `reason` says why a row was refused (`limit`, `client`, `from`, and `sender` since 2.8.0) or that the sweep closed it (`reconcile`, `reconcile-contradicted`), `header_from` what the From: or Sender: line of a message refused for it said (v6), `refusal_line`, `client_ip` and `recovered` (v9), and `domain_id` (v8: the row belongs to a domain by id, `ON DELETE SET NULL`) |
| `logtail_state` | Log-tailer read position |
| `inbound_domains` | Inbound relay domains |
| `inbound_transports` | Upstream host / port / TLS per inbound domain |
@@ -116,7 +118,7 @@ for operators upgrading from older 2.x images.
| `user_inbound_domains` | Inbound domains assigned to a `domain` user — granted separately from the outbound ones |
| `dmarc_reports` | Parsed aggregate report summaries |
| `dmarc_report_records` | Per-source rows inside a report |
| `inbound_spam_log` | Inbound antispam journal: one row per recipient of a message offered on port 25, with the decision (`accept`, `reject`, `tempfail`, `quarantine`) and who made it (`rspamd`, `postfix`, `selfpost`). Metadata only. `inbound_domain_id` (v8) ties a row to its domain by id (v2) |
| `inbound_spam_log` | Inbound antispam journal: one row per recipient of a message offered on port 25, with the decision (`accept`, `reject`, `tempfail`, `quarantine`) and who made it (`rspamd`, `postfix`, `selfpost`). Metadata only (v2). `inbound_domain_id` (v8) ties a row to its domain by id; `queue_id` and `upstream_status`, `upstream_relay`, `upstream_reply`, `upstream_at` (v12) are what the upstream answered for an accepted message |
| `inbound_spam_list` | Allow / deny entries for the external filter (`address`, `domain`, `ip`), instance-wide; unique per list (v2) |
| `notify_state` | What the panel has already written to its users about, one row per subject (`dns:<domain>`, `cert`, `queue`, `limit:<domain>`), so that each thing is said once and a restart says nothing again (v4) |
| `sender_check_days` | What the sender check costs, a row per day of the container's zone (`TZ`) that has something to say: seconds the panel did not answer while Postfix ran, connections turned away (`deferred`), messages deferred mid-message (`messages`), and whether the day's downtime is an upper bound (`estimated`). Kept a year (v6, v9) |
+887
View File
@@ -0,0 +1,887 @@
# Security audit — 2026-10-17
**What this is.** A full security audit of SelfPost at `44bf09d` (release
`2.14.3`): the code tree, the image and its process model, the deployment
fragments, CI/CD and the supply chain, the dependencies, and the live
installation on `selfpost.mixfed.ru` (read-only, plus a handful of harmless
probes from outside). Nothing in the tree was changed; this file is the only
output. The code audit of 2026-10-12 ([audit-2026-10-12.md](audit-2026-10-12.md))
looked for defects and drift; this one looks for what an attacker gets.
**Method.** Four parallel readers, one per zone (authentication, sessions,
users and request security; the HTTP surface, templates, backup/export/import;
the mail path — Postfix configuration, the four milters, SASL, DKIM, inbound,
DMARC ingest, the log parsers; image, process model, filesystem, deployment,
CI, supply chain), each reading every file of its zone and its tests in full,
each holding the code to every sentence of [security.md](security.md) and
[architecture.md](architecture.md), each told to prove a finding (a throwaway
test, run and deleted; the Postfix or Cyrus source under `dev/upstream/`)
rather than guess. The coordinator re-read the code behind every finding of
medium or above, settled the claims that needed the running system on the
test server, and probed the public ports and the DNS from outside. Machine
checks: `go build`, `go vet`, `go mod verify`, `govulncheck`, a search of the
whole git history for key and token shapes. `staticcheck 2026.1` does not run
on the local Go 1.27 toolchain; CI runs it on 1.26.
**Machine results.** Build, vet, module verification: clean. Git history: no
private key or token outside test fixtures; `dev/` and the `.exe` files are
untracked and outside the build context. `govulncheck` on Go 1.27.0 lists
twelve standard-library advisories of 2026-10-08 (`GO-2026-6599` … `6617`:
`net/http`, HTTP/2, `net/textproto`, `crypto/tls`, `html/template`), all fixed
in Go 1.26.9 / 1.27.2 — the live `2.14.3` binary is built with go1.26.9 (the
image's `golang:1.26-trixie` tag floated onto it), so it is not affected; one
module advisory (`GO-2026-5932`, `x/crypto/openpgp`) is not reached by this
code. Debian security tracker, trixie, 2026-10-17: `postfix
3.10.13-0+deb13u1` nothing open; `cyrus-sasl2 2.1.28+dfsg1-9` has
`CVE-2026-107161` open (heap overflow in the DIGEST-MD5 *client* plugin; the
image serves `PLAIN LOGIN` only — not reachable); `supervisor 4.2.5-3` has
`CVE-2019-12105` open (needs the inet HTTP server, which the image does not
configure); `openssl 3.5.7-1~deb13u3` nothing open.
**Severity** as in the previous audit: **critical** — the security boundary
is crossed or mail/data lost today, by a stranger; **high** — a documented
guarantee is false in a way an attacker will use; **medium** — a tenant or
stranger gets something the design denies them, or a stated guard is not
there; **low** — unlikely condition, cost without a boundary, or hygiene with
a security flavour; **nit** — wording. Confidence is *proven* (a test, a
probe, or the running system), *likely* (unambiguous reading) or *possible*.
IDs keep the zone letter: **A** auth, **W** web, **M** mail, **I** image and
infrastructure, **S** the live server and its host, **X** cross-cutting.
**The short version.** No critical finding; nothing an unauthenticated
stranger can do crosses the relay's boundary: every port refuses relaying,
credentials travel only under TLS ≥ 1.2, the SASL identity cannot be chosen
beyond the stored account, the signer keys on the login and nothing in the
message, the tenant boundary holds on every HTTP route, there is no XSS,
injection or traversal path, and the backup/export files are sound. One high
(A2): a stolen `global` session is enough to take a full backup after all,
through the user form. Seven mediums, all about a tenant or a stranger being
able to affect *another* tenant's limits, logs or report pages (M1, M2, M3,
W1), the host's TLS key being readable by the `postfix` uid (I1), one gap in
the symlink guard (I2) and the shipped default under which all visitors share
one sign-in limit (A1). The live host itself has the two findings that matter
most today and are not product code (S1, S2 — recorded in `dev/`, not
published, with the commands that close them).
---
## Findings
### High
**A2. A stolen `global` session takes the server: the user form sets the
signed-in user's own password without the current one, so backup-reauth is
passed with a password the thief chose.** *Proven (by reading; three
handlers).* `POST /server/users/<own id>` with a `password` field
([handlers_users.go:237-337](../internal/web/handlers/handlers_users.go))
replaces the signed-in user's own hash with no current-password check and
keeps the current session (`endSessionsAfterPasswordSet`, :342-350, ends only
the *other* sessions when a user sets their own); `reauth`
([handlers_backup.go:336-357](../internal/web/handlers/handlers_backup.go))
compares the form's `account_password` with the *current* stored hash. Three
requests with a stolen global cookie: set a password, `POST /server/backup`
with it, download the DKIM keys, `sasldb2` and every password hash.
Alternatively `POST /server/users/new` with `role=global` creates a second
administrator with a known password, who survives the victim noticing and
changing their password (Account › password ends only the victim's other
sessions). security.md § Backup and domain export ("a stolen session cookie
or a panel left open no longer hands over a copy of the server") is false for
a `global` session; it holds for a `domain` session. Compare Account, where
changing one's own password or e-mail asks for the current one
(handlers_account.go:124-139, 229). **Fix:** `reauth` (already written) on
`POST /server/users/new`, on `POST /server/users/{uid}` whenever `password` or
`role` changes, and on `POST /server/users/{uid}/delete`; a doc line. ~2 h
with tests.
### Medium
**M1. The level-2 domain limit is keyed on the `MAIL FROM` domain before
Postfix has held it to the login, so a stranger on 465/587, or another
tenant's application, reserves a victim domain's slots.** *Proven (unit
tests by the reader; the ordering from Postfix source; the coordinator's
unauthenticated `MAIL FROM` on 587 answered `250 2.1.0 Ok`, which is the
milter's MAIL event having been sent).* `overLimit`
([ratelimit.go:28-62](../internal/milter/ratelimit.go)) takes
`domainOf(s.from)` whatever the login, and `MailFrom`
([milter.go:126-144](../internal/milter/milter.go)) runs it with an empty
login. With `smtpd_delay_reject` at its default, Postfix runs no sender or
client restriction at MAIL (`src/smtpd/smtpd.c:2767-2779`) and sends the
milter's MAIL event right away. A stranger opens TLS to 465, `EHLO`, `MAIL
FROM:<x@victim.example>`, no AUTH: a slot in `victim.example`'s window is
taken (`tryAdmit`), RCPT is refused, the transaction stays open and the slot
is held for the 10-minute in-flight TTL; the next `MAIL FROM` on the same
connection re-reserves. Under `smtpd_client_message_rate_limit=100/h` one
address holds ~16 slots indefinitely, 50 for the first ten minutes; a domain
with a ceiling of 50/h is blocked for ten minutes at a time by one address,
its own application is answered `451`, a `limit` row is written under the
victim's domain and the victim's users get the "a rate limit is refusing mail"
notification. An application of tenant A does the same against tenant B with
`MAIL FROM:<x@tenantB>`; Postfix refuses it at RCPT (553), too late.
**Fix:** take the domain from the login alone (`ApplicationByLogin(s.login).DomainName`,
the call already made for the null sender) and return at once when
`s.login == ""` — an unauthenticated session can never deliver on 465/587;
same for `recordRejected`'s `Domain`. ~1-2 h with tests. (The alternative,
`smtpd_delay_reject=no` on the submission services, moves the client
restrictions to MAIL as well and closes the stranger's case at the Postfix
level, at the price of the delayed-reject logging; the milter fix is the one
to make either way.)
**M2. The inbound journal takes a stranger's recipient for Postfix's own
log tail: a stranger writes rows with a chosen sender and recipient under any
inbound domain.** *Proven on the live server.* `inboundRejectRe`
([logtail/inbound.go:112](../internal/logtail/inbound.go)) ends the reply at
the *first* `; from=<`. Postfix's reject reply quotes the refused recipient in
*internal* form — quotes removed, `; < > =` kept (`extract_addr` →
`tok822_internalize`; `smtpd_check_reject` applies only `printable()`). Sent
from outside to port 25: `RCPT TO:<"; from=<ceo@example.net>
to=<board@example.net> proto=ESMTP helo=<x>; "@nonrelay.example>`; mail.log
on the server: `… 554 5.7.1 <; from=<ceo@example.net> to=<board@example.net>
proto=ESMTP helo=<x>; @nonrelay.example>: Relay access denied; from=<probe@example.net>
to=<"; from=…"@nonrelay.example> proto=ESMTP helo=<audit.example>`. The
regex's reply group stops at the forged `; from=<`, so the row's sender,
recipient and inbound domain are the stranger's choice (the reader's test fed
this line shape to `inboundJournal.handle` and got one row,
`domain=victim.example from=ceo@… to=board@…`); the quote/bracket/space filter
on `from`/`to` passes because the forged values are clean. The probe above
used a domain that is not inbound, so no row was written on the test server.
security.md § Mail path ("the reply now ends at Postfix's own `; from=<`") is
false. **Fix:** Postfix neuters ` <>()\";@` in a HELO name
(`smtpd.c:1634`), so Postfix's own tail is always the *last* `; from=<…>
to=<…> proto=\S+( helo=<[^<>;]*>)?( sasl_…)*$` on the line: make the reply
group greedy and anchor the tail; keep the filter; add the line above as a
test. ~1 h. (`fromRefusalRe` and `refusalLineRe` are not affected: the
sender check's reply carries no `;` and the journal milter's reply is fixed.)
**M3. The inbound journal milter neither de-duplicates recipients nor
counts its rows toward the 10 000-row cap: one accepted message writes up to
1 000 rows, uncut for up to six hours.** *Proven (unit test by the reader;
code confirmed).* `inboundSession.RcptTo`
([milter/inbound.go:86-89](../internal/milter/inbound.go)) appends every RCPT
(the outbound session de-duplicates, milter.go:159-168) and the end of the
message inserts one row per recipient at an inbound domain (:218-232),
synchronously inside the milter callback on the panel's one SQLite
connection; the cap's trigger counts only the tailer's rows
([logtail/inbound.go:274-280](../internal/logtail/inbound.go)), the six-hourly
prune is the rest. Postfix accepts a repeated recipient up to
`smtpd_recipient_limit` (1 000) and sends a RCPT event for each. In `list`
mode the stranger repeats one listed address; at the default
`INBOUND_RATE_LIMIT_MESSAGES_PER_IP=20/h` that is 20 000 rows an hour per
address, and a slow pass past the 30 s content timeout fails open.
security.md's "cut to 10 000 rows every 500 rows the tailer writes" is true as
written and does not cover this path. **Fix:** de-duplicate recipients
(case-folded) as the outbound session does; count milter inserts into the
prune trigger (a counter in `InsertInboundSpam`, or the milter calls
`PruneInboundSpam(time.Time{}, cap)` every N inserts); one transaction per
message. ~2 h.
**W1. A tenant's custom DMARC report address may be on the server's own
hostname, which opens an untagged ingest mailbox, or another tenant's, to the
whole internet.** *Proven (unit test by the reader; code confirmed).*
`HandleDomainDMARC` ([handlers_dmarc.go:37-47](../internal/web/handlers/handlers_dmarc.go))
accepts any address `validate.Email` takes; `AllowedRecipients`
([dmarc/service.go:58-66](../internal/dmarc/service.go)) keeps every stored
address whose domain is the hostname, not only `HostedReportAddress(host,
d.Name)`, and it goes into `dmarc_recipients` / `dmarc_transport`;
`checkReportDomain` ([ingest.go:122](../internal/dmarc/ingest.go)) cross-checks
the claimed domain only against a `dmarc-reports+<tag>` local part. A
`domain` user of tenant A sets `ops@<hostname>` as the report address of an
assigned domain: from then on anyone can mail a report claiming
`<policy_published><domain>tenant-b</domain>` to that address and it is
stored and shown under B's DMARC pages, and counts toward B's 500-report cap.
Variant: A stores `dmarc-reports+tenant-b@<hostname>` and B's hosted mailbox
is open without B choosing "SelfPost hosted". The accepted-risk sentence
("What a stranger can do is limited to a domain that chose a SelfPost-hosted
address, through that address") is false while this is accepted; the
existing `TestAllowedRecipientsFollowTheDomainAddresses` asserts
`ops@<host>` *is* allowed, so this was a choice, not a slip. **Fix:** refuse
a custom address whose domain is the hostname ("use the SelfPost hosted
choice"); keep only the exact hosted address per domain in
`AllowedRecipients`; require a tag for every recipient on the hostname in
`checkReportDomain`; fix the test and the accepted-risk text. ~1.5 h.
**I1. The host's TLS private key, given to group 10998 as the guide
instructs, is readable by the `postfix` uid — every network-facing Postfix
daemon.** *Proven on the live server* (`docker exec -u postfix … head
privkey.pem` prints the key). [Dockerfile:126-128](../build/Dockerfile) puts
`postfix` in `selfpost` (gid 10998) so it can read `sasldb2` and the maps;
[guide.md](guide.md) § Taking a full backup and § Reverse proxy tell the
operator `chgrp 10998 certs/privkey.pem && chmod 640` so the panel can
archive it, and [handlers_backup.go:398-399](../internal/web/handlers/handlers_backup.go)
says the same when it is missing;
[deploy/traefik/extract-cert.sh](../deploy/traefik/extract-cert.sh) keeps that
group on renewal. [postfix-config.sh:92-114](../build/postfix-config.sh) and
[postfix-cert-reload.sh](../build/postfix-cert-reload.sh) copy the key to
`/etc/postfix/tls-internal` as root 0600 *because* Postfix's privilege
separation exists so that a compromised unprivileged daemon cannot read it —
and the original, mounted at `/etc/postfix/tls`, is readable by that uid
through the backup group. A memory-safety bug in `smtpd` reached from the
internet yields the certificate's key, which is the panel's and the mail's
(one certificate). The DKIM keys (0700 `panel`) and the database (0600) are
not exposed this way; `sasldb2` is, by design. **Fix:** a third group for the
backup key alone (`groupadd --system --gid 10997 …; usermod -aG … panel`),
the guide, the handler text and the e2e stage (`stage.go:119-127` chmods the
key 0644 today) follow; `extract-cert.sh` copies the group by reference and
needs no change. ~2 h.
**I2. `drop_links` leaves out the three DMARC maps; root's `: >` in
`postfix-config.sh` follows a dangling link the panel can plant.** *Proven
mechanism; needs code already running as `panel`.* (Found independently by
the mail and the infrastructure reader as M4/I2.)
[entrypoint.sh:89-94](../build/entrypoint.sh) lists the five outbound maps,
`sasldb2`, the key tree and `mail.log`, not `/data/postfix/dmarc_recipients`,
`dmarc_transport`, `dmarc_relay_domains`;
[postfix-config.sh:545-547](../build/postfix-config.sh) runs `[ -e "$f" ] ||
: > "$f"` as root for exactly those when `DMARC_REPORTS_ENABLE=true`
(`-e` is false for a dangling link, the redirect follows it). `/data/postfix`
is the panel's (2750); the panel is in group `supervisor` and
`supervisor.shutdown` is on the XML-RPC interface it already speaks; Docker's
`restart: unless-stopped` re-runs the entrypoint as root, which creates an
empty root-owned 0644 file at any directory that exists. Inside this image
that is close to nothing (no `sudoers.d`, no `conf.d` include, logrotate runs
one named config), but security.md § Mail path ("A root step of the
entrypoint follows no symbolic link the panel could plant in the tree") is
the guard this was meant to be, and entrypoint.sh:165 records the same maps
once forgotten in the `chown` list. **Fix:** the three names in
`drop_links`; belt and braces `[ -e "$f" ] || [ -L "$f" ] || : > "$f"` at
postfix-config.sh:538 and :545; an e2e case that plants a link and expects
the "removing the symbolic link" line. ~0.5 h.
**A1. Under the shipped deployment every visitor shares one sign-in, setup,
reset and link-page bucket.** *Proven (test by the reader: two requests from
`172.18.0.1` with different `X-Forwarded-For` keyed `172.18.0.1`).* The
default is Apache on the host proxying to `127.0.0.1:8080`; the panel sees
every connection from the Docker gateway and `TRUSTED_PROXY_CIDR` is empty
by default ([main.go:168](../cmd/panel/main.go), `.env.example` commented
out, guide.md:254 "Leave unset unless…"), so XFF is ignored
([auth.go:128-146](../internal/web/auth/auth.go)). Anyone POSTs `/login` ten
times per 15 minutes (any body — the limiter is consumed before the form is
read, handlers.go:105) and every administrator gets "Too many attempts" for
as long as it goes on, at one request per 90 s; the limiter also counts
*successful* sign-ins. The same for `/login/reset` (nobody can ask for a
link), the link pages and `/setup/<token>` during first run. Only a restart
clears the buckets. The guide and Health's "Your address" card say this is
so; security.md § Authentication ("per IP") and § First-run setup ("per IP,
separate from login") state it as a property, and it is not under Accepted
risks. On the test server `TRUSTED_PROXY_CIDR=172.18.0.1` matches the
gateway, so the live install is not affected. **Fix:** (a) the shipped
compose and guide set the trust explicitly for the Apache-on-host scenario,
and the panel warns at start — not only on Health — when every request
arrives from one private peer that carries XFF; (b) key the login limiter
by *username* as well, with a per-user delay, so one key cannot lock
everyone out, and the link limiters by token hash; (c) do not count
successful sign-ins. ~3-4 h for (b)+(c), 1 h for (a).
### Low
**S1, S2 — the test server's host, not the product.** Two findings about
the host's own SSH policy and update state are recorded in
`dev/security-audit-2026-10-17-host.md`, which is not published (the
repository is public; a list of a host's open doors does not belong in it
until they are closed). Both are the owner's own actions, together under an
hour, no product code. They are listed low *in the product's scale*, and
S1's worst case is a root shell on the machine that holds `/data`, the TLS
keys, Carrel and Wynd — so they are **first in the plan**.
**S3 — host and DNS. The server's own name has no DMARC record.** The panel
sends notifications and password-reset links as `<something>@selfpost.mixfed.ru`,
SPF is `v=spf1 a -all` and the panel signs with the server's key, but with
no `_dmarc.selfpost.mixfed.ru` a receiver enforces neither, so a forged
"reset your SelfPost password" mail from the server's own name is as
deliverable as a real one. No MTA-STS, no TLSRPT, no CAA on `mixfed.ru`
either (the latter two are optional; CAA is cheap). **Fix:** `_dmarc TXT
"v=DMARC1; p=reject; rua=mailto:…"` for the server's name (the panel's
Settings page already shows the DKIM record); CAA `0 issue "letsencrypt.org"`;
15 min in DNS. Product side: the kit page could list the DMARC line for the
hostname next to the DKIM one (30 min).
**S4 — deployment. The antispam sidecar runs rspamd 3.11.1 (December 2024)
with default capabilities; upstream is 4.2.2 (2026-10-10) and the line
between fixed a heap overflow in `util.lower_utf8`, use-after-free on
reload and several parser crashes.** rspamd reads every byte a stranger sends
to port 25 before the journal does, and
[docker-compose.antispam.yml](../deploy/antispam/docker-compose.antispam.yml)
pins `rspamd/rspamd:3.11.1-3` with no `cap_drop`, no `no-new-privileges`, no
`pids_limit`. The milter is fail-open, so a crash costs filtering, not mail.
**Fix:** bump the pin to `4.2.x` after one e2e run (4.0 changed the WebUI and
DKIM pieces, neither used here; the `milter_headers`/`multimap` configs need
reading against the 4.x docs), add `cap_drop: [ALL]`, `security_opt:
[no-new-privileges:true]`, `pids_limit`; 1-2 h.
**S5 — host hygiene.** Three small items (a resolver service listening
where nothing needs it, uncapped container logs, a stale copy of the compose
file), in the `dev/` file with S1 and S2; 10 min.
**S6 — Postfix default. `ETRN` is accepted from anyone on port 25.**
*Proven from outside:* `ETRN <an inbound domain of the server>` → `250
Queuing started`.
`smtpd_etrn_restrictions` is empty (Postfix's default allows every client)
and `fast_flush_domains = $relay_domains`, so a stranger triggers a queue
flush toward an inbound domain's upstream at will — a mild lever to hammer an
upstream with a large deferred queue, nothing more. **Fix:**
`smtpd_etrn_restrictions = reject` (and `smtpd_discard_ehlo_keywords = etrn`)
in postfix-config.sh; 10 min.
**A3. Ten reset mails an hour to one mailbox from anyone who knows a
username.** `sendReset` ([links.go:154-191](../internal/web/auth/links.go))
spaces reset mails 10 minutes apart and caps them at 5/h per account; a
stranger who repeats a username keeps the administrator's confirmed mailbox
receiving a live 30-minute link five times an hour, indefinitely. No secret
leaks; nuisance, and a standing live link in a mailbox. **Fix:** after N
unsolicited links a day, one mail that says further requests are ignored for
24 h; or require username *and* confirmed address on the form. ~1-2 h.
**A4. The duplicate-cookie log line prints a link's token (unprefixed cookie
only).** *Proven by test.* [auth/handlers.go:56-58](../internal/web/auth/handlers.go)
logs `r.URL.Path` raw, so `GET /confirm/<token>` with two `selfpost_session`
cookies writes the token to the log; reachable only with
`PANEL_COOKIE_SECURE=false`, where a sibling host of the parent domain can
plant a cookie of that name (`__Host-` cannot be duplicated).
security.md:179 says a rejected request's log line hides a link's token.
**Fix:** log through `loggedPath`; 15 min.
**A5 / W2. DMARC reports belong to a domain by *name*, outlive the domain,
and a domain deleted and added again hands its old reports to the new
assignee.** [handlers_dmarc_reports.go:176-188](../internal/web/handlers/handlers_dmarc_reports.go)
(`cand.Name == rep.Domain`), [store/dmarc.go:194-204](../internal/store/dmarc.go)
(`WHERE domain IN (names)`), `DeleteDomain` leaves `dmarc_reports` alone (no
FK). The logs were moved to ids in 2.5.0 for exactly this case (security.md
§ Roles); reports were not. Aggregate statistics about a name the new tenant
now owns, so low. **Fix:** delete `dmarc_reports WHERE domain = ?` in
`domain.Service.Delete` (simplest, 0.5 h), or `domain_id` on the table and
scope by it as `send_log` does (2 h with the migration).
**A8. No log line for a refused sign-in or a lockout.** `submitLogin`
([auth/handlers.go:104-132](../internal/web/auth/handlers.go)) logs nothing
on 401 or 429; a brute-force, or A1 happening, is visible only in the proxy's
access log. **Fix:** one line per refusal, rate-limited per key; 30 min.
**A9. IPv6 clients are keyed per /128.** `ip.String()` at auth.go:138; a
client with a /64 rotates addresses and never meets the 10/15 min limit;
the bucket map is bounded (4096 × 4) so memory is safe, but blocked buckets
are evicted under that pressure, which lets the attacker flush its own
blocks. bcrypt cost 12 remains the real brake. **Fix:** key IPv6 by /64;
20 min.
**W3. The sender-map rebuild is not serialised.** `Resync`
([app/service.go:229-239](../internal/app/service.go)) reads bindings and
writes with no lock (the selector table has `tableMu`); two concurrent
application changes can leave the map stale until the next change — an
application whose binding is missing is refused with
`reject_sender_login_mismatch` until then. Availability only. **Fix:** a
mutex around read + write + reload; 0.5 h.
**W4 / W5 / W8. Import bounds.** The imported DKIM key size is unbounded
upward ([keys.go:129-146](../internal/domain/keys.go); a 16 384-bit key makes
every signature of that domain cost seconds); the number of applications in
an export is unbounded (each is a full copy of `sasldb2` plus a
`saslpasswd2` spawn — a 1 MiB export with ~5 000 applications is minutes of
quadratic work on the panel's one connection, then a rollback of the same
size); importing a domain named as the hostname silently replaces the
server's own signing key while DNS still holds the old record. All
global-only, self-inflicted. **Fix:** refuse keys outside 1024-4096 bits;
cap applications at 500 in `checkDomainImport`; refuse (or warn on) an import
named as the hostname; 1.5 h together.
**W6 / M9. A `domain` user can trigger `postfix reload` and uncached DNS
lookups without limit.** Every application, upstream, recipient or
report-address save reloads Postfix ([postfix.go:67-76](../internal/postfix/postfix.go)
→ supervisord `postfix-reload`), and `POST …/dns-recheck` bypasses the
5-minute cache for up to ~14 resolver queries. Authenticated operators only,
bounded by the HTTP server. **Fix:** coalesce reloads (debounce, or reload
only when the rendered file changed) and a small per-user limiter on the
recheck; 1-2 h.
**M5. The sender check keeps every `From:`/`Sender:` value of a message.**
*Proven (100 headers of 60 000 bytes → 6 MB held).*
[sender.go:310-323](../internal/milter/sender.go) appends every value; Postfix
bounds one header at 60 000 bytes but not their number, and the signer's
1 MiB cap runs after the sender check. Authenticated applications only, ~10
MiB per connection × 50 connections, and the message is refused anyway;
security.md:810 "a few headers each" is overstated. **Fix:** keep at most two
values of each (`judge` needs only none/one/more); 20 min.
**M7. The upstream TLS mode `encrypt` is unauthenticated TLS.**
[postfix/inbound.go:84-88](../internal/postfix/inbound.go) writes `encrypt`;
Postfix verifies no certificate at that level, so a `domain` user who chooses
it gets privacy against passive observers only. The docs promise nothing
more. **Fix:** say "no certificate check" in the guide (10 min); optionally
offer `secure` with the upstream's name (hours).
**I3. No `pids_limit`; the kept capabilities stay with root for the
container's life.** [docker-compose.yml:141-153](../deploy/docker-compose.yml):
`cap_drop: ALL`, eight `cap_add` (each justified in the file; the live
`CapEff 0x4fb` matches), `no-new-privileges`, no `read_only` (explained), no
`pids_limit`, no memory limit (an accepted risk). A fork bomb from a
compromised `panel` or `postfix` has the host's pid space as its limit. The
"root runs very briefly" comment describes the entrypoint only: supervisord,
Postfix's `master`, `cert-reload` and `logrotate` are root throughout, which
is inherent. **Fix:** `pids_limit: 512`; a word in the comment; 10 min plus
an e2e run.
**I4. Supply chain: mutable references everywhere the image is built from;
no vulnerability scan in CI.** Actions by major tag, not SHA
(`checkout@v4`, `setup-go@v5`, `build-push-action@v6`, …); `golang:1.26-trixie`
and `debian:trixie-slim` floating; apt unpinned (right for security, but two
builds of one tag differ); `provenance: false`, no SBOM; `certbot/certbot:latest`,
`caddy:2` in the fragments; no `govulncheck` step, so the twelve stdlib
advisories above would have gone unnoticed had the base tag not moved. What
is sound: `permissions` set, `pull_request` not `_target`, release only from
a published release or a dispatch, e2e-gated push of the same bytes, safe
GHCR cleanup, `go.sum` verified, the `replace` to `third_party/go-milter` is
upstream v0.4.1 byte for byte plus the one documented change. **Fix:** pin
actions by SHA with the version in a comment (30 min); `govulncheck ./...` in
`test.yml` and before the push in `release.yml` (20 min); pin `certbot` and
`caddy` (10 min); `provenance`/`sbom` only after reading the GHCR cleanup loop
(attestations appear as untagged versions) (1 h).
**I5. No rebuild cadence between releases.** A DSA for postfix, openssl or
libsasl2 after a release reaches operators only with the next release build,
and published tags are never rebuilt (the owner's rule). A policy, not a bug;
it is not written down. **Fix:** a sentence in guide § Upgrading and in
SECURITY.md; optionally a weekly job that compares the image's package list
against trixie-security and fails when postfix/libsasl2/openssl/libdb moved,
as a nudge to cut a patch release; 10 min / 1 h.
**I6. `/healthz` is public through every proxy fragment.** `web.go:212`
registers it outside auth; the live server answers `ok` to the internet. No
secret, but "unhealthy: postfix: error" is a free tell. **Fix:** answer
`/healthz` only to loopback peers (and `TRUSTED_PROXY_CIDR` peers), or a
`<Location /healthz> Require ip 127.0.0.1</Location>` line in the fragments;
30 min.
**X1. SECURITY.md's supported-versions table names the `1.x` line while the
product is at `2.14`.** An operator reading the policy would conclude that
`2.x` is unsupported and `1.x` gets fixes, the reverse of the truth. 10 min.
### Nits
- **A6.** A wrong method on `/server/*`, `/overview`, `/outbound/queue`
answers `405 Allow: …` from the mux before `globalOnly`, not the "same 404"
the doc promises. Routes are public source; 30 min to rewrite the 405 for a
non-global principal, or drop the sentence.
- **A7.** The setup token is re-minted on the next `/setup/*` hit, not at
expiry as security.md says; a stranger's `GET /setup/x` mints and logs one
(the only GET with a side effect besides W7). 30 min or wording.
- **A10.** Session renewal math drifts after `PANEL_SESSION_IDLE_DAYS` is
lowered (sessions keep the old window until their old expiry). 30 min.
- **W7.** `GET /server/settings` and `GET /server/health` generate the
server's DKIM key on first view — "a GET never changes state" has this
exception. Wording, or move the bootstrap to start.
- **M6.** `Content-Type`, `MIME-Version`, `Message-ID` are not in `h=` (same
as OpenDKIM's default; a relay can change a boundary and the signature still
verifies). One line to add them, with `Message-ID` *not* over-signed
(cleanup adds a missing one after the milters).
- **M8.** A header line over 998 bytes is broken by Postfix's `smtp` client
after signing and the signature fails silently; only body lines are logged
today. Log it per message in `Header`.
- **README quick start** runs `docker run` with default capabilities and no
`no-new-privileges` — it is the local trial and says so; a one-line note
that the compose file is the hardened one would not hurt.
- The Apache vhost on the test server sets `Referrer-Policy: no-referrer`,
the panel `same-origin`; both headers are sent and browsers take the
panel's (last) one. Drop the Apache lines the panel already sends; the
shipped `deploy/apache/selfpost-vhost.conf` is correct as it is.
### Doc and comment drift (no security effect once the findings above are fixed)
- security.md § Authentication: "a newer link of the same kind replaces the
older" (true for confirmation links only; the reset sentence later in the
section and the code say otherwise); migration `0013` has the same line.
- security.md § Backup and domain export: backup-reauth holds for a `domain`
session, not a `global` one (A2).
- security.md § Mail path: `drop_links` (I2); "the reply ends at Postfix's
own `; from=<`" (M2); "cut to 10 000 rows every 500 rows the tailer
writes" (M3); "a few headers each" (M5); "What the client wrote reaches the
reply … only as printable ASCII" holds for the From/Sender line, while a
refused row's `from_addr`/`to_addr`/`subject` are the envelope and decoded
Subject as written (html/template escapes them) — wording.
- security.md § Output and process: "(queue, log, journal, themes)" — no
themes; "keeps `KILL`" reads as "only KILL" — list the eight.
- security.md § Accepted risks, DMARC: the stranger's reach sentence (W1);
"cross-checked against the +tag" holds for tagged addresses only.
- security.md § Roles: the by-id rule is stated for logs; reports are by
name (A5/W2).
- security.md § State-changing requests: GETs that write (A7, W7).
- architecture.md: "the error text never reaches the browser" omits the
Backup-page exception security.md records; "an existing [key] is reused,
never overwritten" — `ImportKey` overwrites by design (W8); "a browser that
still holds the cookie works after a full backup restore" agrees with the
code and is noted only because sessions and links are treated differently
on purpose.
- Comments: `store/applications.go:32-33`, `app/password.go:16-17` say
`sasldb2` holds a hash (plaintext, as security.md says);
`sender.go:205-209` says Postfix folds table keys as Unicode — `texthash`
is `dict_ht` with ASCII `lowercase()` (`dict_thash.c:113`,
`dict_ht.c:58-64`), the safe direction; `supervisor/rpc.go:17-18` names the
socket's group `selfpost` (it is `supervisor`); `postfix-cert-reload.sh:46`
cites "OpenSSL 3.0.22" (trixie ships 3.5.x); `links.go:152-153` "three an
hour" (five); `docker-compose.yml:102-104` "very briefly" (I3).
- guide.md: the `chgrp 10998` advice should say what it gives Postfix (I1,
until the third group lands); nothing says how the panel reads Caddy's key
for a full backup; the upstream TLS mode should say "no certificate check"
(M7).
- The live compose on the test server is an older copy of the repository's:
comments differ and the `INBOUND_SPAM_LOG_RETENTION_DAYS` line is missing
(the default applies). Not a finding; re-copy at the next rollout.
---
## Checked and sound
Each claim below was held to the line that makes it true; the readers' full
lists with line references are condensed here.
**Boundary from the internet.** Not an open relay on any port (every
restriction list walked as Postfix evaluates it; confirmed from outside:
`Relay access denied` on 25 for a foreign and for a non-inbound domain,
`Client host rejected` for an unauthenticated RCPT on 587, `User unknown in
relay recipient table` for an unlisted address — no backscatter).
`mynetworks` is loopback and never referenced by a `permit_mynetworks`;
`mydestination` empty, local delivery an `error:` transport;
`allow_untrusted_routing=no`; `disable_vrfy_command=yes` (confirmed). SASL
only on 465/587, `PLAIN LOGIN` after TLS only (`smtpd_tls_auth_only`, 465
wrapper mode, 587 `encrypt`; confirmed from outside: no `AUTH` before
STARTTLS, TLS 1.1 refused, TLS 1.3 negotiated, certificate valid). Cyrus's
default proxy policy forbids a foreign authzid, no realm is appended, sasldb
keys are exact bytes (`lib/common.c:1698-1721`, `canonusr.c:390-403`).
Envelope sender held to the login by the same three lookups on both sides
(`MA_FIND_DEFAULT`; `Bindings.Allowed`), keys folded ASCII. The sender check
parses `From:`/`Sender:` as a mail program does and refuses every line of the
"other sender" list; `Resent-From` unjudged as documented. The signer keys
on `{auth_authen}` → `Bindings.DomainOf`, the local socket on the server's
name, nothing in the message chooses a key, an unknown login defers;
over-signing and `From` last verified against go-msgauth v0.7.0; relaxed
canonicalisation covers Postfix's `Name :` normalisation; 1 MiB header cap;
key tree 0700/0600 `panel:panel` (confirmed live). The 2.1.0 per-message
`{client_addr}` fix holds (`MailFrom` re-reads it; `Body` answers
`continue`). Inbound: `smtpd_forbid_bare_newline=normalize`, maps rendered
only for domains with an upstream, `[host]:port` next hop, verdict read only
beside its HMAC signature, rspamd strips a sender's own verdict headers for
every client. DMARC ingest: pipe as `panel`, one at a time, 10 MiB message,
8 MiB part and decompressed, no DTD entities (Go's `encoding/xml`), ≤ 5 000
records, 255-byte fields, domain checked before any write. `saslpasswd2`:
fixed argv, `--` before the login, password on stdin, copy renamed into
place at 0640. Notify: recipient re-checked before any header or argv,
Subject one line Q-encoded, `sendmail -i -f … -- to`, hourly ceiling.
**Panel.** Setup token 128 bits, constant-time, 10 min, void once a user
exists, first user one statement into an empty table, `/setup/` limited
before anything else; bcrypt 12 everywhere, unknown name costs the same
compare; sessions 256-bit, SHA-256 stored, `HttpOnly`/`Secure`/`SameSite`,
`__Host-`, duplicates = signed out, fixation closed (sign-in destroys the
presented session), by user id, touched only after the user is found,
hourly renewal, htmx GETs do not renew; every "what ends a session" rule
verified line by line; reset and confirmation links 256-bit, hash stored,
`https://` + `SELFPOST_HOSTNAME`, taken once in the transaction that sets the
password and re-checks the address, equal answer and timing for every name,
all the stated limits and spacing, confirmation needs the account's own live
session, restore deletes every link, stop drains the mailing goroutines;
mail header injection closed twice. Origin check: non-read methods only,
`Sec-Fetch-Site` wins, else `Origin` host:port must equal `r.Host`, neither
→ 403, before auth so the public POSTs are covered; every state change a
POST; the proxy fragments preserve `Host`. Headers: CSP `default-src
'self'; object-src 'none'; base-uri 'none'; form-action 'self';
frame-ancestors 'none'`, `nosniff`, `X-Frame-Options: DENY`,
`Referrer-Policy: same-origin`, HSTS (confirmed from outside; TRACE 405;
HTTP → 301 HTTPS). HTTP phases bounded as documented. Tenant boundary:
`/server/` registered only through `server()` and every handler
re-checks; `lookupDomain`, `lookupDomainApplication`,
`requireInboundDomain` answer the same 404 for missing and foreign; outbound
and inbound logs scoped by `domain_id IN (…)` in SQL with `1 = 0` for an
empty scope, filters only narrow; one delivery by `row.DomainID`; DMARC pages
by assignment; last-global guard inside the transaction; roles whitelisted
twice. Rendering: every user string through `html/template`, the one
`template.HTML` producer escapes first, every `hx-get`/`href`/`popovertarget`
server-built from ints and fixed paths, no inline script; SQL parameterised;
every redirect target a literal; query flags map to fixed strings.
Validation whitelists for domains, selectors, logins, addresses, hosts,
ports, filter-list values; map writers' backstops (`assertMapSafe`,
`assertMapToken`, `assertConfigSafe`, `configsafe.Token`); every write
temp + fsync + rename. Commands: `postqueue -p|-j` bounded and timed,
`supervisorctl status`, `postconf` — fixed argv, no shell. Secret pages
`no-store`, one-time password emptied on `pagehide`; backup and export
`no-store`, attachment, `reauth` (bcrypt under the password-change limiter)
before the write deadline is lifted. Import: 1 MiB, magic-byte detection,
whole plaintext read before JSON, `DisallowUnknownFields`, every field
re-validated. secretfile: scrypt N=2^15/r=8/p=1, random salt, AES-256-GCM,
random nonce prefix + counter, header/counter/end flag as AAD, caps read
before any KDF work, chunk ≤ 64 KiB; CLI password from env or file only,
wrong password caught before the output is touched. Backup: `VACUUM INTO`
on a read-only handle, live DB files/log/queue/setup-token/`sasldb2.new`
excluded, links under `data/` skipped, under `certs/` followed to regular
files only, `contents.json` last; restore: no extraction in the panel,
version check, partial unpack, journal beside the DB, names confined to
`data/`, `Lstat` + regular-file check, size and SHA-256 compared. Log
parsers: head-anchored, recipient by Postfix's quoting grammar, lazy to the
first `status=`, port-25 lines not stored, the HELO attack of 2026-10-12
closed by Postfix's neutering, stranger text printable and cut, journal
capped — except M2 and M3 above. DNS checks to fixed external resolvers with
timeouts and a lookup budget.
**Image and process.** Root phase: `chown -R`/`find -exec` do not follow
links, `drop_links` covers every other path root then touches (I2 is the
gap), no `set -x`, no secret in the environment (confirmed live: hostnames,
limits, paths, TZ), the panel is not running while root touches `/data`.
Modes as documented, confirmed live: DB 0600, DKIM 0700/0600, `sasldb2`
0640 in 2750, maps 0640, `mail.log` 0640 `postfix:selfpost`, sockets 0660
in 2750, `supervisor.sock` 0770 `root:supervisor`, key copy 0600 root in
`tls-internal` (the `postfix` uid cannot read it — I1 is the *mounted*
original). supervisord: unix socket only, no inet server, no include
(confirmed live), config root-only, so the panel can start/stop/restart/
shutdown the five programs and nothing more; `crashexit.py` only `SIGTERM`s
its parent. Root helpers read nothing the panel can write; logrotate 3.22.0
opens with `O_NOFOLLOW`/`O_EXCL` (verified in its source). Capabilities: the
eight in compose are each needed (`DAC_OVERRIDE` for root to create files in
the panel's 2750 directories, `KILL` measured on 2026-10-16), the live
`CapEff` decodes to exactly them, `no-new-privileges:true` (so the fourteen
setuid/setgid binaries of the base image — `su`, `passwd`, `mount`,
`postdrop`, `postqueue`, … — are inert for the panel; removing the Debian set
is hygiene, not a fix). `.dockerignore` and the `COPY` set keep `dev/`,
`test/`, the `.exe` files and the docs out of the image. e2e stand: isolated
project, loopback port, throwaway key, `PANEL_COOKIE_SECURE=false` only
there. Deploy fragments forward `Host` and append `X-Forwarded-For`; the
panel reads the last hop only from a trusted peer (right for Apache,
nginx, Caddy, Traefik, all of which merge repeated headers). `extract-cert.sh`
sound as a root cron script. 8080 bound to loopback on the test server;
Apache 2.4.68 fronts it with HSTS, the ACME exception and a redirect.
**Refuted hypotheses (not findings).** Postfix's `texthash` folds keys as
Unicode (it is ASCII — the envelope is held *more* strictly than the comment
assumes); an XFF the client sends ahead of Apache's can win (Apache appends;
the panel takes the last); `go-milter`'s vendored copy differs from upstream
beyond the documented patch (it does not); the DMARC ingest can be made to
expand entities or nested archives (it cannot); Cyrus's open CVE is
reachable (client-side DIGEST-MD5 only); the stdlib advisories apply to the
live binary (built with go1.26.9, which has the fixes).
---
## Plan
Seven stages. Stages 0-2 are product code, each a patch release in its own
right, each with "done when" being the test that proves it; stage H is the
owner's host and takes an hour of his own time; stages 3-6 are the rest in
the order that buys the most. The hours are the reader's estimates with the
coordinator's correction; tests and the CHANGELOG line are inside them.
Model routing per development.md (security/infra → Opus, docs → Sonnet).
**My recommendation.** Stage H today — S1 is the only finding here whose
worst case is a root shell, and it needs no code. Then stage 0 as one cut
(the high and four cheap guards, half a day), stage 1 as the next cut (the
tenant-boundary mediums, a day), stage 2 (the key group) when the e2e has a
free afternoon, and stages 3-6 in any order. Nothing here is "not planned":
the two findings I would *accept* rather than fix are named in stage 6 with
the argument.
### Stage H — the host (owner, ~1 h, no product code)
- [ ] **S1, S2, S5** — the commands, the package list and the "done when"
of each are in `dev/security-audit-2026-10-17-host.md` (not published);
delete that file once this stage is done.
- [ ] **S3.** DNS: `_dmarc.selfpost.mixfed.ru TXT "v=DMARC1; p=reject;
rua=mailto:<an address you read>"`; `mixfed.ru CAA 0 issue
"letsencrypt.org"`. Done when `dig +short TXT _dmarc.selfpost.mixfed.ru`
answers.
### Stage 0 — the high and the cheap guards (~5 h) → patch release (shipped `2.14.4`, 2026-10-17)
- [x] **A2.** `reauth` on user create, on a user update that carries
`password` or changes `role`, and on user delete; the user form gets the
`account_password` field the Backup page has; security.md § Backup and
domain export says the user form is covered. Done when a test with a
session and no password gets 401 on all three and the existing user-form
tests pass with the field. — **Opus**, 2 h. *Done 2026-10-17:* also on a
change of e-mail (where reset links go); a save of the name and the
reach alone is not asked; TestUserFormAsksForTheAccountPassword.
- [x] **I2.** The three DMARC maps in `drop_links`; `[ -L ]` beside `[ -e ]`
in postfix-config.sh:538,545; an e2e case plants a link and expects the
log line. — **Opus**, 0.5 h. *Done 2026-10-17:* TestStartsOnA28Data
plants a dangling `dmarc_transport` with DMARC reports on.
- [x] **M2.** Greedy reply group anchored on Postfix's own tail; the live
line above as a test; security.md § Mail path reworded ("the last `;
from=<`, which is Postfix's because a HELO name can hold none of
`<>;\"`"). — **Opus**, 1 h. *Done 2026-10-17:* the HELO name in the
tail may hold none of the neutered characters and each SASL field is one
word (a `.*` SASL tail would have let a quoted sender spell the whole
tail); TestInboundRecipientCannotWriteTheRow.
- [x] **M5.** Keep at most two `From:`/`Sender:` values; security.md:810
reworded. — **Opus**, 0.5 h. *Done 2026-10-17:* TestSenderKeepsTwoFromAndSenderLines.
- [x] **A4.** Duplicate-cookie line through `loggedPath`. — **Sonnet**, 0.25 h.
*Done 2026-10-17:* `loggedPath` moved to `auth.LoggedPath`, used by both
lines; TestDuplicateCookieLineHidesALinksToken.
- [x] **S6.** `smtpd_etrn_restrictions = reject`, `smtpd_discard_ehlo_keywords
= etrn` in postfix-config.sh; architecture.md § Postfix one line. Done when
an e2e `ETRN` on 25 gets `554`. — **Opus**, 0.5 h. *Done 2026-10-17:*
testInboundRelay sends ETRN for the inbound domain; checkPostfixHardening
reads both settings back.
- [x] **X1.** SECURITY.md's table: `latest 2.x` supported, `1.x` not. —
**Sonnet**, 0.25 h. *Done 2026-10-17.*
### Stage 1 — the tenant boundary in the mail path (~7 h) → patch release (shipped `2.14.4`, 2026-10-17)
- [x] **M1.** `overLimit` and `recordRejected` take the domain from the login
(`ApplicationByLogin`), return early on an empty login; the reader's two
tests (stranger; other tenant) become the regression tests; an e2e sends an
unauthenticated `MAIL FROM` for a domain with a limit of 1 and then the
domain's own message, which must pass. security.md § Mail path records
that an unauthenticated session reserves nothing. — **Opus**, 2 h.
*Done 2026-10-17:* TestRateLimitIsNotReservedByAnotherSendersMailFrom;
e2e `domain_limit_not_reserved_by_a_stranger`.
- [x] **M3.** De-duplicate recipients in `inboundSession.RcptTo` (case-folded,
as the outbound session does); the milter's inserts count toward the prune
trigger (a counter in the store beside `InsertInboundSpam`, the prune
called from whichever side crosses 500); one transaction per message.
Done when a unit test sends one message with the same recipient 1 000
times and sees one row, and 600 milter rows with no refusal trigger the
cut. — **Opus**, 2 h. *Done 2026-10-17:* the counter and the cut moved
into the store (`countInboundWrites`, both writers),
`InsertInboundSpamMessage`; TestInboundRecipientRepeatedIsOneRow,
TestInboundJournalIsCutWhicheverWriterWrites.
- [x] **W1.** Refuse a custom report address on the hostname; exact hosted
address only in `AllowedRecipients`; a tag required for every recipient on
the hostname in `checkReportDomain`; the existing test inverted; the
accepted-risk text corrected. Check the live registry for a custom address
on the hostname first (none expected). — **Opus**, 1.5 h. *Done
2026-10-17:* the live registry holds two hosted addresses and no custom
one on the hostname; TestCustomReportAddressOnTheHostnameIsRefused, the
untagged cases in TestIngestMessageRejectsTagMismatch.
- [x] **A5/W2.** Delete a domain's reports in `domain.Service.Delete` (the
cheap form); security.md § Roles says reports go with the domain. —
**Sonnet**, 0.5 h. *Done 2026-10-17:* in `store.DeleteDomain`, one
transaction with the domain; TestDeletingADomainDeletesItsReports. (The `domain_id` form is better and 2 h; take it if
stage 1 is split across two sittings.)
- [x] **W3.** A mutex around `Resync`'s read + write + reload. — **Sonnet**,
0.5 h. *Done 2026-10-17:* TestResyncRunsOneAtATime.
### Stage 2 — the key group (~2.5 h) → patch release, then the test server's `certs/` (shipped `2.14.4`, 2026-10-17)
- [x] **I1.** `groupadd --system --gid 10997 backupkey; usermod -aG backupkey
panel` in the Dockerfile; the guide's two `chgrp 10998` become `10997`
with a sentence on why; `handlers_backup.go:398-399` likewise; the e2e
stage gives the key to 10997 and `restore_test` asserts the `postfix` uid
cannot read `/etc/postfix/tls/privkey.pem`; security.md § Output and
process lists the three groups and who is in each. Rollout: `chgrp 10997
/root/selfpost-deploy/certs/privkey.pem` and the same in
`cert-deploy-hook.sh` on the test server. — **Opus**, 2.5 h. *Done
2026-10-17:* the e2e stage gives its key to 10997 at 0640 when the runner
is root and `testFullBackupRestore` checks both uids; the test server's
key at the rollout.
### Stage 3 — limits and defaults (~7 h) (shipped `2.14.4`, 2026-10-17)
- [x] **A1.** (a) Compose and guide set `TRUSTED_PROXY_CIDR` for the
Apache-on-host scenario (the Docker gateway, with the command that prints
it) and the panel logs a warning at start when the first N requests all
come from one private peer carrying XFF and the list is empty; (b) the
login limiter also keyed by username with its own window, the link-page
limiters by token hash; (c) successful sign-ins not counted. security.md
records (a)-(c). Done when the reader's test passes with two clients
behind one untrusted peer. — **Opus**, 4 h. *Done 2026-10-17:* (a) as a
runtime warning (`proxyWatch`) and the guide's install step with the
command - the shipped compose cannot know the gateway, so it is not set
there; (b) per address and username, per link, the address alone at ten
times that; (c) a right password gives its attempt back.
TestLoginBehindOneUntrustedProxyKeepsOthersIn, TestLoginSuccessesDoNotCount,
TestProxyWatchAndRefusalsAreLogged.
- [x] **A8.** One log line per refused sign-in or lockout, rate-limited per
key. — **Sonnet**, 0.5 h. *Done 2026-10-17* (`refusalLog`).
- [x] **A9.** IPv6 keyed by /64. — **Sonnet**, 0.5 h. *Done 2026-10-17:*
TestIPv6IsLimitedPerSlash64.
- [x] **A3.** After five unsolicited reset links in a day, one mail saying
further requests are ignored for 24 h, then silence. — **Opus**, 1.5 h.
*Done 2026-10-17:* TestResetLinksPauseForADayAfterFive.
- [x] **I6.** `/healthz` answered only to loopback and trusted-proxy peers;
the guide's "Container health" says the proxy may expose it on purpose
with the fragment line. — **Sonnet**, 0.5 h. *Done 2026-10-17,* in another
shape: answered to a request with no `X-Forwarded-For` from loopback or a
private address - a trusted proxy's peer would have let the internet in
again through that proxy; TestHealthzAnswersOnlyLocalProbes.
### Stage 4 — resources and imports (~5 h) (shipped `2.14.4`, 2026-10-17)
- [x] **I3.** `pids_limit: 512` in every compose fragment; the comment
corrected; one e2e run. — **Sonnet**, 0.5 h. *Done 2026-10-17:* 1024 for
SelfPost (Postfix's default_process_limit of 100 per service, three smtpd
services), 256 for the proxies and rspamd, 64 for certbot; e2e reads
`pids.max` back (checkPidsLimit).
- [x] **W6/M9.** Coalesce Postfix reloads (reload only when the rendered map
changed; a 2 s debounce for bursts); a per-user limiter on `dns-recheck`.
— **Opus**, 1.5 h. *Done 2026-10-17:* a rebuild that changes no file
writes nothing and asks no reload (`writeChanged`), Health's button
re-reads in any case; no debounce - with unchanged saves gone, a burst is
real changes, each of which Postfix must see; twenty re-checks in 15
minutes per user, then the cached result. TestARebuildThatChangesNothingDoesNotReload,
TestDNSRechecksArePerUser.
- [x] **W4/W5/W8.** Key size 1024-4096 on import; ≤ 500 applications per
export; an import named as the hostname refused with a sentence. —
**Sonnet**, 1.5 h. *Done 2026-10-17:*
TestImportKeyRefusesAnOversizedKey and the import cases (a key under
1024 bits the parser already refuses).
- [x] **S4.** rspamd pin → `4.2.x` after reading `milter_headers`/`multimap`
against the 4.x docs and one e2e run (`inbound_upstream_test`,
`antispam_test`); `cap_drop: [ALL]`, `no-new-privileges`, `pids_limit` on
the sidecar. — **Opus**, 1.5 h. *Done 2026-10-17:* rspamd
4.2.2 and 3.11.1 read side by side (the functions SelfPost relies on are
unchanged); `rspamd/rspamd:4.2.2-0` with `cap_drop: [ALL]`,
`no-new-privileges`, and - recommended by that reading - the normal
worker and the controller (default password "q1") kept on loopback, the
proxy alone on the network (`worker-proxy.inc`, `RSPAMD_LOCAL_ADDR`). The
e2e has no rspamd, so a throwaway container on the test server with our
three files checked it: the listeners, a sender's own verdict headers
removed from a private address, rspamd's added, the signature equal to
openssl's HMAC, the deny list's 554 message, healthy. The live sidecar at
the rollout.
### Stage 5 — supply chain and currency (~2.5 h) (shipped `2.14.4`, 2026-10-17)
- [x] **I4.** Actions pinned by SHA with the version in a comment;
`govulncheck ./...` in `test.yml` and in `release.yml` before the push (a
stdlib advisory fixed in the current patch of the pinned minor fails the
build until the base tag moves — that is the point); `certbot` and
`caddy` pinned in the fragments. Leave `provenance`/`sbom` off until the
GHCR cleanup loop is taught to keep attestation manifests (a follow-up,
1 h). — **Sonnet**, 1 h. *Done 2026-10-17:* actions pinned to the
latest release of the major each used (checkout v4.4.0, setup-go v5.6.0,
setup-buildx v3.12.0, build-push v6.19.2, login v3.7.0); govulncheck
v1.9.0 on the source in test.yml and on the image's two binaries
(`-mode=binary`, the base image's Go) in release.yml before the push;
certbot v5.8.0, caddy 2.11.7. provenance/SBOM stay off, as planned.
- [x] **I5.** guide § Upgrading and SECURITY.md: base-package fixes arrive
with the next patch release; the owner's rule on published tags. A weekly
workflow that rebuilds the package list in `debian:trixie-slim` and fails
when postfix/libsasl2/openssl/libdb moved, as the nudge. — **Sonnet**, *Done 2026-10-17:* the guide, SECURITY.md;
`base-packages.yml` + `.github/scripts/base-packages.sh` against a table
in development.md (seven packages, from the 2.14.3 image), tried on the
test server both ways.
1.5 h.
### Stage 6 — the rest, and what to accept (~3 h) (shipped `2.14.4`, 2026-10-17)
- [x] **M7.** guide: upstream `encrypt` checks no certificate. — **Sonnet**,
0.25 h. *Done 2026-10-17:* guide and Help. (Offering `secure` is hours and a `domain`-user-facing choice;
propose it on the roadmap, not here.)
- [x] **A6, A7, A10, W7, M6, M8** and the README note — half an hour each,
or the wording change named under each. — **Sonnet**, 2 h. *Done 2026-10-17:* A10 and
M6, M8 in code (a session from a longer window is cut at the next check;
three headers signed; a long header line logged); A6, A7, W7 by wording
in security.md - each is what the code should do; the README note.
- [x] **Doc drift sweep** — every line under "Doc and comment drift" above.
— **Sonnet**, 1 h. *Done 2026-10-17:*
every line, each with the finding that fixed it or in this sweep; left as
history: migration `0013`'s comment (a migration is not edited after it
shipped); the live compose is re-copied at the next rollout.
- **Accept, with the argument:** *the setuid/setgid binaries of the base
image* — inert under `no-new-privileges`, which the shipped compose sets
and the e2e exercises; removing Debian's set is a maintenance cost at every
base bump for no reachable gain. *`/data/antispam` world-readable* —
already an accepted risk with its stated trigger (a shared host); the
cheaper `group_add: ["10998"]` on the sidecar plus 0640 is worth 0.5 h when
stage 4 touches that file anyway, and is listed there as optional.
### After the plan
security.md gets a paragraph in its history ("The 2026-10-17 security
audit…") like the earlier reviews, naming A2 and the seven mediums and what
closed them; the roadmap gets a row `security-audit-2026-10-17` whose
Progress column counts the `- [ ]` lines above, as the 2026-10-12 audit's
row does.
+175 -39
View File
@@ -64,7 +64,22 @@ notification (the server's name is kept in lower case for DKIM); only `From`
was over-signed (now the shown headers too) - all fixed. `Sender:`, which
the review found neither held nor signed (F1), is now both: signed, and held
to the login like `From:` (the owner's decision, 2026-10-16). What the header
cap leaves is under Accepted risks.
cap leaves is under Accepted risks. The 2026-10-17 security audit
([security-audit-2026-10-17.md](security-audit-2026-10-17.md); four zone
readers, the live server, probes from outside) found no Critical. Its High,
A2: a stolen `global` session could set its own password on the user form
and so pass the backup's password check - the user form now asks for the
signed-in password to add a user or to change a password, an e-mail or a
role, and to delete one. Its seven Mediums: a stranger reserving a domain's
level-2 slots through `MAIL FROM` (M1, now the login's domain), a refused
recipient writing an Inbound log row in a stranger's words (M2, now
Postfix's own tail), one message as a thousand journal rows uncut (M3), a
custom DMARC address on the server's name opening an untagged mailbox (W1),
the TLS key readable by Postfix through group 10998 (I1, now group 10997),
a link the entrypoint could follow at the DMARC maps (I2), and every
visitor sharing one sign-in limit behind an untrusted proxy (A1) - all fixed
in `2.14.4`, with the lows the plan named; two reviews (Fable) of the fixes
found nothing above low, and their lows were fixed too.
(2) **Accepted risks** —
deliberate departures beyond the mandatory, recorded so the decision is not
lost.
@@ -87,9 +102,14 @@ The panel is exposed to the internet — the items below are **not optional**.
- 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 lifetime — **10 minutes**; a restart with setup unfinished makes a
new one, and so does the first `/setup/` request after it expired - any
request, a stranger's `GET /setup/x` included, which then logs the new
token in the container log, where only the operator reads it (audit
2026-10-17, A7: nothing reaches the requester; the wording said "after
expiry").
- Rate limiting on `/setup/<token>` per client address (an IPv6 one per
/64), separate from login.
- Token comparison is **constant-time** (`subtle.ConstantTimeCompare`).
- Failed attempts do **not** invalidate the token early (protects setup from
being DoS-ed).
@@ -123,7 +143,20 @@ The panel is exposed to the internet — the items below are **not optional**.
### Authentication and sessions
- Rate limiting on login (per IP, with lockout or delay). The Account form's
- Rate limiting on login: ten failures in 15 minutes per client address and
username, and a hundred per address alone; a right password gives its
attempt back, so only failures count. An IPv6 client is one address per
/64. Behind a reverse proxy the panel does not trust (`TRUSTED_PROXY_CIDR`
empty) every visitor has the proxy's address: a stranger there can then
lock one name, or spray a hundred, but not lock everyone out at the
per-name size (until `2.14.4` the one key was the address, successes
counted, and ten requests of any body locked every administrator out -
security audit 2026-10-17, A1, A9). When the first requests after a start
all come from one private peer with `X-Forwarded-For` and no proxy is
trusted, the log says so with the value to set. A refused sign-in and a
lockout are logged, five lines per address in 15 minutes (A8). A public
form - sign-in, reset, a link's page, setup - is read only up to 64 KiB,
and the limiters keep a hash of a typed name, not the name. The Account form's
current-password check has a limit of its own, ten in 15 minutes per user,
so guesses there do not lock the address out of signing in (`2.5.4`).
- An unknown username is refused in the time a wrong password is: the sign-in
@@ -138,7 +171,8 @@ The panel is exposed to the internet — the items below are **not optional**.
after it was found, so the cookie of a deleted user is dead and cannot become
the session of whoever is created under the same name later; renaming a user
leaves their sessions alone.
- **What ends a user's sessions:** deleting the user (all); a password change on
- **What ends a user's sessions:** deleting the user (all); a password set
from a reset link (all, `2.14.0`); a password change on
Account (that user's *other* sessions — never another user's); a password set
for them by a global user on the user form (all of theirs, except the current
one when they set their own); signing out (the current one only); signing
@@ -148,6 +182,47 @@ The panel is exposed to the internet — the items below are **not optional**.
held.
- Polling by the monitoring screens (`HX-Request` `GET`s) does not renew a
session.
- **Password reset by e-mail (`2.14.0`).** A link goes only to an address
its account has confirmed, by a link sent to that address; notifications
go to an unconfirmed one (owner, 2026-10-17) - they carry no secret. A link
is 256 random bits (`crypto/rand`); `user_links` keeps its SHA-256 only,
so the database or a backup opens nothing. A confirmation link lives 24
hours, a reset link 30 minutes; each is taken once, in the transaction
that deletes it (`DELETE … RETURNING`); a newer confirmation link replaces
the older one, while a reset link is not replaced - the one in the mailbox
works until it is used or expires, and another is not sent within ten
minutes of it. A reset link holds only while the account's address is
still the confirmed one it was sent to; a confirmation link only while the
address is the one it confirms. The link's host is `SELFPOST_HOSTNAME`,
never the request's `Host`, which an attacker would choose to receive the
token. The request form answers the same for every name and sends the mail
after answering, so neither the words nor the time tell whether a name
exists or is confirmed; requests are limited to five in 15 minutes per
client address and username (fifty per address alone), mails to five an
hour per account and five reset links a day - after which the owner is
told once that further requests are ignored for 24 hours, and nothing
more is sent (`2.14.4`; audit A3: anyone who knew a username kept a live
link in that mailbox five times an hour). The price is recorded: whoever
knows a username can spend those five links and so pause the owner's own
reset for a day; signing in, and a global administrator setting a
password on Server › Users, still work, and only a link that went out
counts - and a link's page to 30 requests in 15 minutes per link
(300 per address alone), so one stranger behind a shared proxy cannot stop
the others asking for or opening a link (`2.14.4`; audit A1). A link's page changes nothing
on `GET` (a mail scanner opens links): the button does. Setting a password
from a link ends every session of the account and every other reset link.
Changing the account e-mail asks for the current password, as the password
does: the address is where a reset link goes. From the security review
(2026-10-17): confirming an address also takes being signed in as its
account, and the confirmation mail does not name the account - a mistyped
address is a stranger's, who could otherwise confirm it and reset the
password; a reset request does not replace the links already sent, and
none is sent within ten minutes of the last, so a stranger repeating a
username neither kills the link in the mailbox nor spends the account's
links; the link is taken and the password set in one transaction that
checks the address again; a restore deletes every link; a rejected
request's log line hides a link's token; a stop waits for the links still
being mailed.
### Roles
@@ -162,7 +237,7 @@ assigned outbound domains and inbound domains **separately**, each list with
| Overview, Server (Health, System log, Backup, Users, Settings, kit page) | yes | no — 404 |
| Outbound queue | yes | no — 404 (a queue is not filtered by domain) |
| Outbound: domain page, applications, Domain settings (report address, rate limit, export), log | every domain | assigned outbound domains |
| Outbound › DMARC reports | every report, with the server's ingest statistics | reports of assigned domains, without the ingest box |
| Outbound › DMARC reports | every report, with the server's ingest statistics | reports of assigned domains, without the ingest box (a domain's reports are deleted with it, so a domain added again under the same name starts with none) |
| Inbound: domain page, upstream, recipients | every inbound domain | assigned inbound domains |
| Inbound › Log | every row, including those of a domain deleted since | rows of assigned inbound domains (by id: not those of an earlier domain of the same name), without the links into the filter lists |
| Inbound › Filter lists; the inbound log's retention | yes | no — 404 |
@@ -172,7 +247,10 @@ assigned outbound domains and inbound domains **separately**, each list with
- A path a user may not reach, a domain or application that does not exist,
and one that belongs to another tenant answer the **same 404**, so the panel
does not confirm what exists. A group with nothing assigned is absent from
the user's menu.
the user's menu. A route asked with a method it does not take answers
`405` with `Allow`, from the router, before the role is looked at: the
routes are the public source's, so that says nothing the code does not
(audit 2026-10-17, A6).
- `/server/` is guarded once, by `globalOnly` where its routes are registered
(a route outside the subtree cannot be added through that helper), and every
handler checks again. Outbound and inbound handlers check per object
@@ -196,8 +274,11 @@ assigned outbound domains and inbound domains **separately**, each list with
### State-changing requests
- Every state change is a `POST`; a `GET` never changes state (the delete
confirmation pages only render a form).
- Every state change is a `POST`; a `GET` changes no state a user chose
(the delete confirmation pages only render a form). Two make what has to
exist when it does not: Server › Settings and Health generate the server's
own DKIM key on their first view, and the first `/setup/` request after
the token expired makes a new one (audit 2026-10-17, W7, A7).
- Every non-read request first passes the origin check
(`originAllowed`, [internal/web/security.go](../internal/web/security.go)):
`Sec-Fetch-Site` must be `same-origin`, or `Origin` must name the panel's own
@@ -220,8 +301,8 @@ operator can act on it.
### Output and process
- Rendering goes through `html/template` with auto-escaping (queue, log,
journal, themes).
- Rendering goes through `html/template` with auto-escaping (queue, logs,
journal, every page).
- The panel process is **not root** (`user=panel` in supervisord); path access
is granted through the `selfpost` group with minimal permissions. The
supervisord control socket, which can stop or restart any program, is the
@@ -229,14 +310,22 @@ operator can act on it.
`selfpost` for files and does not reach it (`2.6.0`; OpenDKIM shared
`selfpost` too until `2.8.0`).
- **Group `selfpost` (gid 10998) has two members, `panel` and `postfix`.**
Through it Postfix reads what the panel writes - `sasldb2` and the maps
under `/data/postfix` (2750, files 0640) - and connects to the panel's
Through it Postfix reads what the panel writes - `sasldb2` under
`/data/sasl` and the maps under `/data/postfix` (both 2750, files 0640) - and connects to the panel's
milter sockets in `/run/selfpost` (2750, sockets 0660); the panel reads
`mail.log`, which `postfix` writes, through it the other way
([build/Dockerfile](../build/Dockerfile), `build/entrypoint.sh`). The DKIM
keys are not in it (§ Mail path).
- **The container keeps `KILL`** (`deploy/docker-compose.yml`, `cap_add`;
every other capability not listed there is dropped). Without it root
- **Group `backupkey` (gid 10997) has one member, `panel`.** The operator
gives the host's TLS private key to it so that a full backup carries the
key; Postfix serves a root-only copy and has no way to the original. Until
`2.14.4` the guide gave the key to `selfpost`, so every Postfix daemon -
an smtpd talking to the internet included - could read it (security audit
2026-10-17, I1; measured on the test server with `docker exec -u postfix`).
- **The container keeps eight capabilities**, `KILL` among them
(`deploy/docker-compose.yml`, `cap_add`: NET_BIND_SERVICE, CHOWN, FOWNER,
FSETID, SETUID, SETGID, DAC_OVERRIDE, KILL, each with its reason there;
every other is dropped). Without it root
cannot signal a process of another uid (kill(2), capabilities(7); measured
on the test server, 2026-10-16), and two root processes must: supervisord
stops and restarts the panel, and Postfix's master ends its children,
@@ -270,8 +359,10 @@ operator can act on it.
server's key may sign it. What cannot be signed (a login with no domain, a
name with no key, a read error) is deferred, never sent unsigned. Port 25
has no signer. `relaxed/simple`, rsa-sha256; `h=` names every header of
RFC 6376 § 5.4.1's list the message has, and `Sender` and
`List-Unsubscribe-Post`, then once more each of the shown ones present
RFC 6376 § 5.4.1's list the message has, and `Sender`,
`List-Unsubscribe-Post`, `Content-Type`, `MIME-Version` and `Message-ID`
(the last three since `2.14.4`, audit M6: unsigned, a relay could change a
MIME boundary and the signature still verified), then once more each of the shown ones present
(Subject, Date, To, Cc, Reply-To, Sender) and `From` last, so one of those
added after signing breaks the signature. Until `2.8.0` OpenDKIM signed - a
2018 beta with three 2026 CVEs open and nobody upstream to fix them
@@ -286,14 +377,12 @@ operator can act on it.
signer reads them and never the database. The entrypoint sets these modes
at every start (e2e `checkDKIMTree` holds the tree to them). Until `2.8.0`
the keys were under `/data/opendkim`, 2750/0640 in group `selfpost` so
that OpenDKIM could read them - and so could every `postfix` process. That
tree is kept for a rollback to 2.7.x until the next release; since `2.8.0`
the entrypoint makes it the panel's alone as well (0700/0600, panel:panel)
at every start, copies its keys as the panel user (`setpriv`) while
`/data/dkim` holds none, and a backup leaves it out (§ Backup and domain
export). A root step of the entrypoint follows no symbolic link the panel
could plant in either tree (`drop_links`; `cp -RP`; `chown -R` and `find`
do not follow one).
that OpenDKIM could read them - and so could every `postfix` process.
2.8.x kept that tree, the panel's alone, for a rollback to 2.7.x and copied
its keys over; since `2.9.0` the entrypoint removes it at start, and refuses
to start when the keys are only there (start 2.8.x once first). A root step
of the entrypoint follows no symbolic link the panel could plant in the
tree (`drop_links`; `rm -rf`, `chown -R` and `find` do not follow one).
- **An application signs only as itself (`2.4.0`).** The signature's `d=` is
the login's domain, and a receiver holds it to the domain of the `From:`
line (DMARC, RFC 7489 § 3.1.1): without a check of that line an
@@ -370,6 +459,16 @@ operator can act on it.
ask for it at all, an application with a client-address list is refused when
no address is known, and neither milter replies anything but "continue" to a
message it lets through. `test/e2e` sends two `MAIL FROM` on one connection.
- **A level-2 limit is reserved only by its own senders.** The domain limit
is the domain of the signed-in application, never the `MAIL FROM`
domain, and a session with no login reserves nothing. `MAIL FROM` reaches
the journal milter before Postfix holds it to the login (with
`smtpd_delay_reject` at its default no sender restriction runs at MAIL),
so until `2.14.4` a stranger on 465/587 who never signed in, or another
tenant's application, could name a domain there and take its slots for
the in-flight TTL - and have a `limit` refusal written under it (security
audit 2026-10-17, M1). `test/e2e` names a limited domain in a stranger's
`MAIL FROM` and then sends the domain's own message.
- **The upstream's answer is an update of a row, not a row**
(inbound-upstream-result, `2.10.0`). A delivery line of mail accepted on
port 25 changes the result of that message's journal rows and adds
@@ -412,12 +511,25 @@ operator can act on it.
The signature goes on to the upstream with the mail and is of no use there:
it fits one queue id. Refusals are
parsed from `mail.log` from the head of the line, with the reply ending at
Postfix's own `; from=<` so that the HELO name after it - the client's text,
which Postfix logs with non-printable characters and ` <>()\";@` turned into
`?` (Postfix 3.10.13, src/smtpd/smtpd.c `helo_cmd()`) - cannot stand in for
the sender, the recipient or the reply; an address with a quote, a bracket or a space in it is not kept.
the *last* `; from=<…> to=<…> proto=…` that runs to the end of the line.
That tail is Postfix's own: the HELO name in it is logged with
non-printable characters and ` <>()\";@` turned into `?` (Postfix 3.10.13,
src/smtpd/smtpd.c `helo_cmd()`), and the pattern takes none of them there;
a refused recipient, which the reply quotes with its quotes removed and
`; < > =` kept, can spell such a tail only inside a quoted local part,
whose quote then lands in what would be the sender or the recipient - and
an address with a quote, a bracket or a space in it is not kept. A line
whose reply still holds `; from=<` - two tails - makes no row. (Until
`2.14.4` the reply ended at the *first* `; from=<`, which a stranger's
recipient could supply - security audit 2026-10-17, M2.)
Only a recipient at an inbound domain makes a row. The journal is cut to
10 000 rows every 500 rows the tailer writes, and by age every six hours.
10 000 rows every 500 rows written - the tailer's refusals and the journal
milter's accepted recipients together, counted in the store - and by age
every six hours. The journal milter writes a recipient given twice, in any
case, once, and a message's rows in one transaction. (Until `2.14.4` it
wrote every repeated RCPT and its rows were not counted toward the cut:
one message could be a thousand rows, uncut for six hours - security
audit 2026-10-17, M3.)
- **Filter lists** reach another container as files: values are whitelisted on
the way in (address, domain, canonical IP or network; no `/0`, no
IPv4-mapped network), the writer refuses a stored value with whitespace, a
@@ -442,6 +554,19 @@ operator can act on it.
file's own protection, not this one: whoever downloads it chooses that
password. The import and the `selfpost-backup` CLI, which runs inside the
container, are not asked.
- **Nor is it enough to make an account that would pass that check**
(security audit 2026-10-17, A2). Until `2.14.4` a `global` session could
set its own password on the user form - no current password asked, the
session kept - and then pass the check above with the password it chose,
or add a second global user with a password it knew. The user form now
asks for the same account password, under the same limiter, when it adds a
user and when a save sets a password, changes the e-mail (where reset
links go) or changes the role - the signed-in user's own account included -
and deleting a user asks for it too. A save of the name and the domain
assignments alone is not asked: neither reaches an account's secret. Only
wrong passwords count against the limiter; a right one gives its attempt
back, so an administrator adding users is not locked out of the next
backup.
- Both files are secrets: a full backup carries DKIM keys, `sasldb2`, the
panel users' password hashes, `docker-compose.yml`, `.env`, and the TLS
private key from `certs/` when present; a domain export carries the DKIM key
@@ -498,7 +623,8 @@ operator can act on it.
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
- Minimum password length matches the administrator password (12
characters, counted as characters, not bytes, in both): the file
sits offline and can be attacked without a time limit. The panel's forms and,
since `2.5.3`, the CLI hold an encrypting password to it; decrypting takes
any.
@@ -550,8 +676,9 @@ deferred item from the roadmap.
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).
mandatory is a second person who takes backups or exports - a second
global user, or a domain user exporting their domain - 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
@@ -572,10 +699,16 @@ deferred item from the roadmap.
the alignment statistics the panel shows. Inherent to how `rua` works:
reporters are arbitrary third parties and the mailbox address is published in
DNS. The ingest already constrains the meaningful part — a report cannot be
filed under a domain this instance does not host, and the claimed domain is
cross-checked against the `+tag` of the delivery address (effective since
2.0.0: before it the recipient never reached the command) — and the data is
advisory statistics only; nothing enforces off it. What a stranger can do is
filed under a domain this instance does not host, and the claimed domain
must be the `+tag` of the delivery address (checked since 2.0.0: before it
the recipient never reached the command; required since `2.14.4`) — and
the data is advisory statistics only; nothing enforces off it. The only
mailboxes are the hosted addresses, each open while its own domain has
chosen it: a typed report address on the server's own name is refused, and
one stored before is no mailbox (until `2.14.4` a domain user could type
`ops@<hostname>` and so open an untagged mailbox to the internet whose
reports were filed under any domain, or another domain's hosted address -
security audit 2026-10-17, W1). What a stranger can do is
limited to a domain that chose a SelfPost-hosted address, through that
address: file reports under it, replace one whose reporter and id they know,
and fill that domain's 500 — not another domain's. A message that is not a
@@ -776,7 +909,10 @@ deferred item from the roadmap.
`headerLimit`; `2.8.0`): it keeps no more than that and drops its list
once the block it signs is built. No mail program writes such a section,
and the refusal is permanent, so a sender is told rather than retried.
The other milters keep a few headers each (From, Sender, Subject).
The other milters keep a few headers each: the sender check two `From:`
and two `Sender:` values at most - it needs only none, one or more - and
the first Subject (until `2.14.4` it kept every `From:`/`Sender:` line,
which Postfix bounds in length but not in number; audit 2026-10-17, M5).
Until `2.8.0` the milter library itself added every header of a message
to a map of its own in each milter - up to the port's message size limit,
from strangers on port 25 - which none of the milters read; the panel

Some files were not shown because too many files have changed in this diff Show More