177 Commits
Author SHA1 Message Date
mixandClaude Opus 5.5 839a752341 docs: machine requirements by what is switched on, measured
test / test (push) Waiting to run
Owner, 2026-10-19: "Нам нужно записать в документы для деплоя минимальные
требования к машине в зависимости от включённых опций." The README's
"1 vCPU, 512 MB-1 GB RAM, 8-10 GB disk" had never been measured.

Measured on the test installation (2.16.0, inbound relay and rspamd on,
2 vCPU / 4 GB), 2026-10-19:
- the SelfPost container: cgroup 55 MB at rest, 78 MB peak since the
  rollout; panel and supervisord ~30 MB RSS each, Postfix daemons 6-12 MB;
- the rspamd sidecar: 117 MB, peak 120 MB, five processes;
- CPU at rest under 0.1 %; images 315 MB and 311 MB;
- the build step of build/Dockerfile, cold, in golang:1.26-trixie held by
  docker run --memory/--memory-swap and --cpus 1: killed at 512 MB and 1 GB
  (compile of modernc.org/libc, modernc.org/sqlite), passed at 1.5 GB
  (269 s) and 2 GB; unlimited on 2 CPUs 150 s, cgroup peak 1.5 GB. Build
  cache per build: go mod download 577 MB, compile step 403 MB; the Go
  image 1.28 GB;
- send log: dbstat pages and 4.9 lines of ~150 B per message give about
  2-3 KB a message (the guide's env table already said 1-2 KB for the lines);
  mail.log ~400 KB a day of mostly port-25 noise on the test server.
Postfix's own refusal for a short disk cited from src/smtpd/smtpd_check.c,
smtpd_check_queue(). What is worked out rather than measured (the 1 000 and
10 000 a day figures, the host memory column) says so or is framed as a
minimum with room for the host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:23:35 +03:00
mixandClaude Opus 5.5 91dadf7b35 docs: preflight leaves the roadmap and the design contract
test / test (push) Waiting to run
Owner, 2026-10-18: "Preflight там почему остался?" It was not dropped with a
condition for return: what it was for shipped in other forms - its checks on
Server › Health (health-setup-checks, 2.11.0), its test e-mail as a form of
its own (test-email, 2.12.0) - and its mockup went in 2c23f4c. The entry
under Dropped, the route row and the Who-sees-what mention go; the history
is in git and the CHANGELOG. The port-check entry still says it came from
the former preflight, which is where it came from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:56:22 +03:00
mixandClaude Opus 5.5 d17fee8f81 docs: the queues stay the global role's, by the owner's decision
test / test (push) Waiting to run
Owner, 2026-10-18, asked whether a domain administrator should see their
domains' rows now that the queues are tables: "Пока только Global. Пересмотр
по необходимости." panel-redesign.md § Who sees what quotes it; roadmap.md
§ Dropped records it with the condition for return and what the work would
be.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:46:55 +03:00
mixandClaude Opus 5.5 b1882b84f8 release: 2.16.0: the queues as tables
test / test (push) Waiting to run
Outbound › Queue is the table the 2.0 mockup drew, and Inbound › Queue is
built (6db3704, design 629de5e, guards 8525e8e, e2e 853e0ca); the Overview
card names both queues; TestUnbuiltScreensAreOnTheRoadmap keeps a deferred
screen from being forgotten again. With it, two gaps the comparison of every
mockup found (6edf831). No migration, schema 14.

CHANGELOG section; pins in deploy/docker-compose.yml, README (build note)
and the guide (pin, build, the backup example). No tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:36:17 +03:00
mixandClaude Opus 5.5 853e0cabf3 e2e: the queues as tables - a deferred message on each, the empty one
- queue_readable_by_the_panel: the empty queue says "Nothing is waiting" (it
  read postqueue -p's "Mail queue is empty").
- expired_message_is_closed: while defer_transports holds the message,
  Outbound › Queue lists it with Postfix's reason ("deferred transport",
  qmgr_message.c) and a Details link to its row.
- inbound_relay_delivers_and_refuses (testInboundUpstreamDown): the message
  for an upstream that refuses is on Inbound › Queue with "connect to
  10.77.0.11[10.77.0.11]:2526: Connection refused", and not on Outbound ›
  Queue.

make e2e on the test server for 6db3704 plus this: 65 passed, 0 failed;
go vet + go test there: 26 packages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:35:50 +03:00
mixandClaude Opus 5.5 6edf8314bc user form, backup: two gaps the mockup comparison found
Owner, 2026-10-18: "Что ещё из макетов мы также прошляпили?" - every mockup
held against the panel; two small gaps beyond the queues:

- user.html: with All ticked, a list's single domains are disabled
  (mockup user.html:34-36). A panel.js show-when rule with disable: the
  boxes data-single-out / data-single-in follow data-all-out / data-all-in,
  at load and on change; a disabled box is not submitted, which the server
  ignores with All anyway. node test testdata/alldomains_test.js (fails on
  the previous panel.js).
- backup.html: "domain's settings" is a link, to Outbound › Domains (mockup
  backup.html:35 links a domain's settings; a server page has no one domain).

Tests: go test ./internal/web/... pass, TestPanelJS with node 24.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:30:44 +03:00
mixandClaude Opus 5.5 6db3704711 queue-table: Outbound › Queue as the drawn table, Inbound › Queue built
Owner, 2026-10-18: "Фиаско, что очередь не была сделана. Как мы это
допустили? Исправляем!" The mockups out-queue and in-queue (design 629de5e,
guards 8525e8e).

- postfix: postqueue -j gives each message's size and each recipient's
  delay_reason (src/postqueue/showq_json.c, format_json(): "delay_reason"
  only when LEN(why) > 0); QueueListing reads it with the page's 5 s,
  QueueMessages keeps the notifier's 30 s.
- view: one Queue page type for both directions (queue.go), one polled box
  (queue_body.html): a row per recipient, oldest first, cut at 200 with a
  foot; the head "N messages · size", "Refreshes on its own" while empty;
  Last reply is the delay reason, or where the message is (active, hold,
  incoming) before there was one. Inbound has no Details: an inbound
  message has no page of its own.
- handlers: one read of the queue, split by the recipient's domain against
  the inbound domains; an outbound recipient is linked to its send-log row
  (store.SendLogRefsByQueueID, through idx_send_log_queue_id, plan pinned in
  TestSendLogQueriesUseTheirIndexes), matched without regard to case, else
  the message's first row. Both pages are the global role's; the inbound
  one 404s with the relay off. Routes /inbound/queue (+ /fragment), menu,
  touch menu and sibling strip.
- Overview: Queues · Both empty · outbound and inbound with the relay on.
- Help: the queue topic rewritten, an Inbound queue topic (word for word with
  the mockup; domain administrators do not see it, as with the lists).
- TestUnbuiltScreensAreOnTheRoadmap and its rule in development.md: an
  exempted screen must be named in roadmap.md; queue-table leaves the
  roadmap; guide, architecture, panel-redesign.md updated.

Tests: go vet; go test ./internal/web/... ./internal/store ./internal/postfix
./internal/notify pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:17:14 +03:00
mixandClaude Opus 5.5 8525e8ee8d guard: the queues as tables - in-queue built, out-queue held to its outline
The owner, 2026-10-18, on the queue being shown as postqueue's text and the
Inbound queue never built: "Фиаско, что очередь не была сделана. Как мы это
допустили? Исправляем!" The 2026-10-09 deferral the guards encoded is over.

- deferredScreens: in-queue leaves; the page is built from now on.
- outlineExempt: out-queue leaves; its page is held to the mockup's outline
  (box[Waiting]:table) like every other. The map stays, empty.
- The route table: GET /inbound/queue and its fragment.

A guard edit on the owner's decision, alone in its commit; the tests are red
until the next commit builds the pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:59:37 +03:00
mixandClaude Opus 5.5 629de5e33a design: the queues as tables - help for both, a help link on Inbound › Queue
Owner, 2026-10-18: "Фиаско, что очередь не была сделана. ... Исправляем!" -
the queue-table row is taken into work: Outbound › Queue becomes the table
out-queue draws, and Inbound › Queue (in-queue) is built.

The two screens are drawn already; what they did not draw, drawn here first:
- Help › Queue and retries: the table, one row per recipient, with the last
  reply; mail for an inbound domain under Inbound › Queue; Details opens the
  message in the Outbound log. postqueue -p is no longer named.
- Help › Inbound queue, a topic and a menu entry of its own, and the help
  link in in-queue's Waiting head that points at it, as out-queue has.
Rebuilt; check.py --write: 31 screens, 0 violations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:58:58 +03:00
mixandClaude Opus 5.5 2f60be9a3c guard: preflight leaves deferredScreens - its mockup is gone
test / test (push) Waiting to run
The owner dropped preflight as a page of its own on 2026-10-13
(docs/roadmap.md § Dropped: "preflight as a page of its own (dropped
2026-10-13)"), and on 2026-10-18 said to clear what was left of it ("Чисти
этот хвост"). The mockup and its outlines.json key went in 2c23f4c; this
entry exempted a screen that no longer exists. A guard edit on the owner's
decision, alone in its commit.

go test ./internal/web/...: ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:49:46 +03:00
mixandClaude Opus 5.5 2c23f4c003 design: the preflight mockup goes - the page was dropped on 2026-10-13
Owner, 2026-10-18: "Чисти этот хвост." Preflight as a page of its own was
dropped on 2026-10-13 (roadmap.md § Dropped): its installation checks went
to Server › Health (health-setup-checks, 2.11.0) and its test e-mail to a
form of its own (test-email, 2.12.0). The mockup still drew it and every
mockup's navigation still linked it.

- panel/src/preflight.html and its built page deleted; build.py's Server
  group loses the entry, and the 39 pages are rebuilt (each one only loses
  that link, in the navbar and the touch menu).
- check.py --write: outlines.json loses the "preflight" key; 31 screens,
  0 violations.
- index.html and panel-redesign.md § Room for roadmap items no longer list it.

The guard's deferredScreens entry goes in the next commit, on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:48:28 +03:00
mixandClaude Opus 5.5 30fcf978e0 docs: clear shipped plans and closed work out of the documents
test / test (push) Waiting to run
Owner, 2026-10-18: "Надо почистить документы от устаревшего и сделанного."

- Deleted, shipped: plans/delivery-log-storage, from-header-binding,
  inbound-antispam-panel, inbound-upstream-result, panel-dkim-signing,
  panel-notifications, and the own-mta-builds analysis (its three rows
  shipped as 2.6.1, 2.7.0, 2.8.0). What outlived them moved first: when a
  filter-list counter is worth a migration, and the notifications' "when"
  table, into architecture.md; the sender check's own-process trigger into
  security.md. Code comments that cited them point at architecture.md or
  security.md; CHANGELOG keeps their names, unlinked.
- Kept, marked closed: audit-2026-10-12, assumptions-2026-10-14 and
  security-audit-2026-10-17 - the code cites their finding ids in 50-84
  files. Their file:line refs are of the tree each audited.
- roadmap.md: open work only - stage H of the security audit (the owner's),
  go-milter-upstream (the owner posts), and queue-table, which the
  redesign left undecided and only its own shipped section still named;
  Dropped and Ideas as they were. 2.x versioning instead of 1.x.
- development.md: the 47-row planned-version-cuts table (history, in the
  CHANGELOG) gone; a stage's last step now removes its roadmap item and
  plan. Docs map lists the closed audits.
- panel-redesign.md: the done checklist out; Room for roadmap items says
  which drawn screens are unbuilt and why.
- Stale facts fixed: schema-migrations.md and architecture.md at schema 14
  (0013's users columns and user_links, 0014's secure TLS mode);
  security.md's accepted risk "profile saved without the password" - the
  e-mail asks for it since 2.14.0.

Tests: go build, go vet; go test of cmd and the touched internal packages
pass except logtail's three Windows-bound rotation tests (rename of an open
file; CI is Linux). Only comments changed in Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:37:22 +03:00
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) Canceled after 0s
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) Canceled after 0s
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) Canceled after 0s
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) Canceled after 0s
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) Canceled after 0s
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
mixandClaude Opus 5.5 2dded921f5 release: 2.13.0: local-time - the panel in the container's time zone
test / test (push) Canceled after 0s
No migration; schema 12. 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 01:35:42 +03:00
mixandClaude Opus 5.5 bdeb01681d local-time: the panel in the container's zone, named on Settings
Agreed by the owner on 2026-10-13 (the local day, no "UTC" beside a
time); the zone is the container's TZ in .env and Settings only shows it,
his "Да" of 2026-10-17. Mockups c205106.

Every time the panel shows is formatted in time.Local and says no zone:
the logs' Time and the DMARC Received columns, a message's timeline,
Overview's "Updated", Health's certificate and renewal, the filter lists,
DMARC reports and windows, the auto limit's last recalculation. Stored
instants stay UTC. The days counted are the zone's: delivery_log_days and
sender_check_days key their rows by the local date, the sender check cuts
downtime at local midnights (byDay), the log filters' days run from local
midnight to local midnight. Notification mail and the test message write
the zone's abbreviation after a time - they are read away from the panel.
DMARC's one-day window label stays the reporter's (UTC) day.

Settings box 05, Time zone, read-only: the name (UTC without TZ), its
offset, and a TZ the image cannot load said to leave the times in UTC
(time/zoneinfo_unix.go, initLocal) rather than named. TZ is passed by the
compose file ("${TZ:-}"), in .env.example and the guide's table; tzdata is
named in the image (supervisor's python3.13 brings it today). Postfix
takes TZ through import_environment, so mail.log's stamps are the same
zone, which closes the trap the roadmap wrote down: the tailer has always
read them in time.Local.

Tests: a TestMain runs view, handlers, store and cmd/panel in UTC whatever
the machine's zone (as logtail's); TestSettingsShowsTheTimeZone,
TestTimesAreTheContainersZone, TestByDayInTheContainersZone, a Moscow day
in TestQueryInboundSpamFilters; envdoc's pattern takes two-letter names.
e2e time_zone_is_the_containers recreates the stand with
TZ=Europe/Moscow: date says MSK, Settings names the zone and UTC+03:00, a
test message's page gives its time and its mail.log lines in Moscow's.
go vet + go test (26 packages) and make e2e on the test server for this
tree.

Docs: guide (§ Settings, the TZ row, the days of Health and the logs),
architecture, schema-migrations, help (word for word with the mockup),
CHANGELOG [Unreleased] § Added, roadmap row done with the decision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 01:34:28 +03:00
mixandClaude Opus 5.5 c205106418 design: local-time - times without UTC, the zone on Settings
Agreed with the owner on 2026-10-13 (the panel in a chosen zone, "today"
that zone's day, the UTC labels gone) and settled on 2026-10-16/17: the
container runs in his zone ("Контейнер в моем поясе") - TZ in .env, read
by every program at its start - so the zone is shown on Settings, not
chosen there ("Да"), as the level-1 limit is.

Every mockup loses "UTC": the column heads are Time, Received, Held,
Arrived; a stamp is a date and a time. Settings gets box 05, Time zone,
read-only: the zone, its offset, where it is set, and that every time and
day of the panel is that zone's, as are mail.log's stamps. The help's
Settings topic says so. outlines.json: settings gains the box. check.py: 0
violations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:59:49 +03:00
mixandClaude Opus 5.5 4ff032a463 release: 2.12.0: test-email - a test message from Server › Health
test / test (push) Canceled after 0s
Cut without asking, as the owner said on 2026-10-15. A global user sends
one message from postmaster@ the hostname to an address typed; it opens in
the Outbound log, which follows it (the commit before this; mockups
e92769f, e3b2501, 8f0ccf9). Reviewed for security, its low findings fixed.
go vet, go test (26 packages) and make e2e passed on the test server for
that tree. No migration, schema 12.

CHANGELOG [2.12.0]; pins in deploy/docker-compose.yml, README and the guide
(pin, build, the sample restore message); development's "from pin" and
row 38; roadmap row. No tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:42:27 +03:00
mixandClaude Opus 5.5 f2ff8b4fea test-email: a test message from Server › Health
Decided by the owner on 2026-10-13 (out of preflight); the sender is his
choice of 2026-10-16 ("Да, адрес годится"); mockups e92769f, e3b2501,
8f0ccf9, the route guard 75f97c9.

Card 09 on Health, in the right stack's static end (the left stack is
polled whole): one recipient and Send. HandleTestMessage (global role)
takes one address (validate.AccountEmail - no list, nothing that reads as
an option), at most ten an hour counted from the send log, writes the
message's send-log row first (store.InsertTestMessage: queued, the
hostname as domain, no application, no queue id), awaits its Message-ID
(logtail.AwaitMessageID) and hands it to sendmail as notifications go
(notify.Mailer.SendWithID: postmaster@ the hostname, signed by the local
socket with the key of the server's own name). The browser lands on the
row; the tailer gives it its queue id from cleanup's message-id= line
(src/cleanup/cleanup_message.c:639) and from then on its delivery lines
its status. Postfix refusing the hand-over removes the row; sendmail out
of time keeps it (postdrop may have queued it). The sweep closes a row
whose id was never seen once it is no longer awaited
(CloseUnmatchedTestMessages); an id awaited past ten minutes is not taken.

Reviewed for security before the commit (2026-10-17): no high or medium;
its three low findings are fixed in this commit (the unmatched row's
close, the awaited id's age, the timeout keeping the row) and the
security.md bullet says how the queue id is matched.

Tests: TestTestMessage (refusals, the row and the page, Postfix refusing,
the hourly ten), TestTestMessageTimeoutKeepsTheRow, TestTestMessageRows,
TestCloseUnmatchedTestMessages, TestAwaitedMessageID,
TestRunGivesTheTestMessageItsQueueID, TestHealthTestMessageCard; e2e sends
one to the sink: the row is sent with a long queue id, the sink has it
signed d=mail.e2e.test. go vet + go test (26 packages) and make e2e on the
test server for this tree.

Docs: help (word for word with the mockup), guide § Overview and Health,
architecture § Log tailer, security.md, CHANGELOG [Unreleased] § Added,
roadmap row done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:42:21 +03:00
mixandClaude Opus 5.5 8f0ccf90cb design: test-email - the help menu leads to the Test message topic
The topic was added to the Overview group without its entry in the help's
side menu, which lists every topic of the group in order. check.py: 0
violations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:11:04 +03:00
mixandClaude Opus 5.5 75f97c9915 guard: routes - POST /server/health/test-message
The owner, 2026-10-16: "Делай все согласованные" - test-email among them,
its sender his choice of the same day ("Да, адрес годится"); the form is
Health's card 09 (mockups e92769f, e3b2501) and posts to a route under the
page it belongs to, as Re-check DNS and Reload do. The guard's route table
gets the one pattern. Nothing else under internal/web changes in this
commit; the router and the handler follow in the next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:03:34 +03:00
mixandClaude Opus 5.5 e3b25010b2 design: test-email - the Test message card in the right stack
Health's left stack is one polled element (health_stack, swapped whole
every refresh): a form there would lose the address being typed. Card 09
moves under Configuration, the right stack's static end, as Configuration
itself sits outside the poll for its button. On a phone the cards keep the
order of their numbers (data-order). outlines.json rewritten for health.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:03:21 +03:00
mixandClaude Opus 5.5 e92769f833 design: test-email - a Test message card on Health
Decided by the owner on 2026-10-13 (out of preflight), taken up on
2026-10-16; the sender is the owner's choice of 2026-10-16 ("Да, адрес
годится"): postmaster@ the hostname, as notifications. Server › Health
gets card 09, Test message: one recipient and Send. The message goes as
notifications go - through Postfix, signed with the key of the server's
own name - and opens in the Outbound log, whose page follows it to the
receiving server; ten an hour. The help gets a Test message topic: what
the test proves (the server's path, not a domain's) and what it does not
(the inbox). outlines.json: health gains the box, help one topic.
check.py: 0 violations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:02:33 +03:00
mixandClaude Opus 5.5 546a1aa461 release: 2.11.0: health-setup-checks - name, greeting, proxy on Health
test / test (push) Canceled after 0s
Cut without asking, as the owner said on 2026-10-15. Server › Health checks
the certificate's name against the hostname, shows Postfix's greeting and
where the panel sees the viewer from (the commit before this; mockups
b2dcefc, cc33890, 0feaa55). go vet, go test (26 packages) and make e2e
passed on the test server for that tree. No migration, schema 12.

CHANGELOG [2.11.0]; pins in deploy/docker-compose.yml, README and the guide
(pin, build, the sample restore message); development's "from pin" and
row 37; roadmap row. No tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:51:43 +03:00
mixandClaude Opus 5.5 a8fd96c752 health-setup-checks: the certificate's name, Postfix's greeting, the proxy
Decided by the owner on 2026-10-13 (in place of preflight), taken up on
2026-10-16; mockups b2dcefc, cc33890, 0feaa55 (his: the greeting on a row
of its own, the name check a tag beside Names).

- The certificate: health.Certificate keeps its names (DNS names, or the
  CN without any) and ForHost holds it to SELFPOST_HOSTNAME as a TLS
  client does - VerifyHostname over the DNS names, the CN only without
  any, as OpenSSL's X509_check_host and so a sending Postfix still take it
  (src/tls/tls_client.c:439, 513). A certificate for another name fails;
  the tag beside Names says matches / not for the hostname.
- The greeting: postconf -x -h smtpd_banner, read once at the panel's
  start like the served certificate's path (main.cf is not rewritten
  after), is what smtpd writes after "220 " (src/smtpd/smtpd.c:5979). A
  first word other than the hostname warns, with what to set. A fact may
  now take the whole row (Fact.Wide, sp-wide) and carry a tag beside its
  label (Fact.LabelTag); both on the kit page.
- The proxy: auth.Module.Seen says the address the panel counts sign-ins
  against, the peer, and whether X-Forwarded-For was read or left unread
  for an untrusted peer; the Configuration card shows "Your address" and,
  in the second case, warns that every visitor shares one sign-in limit
  and names the /32 (or /128) to put in TRUSTED_PROXY_CIDR.

Tests: TestCertificateForHost (CN-only, DNS names, a wildcard, the CN
ignored beside DNS names), TestSeenAddress, TestHealthSaysWhereItSeesTheViewer,
TestHealthGreetingAndCertificateName; e2e reads the stand's right state on
Health (matches, "220 mail.e2e.test ESMTP", a direct client). go vet + go
test (26 packages) and make e2e on the test server for this tree.

Docs: help (word for word with the mockup), guide § Overview and Health,
CHANGELOG [Unreleased] § Added, roadmap row done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:51:42 +03:00
mixandClaude Opus 5.5 0feaa552d1 design: health-setup-checks - the name check a tag beside Names
The owner, 2026-10-16: "Может match ставить бэйджем в заголовок names?"
The certificate card loses its own "Matches the hostname" row: a tag
beside Names says it - "matches", or "not for the hostname" in red. A
fact's label may carry a tag (sp-facts dt .tag keeps the tag's own type),
shown on the kit page. Help says where the verdict is. check.py: 0
violations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:31:01 +03:00
mixandClaude Opus 5.5 cc33890523 design: health-setup-checks - the greeting a row of its own, as Postfix writes it
The owner, 2026-10-16, on the mockup: "Может Greeting на всю ширину
карточки?" The facts grid gets sp-wide, a fact over the whole row (shown
on the kit page), and the greeting uses it. The greeting itself is what
this image's Postfix writes: "220 mail.example.org ESMTP"
(build/postfix-config.sh sets smtpd_banner to "$myhostname ESMTP"),
not Debian's longer default. The Configuration card's address sits in a
block of its own above the text. check.py: 0 violations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:27:06 +03:00
mixandClaude Opus 5.5 b2dcefc920 design: health-setup-checks - name, greeting and the trusted proxy on Health
Decided by the owner on 2026-10-13 (in place of preflight), taken up on
2026-10-16 ("Делай все согласованные"). Three lines in Health's cards,
no new card: TLS certificate - "Matches the hostname"; Hostname and
reverse DNS - "Greeting", Postfix's 220 line; Configuration - "Your
address", the address the panel sees the browser at and where it took it
from. The help says when each fails and what to do. check.py: 0
violations; outlines unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:22:38 +03:00
mixandClaude Opus 5.5 a1724ad72c release: 2.10.4: origin-strict - a change with neither header is refused
test / test (push) Canceled after 0s
Cut without asking, as the owner said on 2026-10-15. A POST with neither
Sec-Fetch-Site nor Origin is refused (the commit before this); the e2e
client sends Origin. go vet, go test (26 packages) and make e2e passed on
the test server for that tree. No migration, schema 12.

CHANGELOG [2.10.4]; pins in deploy/docker-compose.yml, README and the guide
(pin, build, the sample restore message); development's "from pin" and
row 36; roadmap row. No tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:16:28 +03:00
mixandClaude Opus 5.5 503939c6cf origin-strict: a change with neither Sec-Fetch-Site nor Origin is refused
Agreed with the owner on 2026-10-13 (in place of csrf-tokens), taken up on
2026-10-16 ("Да"). originAllowed (internal/web/security.go) returns false
in the "neither header" branch: such a POST is answered 403 and logged
like a cross-origin one. It was let through until now as an accepted
risk, which left a browser that sends neither open to a request forged by
any site. A browser that follows Fetch § 3.2 never sends a POST without
Origin; one too old to (Firefox before 70) can no longer save anything.

What posts to the panel without a browser: the e2e suite's client, which
now sends Origin on every form (panelClient.postForm); cmd/panel's tests
already did. Nothing else posts to it (the CLI runs in the container).

Tests: security_test's case of no headers is refused; e2e posts with
neither header and gets 403 (testFormPostWithTheBrowsersOrigin). go vet +
go test (26 packages) and make e2e on the test server for this tree.

Docs: security.md - the accepted risk struck, the ADR's decision and
trade-off say refused, the origin-check bullet; architecture § Web,
guide § the proxy; CHANGELOG [Unreleased] § Security; roadmap row done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:16:27 +03:00
mixandClaude Opus 5.5 af778445d4 release: 2.10.3: backup-reauth - the account password before a backup
test / test (push) Canceled after 0s
Cut without asking, as the owner said on 2026-10-15. The full backup and a
domain's export ask for the signed-in user's account password under the
password change's limit; the import's file field is drawn as designed
(1888dce design, the code commit before this). go vet, go test (26
packages) and make e2e passed on the test server for that tree. No
migration, schema 12.

CHANGELOG [2.10.3]; pins in deploy/docker-compose.yml, README and the guide
(pin, build, the sample restore message); development's "from pin" and
row 35; roadmap row. No tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:39:14 +03:00
mixandClaude Opus 5.5 49c872f836 backup-reauth: the account password before a backup or an export
Agreed with the owner on 2026-10-13, taken up on 2026-10-16 ("Да"); the
mockup is 1888dce. Both secret downloads - the full backup on Server ›
Backup and a domain's export on its settings - ask for "Your account
password": reauth (handlers_backup.go) checks the signed-in user's own
password with bcrypt, as a password change does, under the same limiter
(auth.AllowPasswordChange: ten in fifteen minutes per user, shared with the
Account form). A wrong or missing one is 401 on the same page with "Your
account password is incorrect.", logged; nothing is sent. The import and
the selfpost-backup CLI are not asked.

Also, the owner's phone screenshot of 2026-10-16 ("кнопка выбора файла не
такая, как в макете. Это косяк?"): the import's file field was a bare
<input type="file"> drawn by the browser ("Browse…"), where the mockup has
Bulma's .file - "Choose a file…" and the name chosen. The template now
follows the mockup; panel.js writes the name into .file-name on change and
at load (testdata/filename_test.js, run by TestPanelJS). No other file
field exists.

Tests: TestSecretDownloadsAskForTheAccountPassword (missing, wrong, right,
the limiter), the export refusals of the settings page with the password,
cmd/panel's restore tests and e2e's backups with it, e2e's refusal of a
backup without and with a wrong password. go vet + go test (26 packages)
and make e2e on the test server for this tree.

Docs: security.md § Backup and domain export, guide § Full backup and
restore, help (word for word with the mockup); CHANGELOG [Unreleased];
roadmap row done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:39:13 +03:00
mixandClaude Opus 5.5 1888dce1d4 design: backup-reauth - the account password before a backup or an export
Agreed with the owner on 2026-10-13 (roadmap backup-reauth), taken up on
2026-10-16 ("Да"). The Full backup form on Server › Backup and the Export
domain form on a domain's settings get one field each, "Your account
password" (autocomplete current-password, required), before the button,
saying why it is asked at every download. The help's Backup topic says both
ask for it and that wrong guesses count against the password change's
limit. check.py: 0 violations; outlines unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:13:04 +03:00
mixandClaude Opus 5.5 dd99e0f90b release: 2.10.2: phone-desktop-mode - a Menu with folded sections
test / test (push) Canceled after 0s
Cut without asking, as the owner said on 2026-10-15, after his choice of
2026-10-16 ("Вариант 1 ... сворачивание разделов"). Below 1024 px the
navigation is a Menu whose sections fold, the current one open; the page
has a gutter (e0e30e2 design, the code commit before this). go vet, go test
(26 packages) and make e2e passed on the test server for that tree. No
migration, schema 12.

CHANGELOG [2.10.2]; pins in deploy/docker-compose.yml, README and the guide
(pin, build, the sample restore message); development's "from pin" and
row 34; roadmap row. No tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:03:18 +03:00
mixandClaude Opus 5.5 3ba0e9c691 phone-desktop-mode: a Menu with folded sections below 1024 px
The owner, 2026-10-16: "Вариант 1. Но меню выглядит длинной простынёй. Там
бы сделать сворачивание разделов." As the mockup of e0e30e2: the bar's
navbar-menu loses is-active, so Bulma hides it below 1024 px, and the new
touchnav partial (layout.html) takes its place in the brand - Menu, a
<details> whose panel holds Overview, each group as a fold (the current
one open, its page aria-current), and the user's group last with Account,
Help and Sign out (the same POST form as the bar's). The same Show* flags
as the bar decide what is there. panel.css takes the mockup's rules (the
touch navigation, a 1rem gutter for the page and the sibling strip below
1024 px). No script.

The cause, measured, is written in the roadmap row: every width under
1024 px had the always-open menu; "desktop site" (~980 px) added Bulma's
side-by-side columns from 769 px; Bulma's container has no side margin
below 1024 px.

Tests: the Menu marks the current group and page and the bar's menu has
no is-active (TestMenuHighlightsTheCurrentGroupEntryAndSibling), it offers
nothing the bar does not to a domain administrator (TestTouchMenuFollowsTheBar),
the shell's Sign out is counted twice on the password page;
TestPanelCSSIsTheMockupStylesheet. go vet + go test (26 packages) and make
e2e on the test server for this tree. CHANGELOG [Unreleased]; roadmap row
done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:03:18 +03:00
mixandClaude Opus 5.5 e0e30e284a design: phone-desktop-mode - a Menu with folded sections below 1024 px
The owner, 2026-10-16, of two ways for the navigation of a narrow screen
(pictures at 390 and 980 px): "Вариант 1. Но меню выглядит длинной
простынёй. Там бы сделать сворачивание разделов."

The cause, measured: the bar's menu was always open (navbar-menu
is-active, no burger), and Bulma lays it out below 1024 px as a sheet of
every section's pages, filling the first screen - on every phone, and on
a phone in "desktop site" mode (about 980 px) under columns that Bulma
puts side by side from 769 px; and a container has no side margin below
1024 px, so the page met the screen's edge.

Now, below 1024 px: the bar's menu is hidden (no is-active), and a Menu at
the right of the brand opens a panel over the page - Overview, then each
section as a fold, the current one open, the user's last (Account, Help,
Sign out). <details> all through: no script, like the folded DNS records.
The page and the strip of sibling pages get a 1rem gutter. From 1024 px
nothing changes. check.py: 0 violations; outlines unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:44:32 +03:00
mixandClaude Opus 5.5 1dc3b7e5fe release: 2.10.1: long, non-repeating queue ids
test / test (push) Canceled after 0s
Cut without asking, as the owner said on 2026-10-15, after his choice of
2026-10-16 ("1"): Postfix makes long queue ids (enable_long_queue_ids=yes),
so a later message can no longer meet an earlier one's rows. go vet, go test
(26 packages) and make e2e passed on the test server for that tree, long
ids on both an outbound and an inbound row. No migration, schema 12.

CHANGELOG [2.10.1]; pins in deploy/docker-compose.yml, README and the guide
(pin, build, the sample restore message); development's "from pin" and
row 33. No tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:59:19 +03:00
mixandClaude Opus 5.5 22ce1bdfbb postfix: long, non-repeating queue ids
The owner, 2026-10-16, of three ways to stop a reused queue id reaching an
older row (found by the technical review of inbound-upstream-result, L6):
"1" - enable_long_queue_ids=yes. A short id is the microsecond of arrival
and the queue file's inode in hex (Postfix 3.10.13, src/global/mail_queue.h,
MQID_SH_*) and repeats once the inode comes round; a later message with no
send-log row of its own (port-25 mail, a bounce Postfix sends) could then
rewrite an older Outbound log row of the same id and recipient, its page
could show the later message's stored lines (security.md's known exposure,
"enable_long_queue_ids ... ends it"), and the inbound journal's rows needed
detaching. A long id adds the time in seconds and does not repeat
(MQID_LG_*; proto/postconf.proto: existing queue files keep their names,
postqueue -p gets a wider Queue ID column). Every pattern of the panel
takes both shapes (queueLineRe's long branch, the [0-9A-Za-z]+ of the
others, queueEntryRe) - checked one by one.

Tests: e2e reads a long id on an outbound send-log row
(testExpiredMessageIsClosed) and on an inbound journal row
(testInboundUpstreamDown). go vet + go test (26 packages) and make e2e on
the test server for this tree.

Docs: security.md (the collision bullet closed for new mail, the upstream
bullet), architecture § Log tailer, delivery-log-storage's note, the
ForgetInboundQueueID comment; CHANGELOG [Unreleased] § Fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:59:18 +03:00
mixandClaude Opus 5.5 f4f6e0f23d release: 2.10.0: inbound-upstream-result - the upstream's answer in the Inbound log
test / test (push) Canceled after 0s
Cut at the owner's word (2026-10-16): "потом режь" after the check, which
he allowed ("Отправляй").

Inbound › Log says what became of accepted mail - queued, held, sent,
deferred, bounced - with the upstream's last reply, the host and when
(5348330, 295d72e, 46b90d2 design and plan; d515493 code, the reviews'
findings fixed in it). Migration 0012, schema 12.

On the test server as 2.9.1-post.d515493 (the same code), 2026-10-16:
healthy, every program RUNNING, keys unchanged, schema 12. One message
sent from the server to its own port 25 for selfpost@dev.mixdep.ru: its
row sent, "250 2.0.0 Ok: queued as 04F0D1023EF" from mc-ent.mixfed.ru. (The
owner's own message had gone to mc-ent directly: SelfPost is
dev.mixdep.ru's second MX.)

CHANGELOG [2.10.0]; pins in deploy/docker-compose.yml, README and the guide
(pin, build, the sample restore message); development's "from pin" and row
32; roadmap row and recommended order; plan closed. No tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:03:38 +03:00
mixandClaude Opus 5.5 d51549385b inbound-upstream-result: the upstream's answer in the Inbound log
test / test (push) Canceled after 0s
The owner, 2026-10-16: the Inbound log lacks the upstream's answer - "Не
понятна судьба письма: принято, отклонено, хост не доступен"; the result
in the row only, a page per message later if needed. Mockups 5348330 and
46b90d2, plan 295d72e.

- Migration 0012: inbound_spam_log gains queue_id, upstream_status ('',
  queued, held, sent, deferred, bounced; CHECK), upstream_relay,
  upstream_reply, upstream_at, and a partial index on queue_id. Schema 12.
- The inbound journal milter writes an accepted row with the queue id of
  end-of-message and "queued" ("held" when the filter had it held), the
  recipient as Postfix logs it (recipientAsLogged), after detaching older
  rows of a queue id Postfix uses again; no queue id, no result.
- The tailer: a delivery line no send-log row took goes to the journal -
  status, relay= ("" for none), the reply after the first status=, cut to
  255 printable bytes, the line's time; queued/held/deferred rows only,
  sent and bounced final. qmgr's expiry makes them bounced, keeping the
  last reply. cleanup's "removed (" detaches rows of a message never
  queued. Addresses in delivery lines are read by Postfix's quoting
  (loggedAddress): a quoted RCPT TO cannot end one early.
- The page: an Upstream column - a plain tag for queued and held, else a
  tag opening the host, the reply, when and what follows; "—" for refused
  mail and older rows. Help (word for word with the mockup), guide,
  architecture, security, schema-migrations, CHANGELOG.

Reviewed before the commit (technical and security, 2026-10-16); the plan
lists each finding and what it became.

Tests: store (results, finality, expiry, held, a reused id, the queue-id
index in every plan), milter (queue id, held, quoted recipient, no {i}),
logtail (relay and reply, a forged quoted recipient, Run hands the journal
what the send log did not take, cleanup's removal), view (the column, held,
null sender), e2e (both relayed messages sent with the sink's 250; an
upstream refusing connections deferred with the reason and no host, then
expired to bounced; the refused row has none). 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-16 16:35:09 +03:00
mixandClaude Opus 5.5 46b90d2a8d design: inbound-upstream-result - held, and a bounce with no return address
Findings of the reviews of the row (2026-10-16): a message the filter had
held is "held" in the Upstream column - in the queue, not tried until it is
released - and not "queued"; and a bounced message is said to have bounced
to its sender only when it had a return address (Postfix does not bounce
mail from the null sender). The help's Inbound log topic says both.
check.py: 0 violations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:22:09 +03:00
mixandClaude Opus 5.5 6531db977e release: 2.9.1: log panes wrap on a phone
test / test (push) Canceled after 0s
Cut without asking, as the owner said on 2026-10-15, for his report of
2026-10-16 (two scroll bars over a message's Delivery log on his phone).

Log panes wrap their lines under their start instead of scrolling
sideways (216f366 design, bd21ff0 code): the Delivery log of a message,
System log, the queue's listing. go vet, go test (26 packages) and make
e2e passed on the test server for that tree. No migration, schema 11.
The inbound-upstream-result mockup and plan (5348330, 295d72e) ride
along; its code follows for 2.10.0.

CHANGELOG [2.9.1]; pins in deploy/docker-compose.yml, README and the
guide (pin, build, the sample restore message); development's "from pin"
and row 31. No tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:31:58 +03:00
mixandClaude Opus 5.5 bd21ff0f90 log panes: lines wrap under their start instead of scrolling sideways
The owner, 2026-10-16, a phone screenshot of a message's Delivery log:
"На телефоне при просмотре лога, появляются две полосы. Мы это можем
поправить?" - the horizontal scroll bars Chrome on Android drew over a
pre whose mail.log lines were wider than it. As the mockup of 216f366:
sp-log wraps (pre-wrap, overflow-wrap: anywhere) and no longer scrolls;
log_pane writes each line as a block, sp-ll, with a 4ch hanging indent and
its newline inside it, so copied text keeps one line per line. One
partial: the Delivery log of a message, System log, the outbound queue's
listing.

Tests: TestLogPaneIsAPreWithEscapedLines reads the new shape; the page
tests' substrings stand; TestPanelCSSIsTheMockupStylesheet holds panel.css
to the mockup. go vet + go test (26 packages) and make e2e on the test
server for this tree. CHANGELOG [Unreleased] § Fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:31:17 +03:00
mixandClaude Opus 5.5 216f366dfa design: log panes wrap their lines instead of scrolling sideways
The owner, 2026-10-16, a phone screenshot of an outbound message's Delivery
log: "На телефоне при просмотре лога, появляются две полосы. Мы это можем
поправить?" The two stripes are the horizontal scroll bars Chrome on
Android draws over a pre whose mail.log lines are wider than it
(overflow-x: auto); and what matters on a delivery line - status= and the
reply - was at its end, out of sight.

sp-log wraps (white-space: pre-wrap, overflow-wrap: anywhere) and no longer
scrolls; each line is a block, sp-ll, with a hanging indent of 4ch, so the
rest of a wrapped line stands under its start and a new line is told by its
time at the left. The line keeps its newline inside its block, so copied
text still has one line per line. Every pane of the kit: components,
system-log, out-message, in-quarantine-message. check.py: 0 violations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:15:10 +03:00
mixandClaude Opus 5.5 295d72e721 plan: inbound-upstream-result - the upstream's answer in the Inbound log
The owner, 2026-10-16: the Inbound log lacks the upstream's answer; the
result in the row only, a page per message later if needed. One stage,
2.10.0: migration 0012, the milter keeps the queue id, the tailer hands
port-25 delivery lines to the journal, an Upstream column (mockup 5348330).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:11:06 +03:00
mixandClaude Opus 5.5 5348330d2b design: inbound-upstream-result - an Upstream column in the Inbound log
The owner, 2026-10-16: "Нам не хватает в журнале для Inbound ответа
upstream. Не понятна судьба письма: принято, отклонено, хост не доступен и
пр." and, asked whether a result in the row or a page per message: "Да,
согласен. Пока только результат. Отдельная страница в план в случае
появления потребности."

Inbound › Log gets an Upstream column after Decision: what became of an
accepted message - queued (a plain tag: not tried yet), sent, deferred,
bounced - each but queued a tag that opens an "Upstream" sheet with the
host that answered (left out when Postfix had no session: relay=none,
src/smtp/smtp_trouble.c:273, 421 in Postfix 3.10.13), the last reply
(the "(...)" of the delivery line, src/global/log_adhoc.c) and when it
came, with what happens next for deferred and bounced. A refused message
has "—". The lead and the box foot say so; the help's Inbound log topic
replaces "Accept says what this server did, not what the upstream did"
with what each result means, and notes that rows logged before 2.10.0
have none. A new first row shows queued.

Outline unchanged (the box is still a table). check.py: 0 violations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:10:23 +03:00
mixandClaude Opus 5.5 446feadd74 release: 2.9.0: DNS records folded, Settings re-checks, /data/opendkim gone
test / test (push) Canceled after 0s
Cut at the owner's word (2026-10-16, "потом режь 2.9.0").

dns-record-collapse (fffee9c, 51f303c): a record DNS holds as shown is one
line, its values behind Show values. settings-save-row (f95c553,
82a53e1): the Settings form is the left column, Save under its fields.
notification-mail-recheck (98e4dfc, 52a9811, 3f5cd9a): Re-check DNS on
Settings, POST /server/settings/dns-recheck. And the release after 2.8.0
removes /data/opendkim (9420d8c): the entrypoint deletes the 2.7.x key
tree once /data/dkim holds a key, and stops on a /data that never ran
2.8.x.

Each tree passed go vet, go test (26 packages) and make e2e on the test
server; dns-record-collapse and settings-save-row ran there as
2.8.0-post.51f303c and 2.8.0-post.82a53e1. No migration, schema 11.

CHANGELOG [2.9.0]; pins in deploy/docker-compose.yml, README and the guide
(pin, build, the sample restore message; the 2.7.x rollback command keeps
naming 2.7.0); development's "from pin" and row 30; roadmap rows and
recommended order. No tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:46:26 +03:00
mixandClaude Opus 5.5 9420d8c03d entrypoint: /data/opendkim removed, as planned for the release after 2.8.0
2.8.x copied the keys from /data/opendkim to /data/dkim and kept the old
tree for a rollback to 2.7.x "until the next release"
(docs/plans/panel-dkim-signing.md, Q2); the owner's "потом режь 2.9.0"
(2026-10-16) makes this that release. The entrypoint now removes the tree
once /data/dkim holds a key, and says so. A /data with keys under the
old tree only - one that never ran 2.8.x - stops the start with a message
to run 2.8.x first, rather than let the panel give the server's own name
a new key its published record would not match; the tree is left as it
was. Gone with it: the copy into /data/dkim.new, the tree's
normalisation, its exemption from the general hand-over, its key link in
drop_links, and the backup's exclusion of "opendkim" (dead: the panel
never sees the tree now).

Tests: e2e TestStartsOnA28Data replaces TestStartsOnA27Data - a /data as
2.8.x left it (both trees, a queue of foreign uids, a rotation due): the
old tree is gone, the key under /data/dkim is the one left, the selector
table names it, the queue is Postfix's by name; TestRefusesA27Data - the
start stops, says why, leaves the old key and makes no new one. go vet +
go test (26 packages) and make e2e (all pass) on the test server for this
tree.

Docs: guide § Upgrading "To 2.9.0" (rollback to 2.8.x needs nothing, to
2.7.x only from a 2.7.x backup; the 2.7.x rollback paragraph says it is
for 2.8.x only and keeps naming 2.7.0), guide § backup, architecture
(the old tree, § Persistence's table and the restore line), security
§ Backup, development's e2e paragraph, the plan's key step; CHANGELOG
[Unreleased] § Removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:46:04 +03:00
mixandClaude Opus 5.5 3f5cd9adf0 notification-mail-recheck: Re-check DNS on Server › Settings
test / test (push) Canceled after 0s
The owner, 2026-10-16: "Да, бери notification-mail-recheck". The records
of the server's own name can be checked again where they are shown: Re-
check DNS in Settings' page head (mockup 98e4dfc) posts to
POST /server/settings/dns-recheck (guard 52a9811), global role, which
runs Health's lookups - the server's address, then serverMail(true) - and
returns to /server/settings?rechecked=1, "DNS re-checked.". The
Notification mail box's end slot says when the records were checked
("The server's own name · checked 3 min ago", from the check's
CheckedAt; the old wording while nothing was looked up). A record that
has turned right comes back folded.

Tests: the page head's form and the end slot, and the Settings form
holding neither the re-check nor the right column
(TestSettingsRechecksTheMailRecords); the re-check skips the cache,
redirects and the page shows both records folded
(TestSettingsRecheckAsksForNotificationMailAgain); a domain
administrator gets 404 (globalOnlyRoutes). go vet + go test (26
packages) and make e2e on the test server for this tree.

Docs: architecture.md routes, the panel-redesign plan's route table,
guide § Settings, help (help.go word for word with the mockup's);
CHANGELOG [Unreleased]; roadmap row done. The roadmap's settings-save-row
note no longer claims the left-column form was needed for this - the
action went to the page head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:29:54 +03:00
mixandClaude Opus 5.5 52a9811baa guard: routes - POST /server/settings/dns-recheck
The owner, 2026-10-16: "Да, бери notification-mail-recheck, потом режь
2.9.0." The row's goal is a Re-check DNS action where the records of the
server's own name are shown (Server › Settings, mockup 98e4dfc); it
posts to a route of its own under the page it returns to, as a domain's
`.../dns-recheck` does. The guard's route table gets the one pattern.
Nothing else under internal/web changes in this commit; the router and
the handler follow in the next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:12:41 +03:00
mixandClaude Opus 5.5 98e4dfcda4 design: notification-mail-recheck - Re-check DNS on Settings
The owner, 2026-10-16: "Да, бери notification-mail-recheck, потом режь
2.9.0."

Server › Settings gets Re-check DNS in its page head, as an outbound
domain's page has it beside its own DNS records box; the Notification
mail box's end slot says when the records were checked ("The server's
own name · checked 3 min ago"), as the domain's DNS box does. The help's
Notifications topic says Settings shows whether the records are in place
and re-checks them. The button sits in the page head rather than in the
box head: box heads hold a note, a tag, a link or a filter form, and the
page head is where the domain page and Health put the same action.

outlines.json: settings "head" -> "head+actions". check.py: 0 violations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:12:24 +03:00
mixandClaude Opus 5.5 82a53e111c settings-save-row: the Settings form is the left column
test / test (push) Canceled after 0s
The owner chose Variant 2 on 2026-10-16 ("форма в левой колонке, кнопка
под своими полями"); the mockup is f95c553. Server › Settings: the form
wraps only the left column - Log retention, Notifications and Save
settings under them - and the right column, outside the form, holds
Notification mail and the read-only Sending rate limits. The boxes are
renumbered column by column (Notifications 02, Notification mail 03,
Sending rate limits 04). The fields, their names and the POST are
unchanged.

Tests: the page's outline matches the mockup's (TestPanelOutlinesMatchMockups);
go vet + go test (26 packages) and make e2e on the test server for this
tree.

CHANGELOG [Unreleased]; roadmap row done, its section quotes the choice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:04:06 +03:00
mixandClaude Opus 5.5 f95c5530c7 design: settings-save-row - the form in the left column
The owner, 2026-10-16, on the pictures of both layouts with the records
missing and right: "Вариант 2: форма в левой колонке, кнопка под своими
полями."

Server › Settings: the form holds only the left column - 01 Log
retention, 02 Notifications, Save settings under them - so the button
sits under the fields it saves whatever the records' state. The right
column, outside the form, holds what is not a field: 03 Notification
mail (the records of the server's own name) and 04 Sending rate limits
(read-only). The boxes are numbered column by column, as before.

outlines.json rewritten for settings (check.py --write): the form moved
from around the columns into the first one; nothing else changed.
check.py: 0 violations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:50:23 +03:00
mixandClaude Opus 5.5 51f303c466 dns-record-collapse: a published record is drawn folded
test / test (push) Canceled after 0s
The dns_record partial draws a record whose tag is "published" and that
has no "In DNS now" (Record.Folded) as a <details> whose summary is the
head - name, tag, "Show values" - with the host, type and value behind
it, as the mockup of fffee9c. Every other record stays a <div>, open.
One partial, so the outbound domain's page, the inbound domain's and
Settings › Notification mail change together; panel.css takes the
mockup's rules, help.go the help's sentence word for word.

Re-check DNS posts and redirects to the page, which is drawn from the
fresh check: a record that has just turned right comes back folded. No
page that draws records polls them, so nothing has to keep a fold open.

Tests: the dns_record partial folds only a published record and closes
the block it opened (TestDNSRecordFoldsOnlyWhenPublished); the mismatch
test reads the folded head. go vet + go test (26 packages) and make e2e
on the test server for this tree.

CHANGELOG [Unreleased]; roadmap row done, its section says what was
decided.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:34:28 +03:00
mixandClaude Opus 5.5 fffee9cb5d design: dns-record-collapse - a published record is folded
A record DNS already holds as shown is a <details> whose summary is the
record's head: its name, its tag and "Show values" (ti-eye - the icon
subset has no chevron); the host, type and value open behind it. The
head's note is hidden while folded, so the fold stays one line in a half
column (the server's SPF on Settings); it shows when opened. Missing,
weak, mismatched and unchecked records stay open as before.

Mockups: the kit's dns_record example, out-domain (DKIM and DMARC
folded, SPF open on its mismatch), in-domain (MX), settings (DKIM
folded, SPF missing and open); the help's DNS topic says so. Outlines
unchanged (the block keeps sp-record). check.py: 0 violations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:21:49 +03:00
mixandClaude Opus 5.5 1af0d3f1c8 roadmap: go-milter-upstream prepared - the owner posts
test / test (push) Canceled after 0s
The issue, the PR text and a branch of go-milter (the map emptied on
SMFIC_MAIL, a test that fails on master) are in dev/upstream-pr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:02:43 +03:00
mixandClaude Opus 5.5 ac21180d58 release: 2.8.0: panel-dkim-signing - the panel signs, OpenDKIM gone
test / test (push) Canceled after 0s
Cut at the owner's word (2026-10-16, "Выпускай").

The panel signs outgoing mail in a milter of its own (go-msgauth,
relaxed/simple, the login's key on 465/587 and the server's name's on
local mail); OpenDKIM, the syslog relay, the USR1 reload and the From:
parser port are gone. Keys move to /data/dkim (copied once; /data/opendkim
kept for a rollback, left out of backups). The sender check holds Sender:
like From:. go-milter is built patched not to keep every header. The queue
is handed to Postfix's users by name before "postfix check". Commits
005922b..ca3a183 (005922b, faa3c11, c2f63d2, f0dd9f9, 2cfd239, d9d9af3,
0cceb54, 80faf1a, f1d90a2, ca3a183).

On the test server since 2026-10-15 22:48 UTC (2.7.0-post.c2f63d2, now
2.7.0-post.f1d90a2, the same code as this cut): healthy, keys unchanged.
The first rollout (faa3c11) crash-looped five minutes on the queue's owner
numbers and was rolled back; fixed in c2f63d2. The owner's canary: Gmail
dkim/spf/dmarc pass with d=carrel.mixfed.ru, Yandex dkim/spf pass, not
spam; his From:/Sender: check, thirteen cases, all as expected.

CHANGELOG [2.8.0]; pins in deploy/docker-compose.yml, README and the guide
(the guide's rollback command keeps naming 2.7.0 on purpose); development's
"from pin"; roadmap row and recommended order; plan stage 3 closed. No tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:36:54 +03:00
mixandClaude Opus 5.5 ca3a1834da roadmap: go-milter-upstream - an issue and a PR for the header map
test / test (push) Canceled after 0s
Agreed by the owner (2026-10-16): prepare an issue and a small compatible
PR for go-milter (the map emptied when a message starts), with an option
not to keep it proposed; the owner posts them. Then third_party/go-milter
can go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:24:07 +03:00
mixandClaude Opus 5.5 f1d90a2a1b go-milter: build it patched not to keep every header
test / test (push) Canceled after 0s
The security documentation's last open item of panel-dkim-signing (the
owner's decision, 2026-10-16): go-milter v0.4.1 added every header of a
session to a map of its own, emptied only on ABORT, and handed it to
Headers - which no milter of the panel reads. Postfix bounds one header,
not their number, so a message of nothing but headers up to the message
size limit was held whole by the library in the sender check, the signer
and the journal milter on 465/587 and in the inbound journal milter on port
25, where strangers send; the signer's 1 MiB refusal came after it was all
in.

third_party/go-milter is v0.4.1 from the module cache (BSD 2-Clause,
LICENSE kept) with session.go changed: CodeHeader hands the header to
Header and keeps nothing, CodeEOH hands Headers an empty map. PATCHES.md
says what and why, and how to take a new upstream version. go.mod replaces
the module with it; the Dockerfile copies third_party before "go mod
download". TestTheLibraryKeepsNoHeaders holds it through the library's own
client over two messages of one connection. Comments, security.md (the
accepted risk becomes a note), development.md's library table, the plan and
CHANGELOG follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:19:57 +03:00
mixandClaude Opus 5.5 80faf1a901 logrotate: no reload of a Postfix that is not running yet
test / test (push) Canceled after 0s
The rollout of 0cceb54 to the test server (2026-10-16 04:44 UTC) logged
"postfix/postlog: fatal: the Postfix mail system is not running" and
"error running non-shared postrotate script": the day's rotation was due,
logrotate-loop.sh runs at once when the container starts, and the wrapper
starts Postfix only once the milter sockets are there. Nothing was lost -
no postlogd held the old file, and the one Postfix started a second later
opened the new - but logrotate reported a failure and the loop waited six
hours. The postrotate now runs "postfix reload" only when "postfix status"
says a master runs (Postfix 3.10.13, conf/postfix-script:297-307).

e2e TestStartsOnA27Data also starts on a mail.log whose rotation is due
(a state file of two days before) and wants it rotated with no postrotate
error in the container's log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 07:57:09 +03:00
mixandClaude Opus 5.5 0cceb549cc sender check: a Sender: line is held to the rule From: is held to
test / test (push) Canceled after 0s
The owner's decision (2026-10-16) on the security review's F1: a Sender:
line - the sending agent's, shown beside From: as "on behalf of", and
signed since 2.8.0 - was neither held to the login nor, before, signed: an
application could write its own From: and Sender: ceo@bank.com under this
server's signature. Now, when a message has one, it must be one address
the application may send as, and at most one such line (RFC 5322 § 3.6);
From: is judged first.

The refusal is "550 5.7.1 Sender: <address> - <why>", a send-log row of
reason "sender" (store.ReasonSender), tagged "wrong Sender"; the message
page names the line. The log tailer recovers such a row from cleanup's line
as it does a From: refusal (fromRefusalRe reads either). Help text as in
the design commit d9d9af3; guide, architecture, security and CHANGELOG say
it, security.md keeps Resent-From: (signed, not judged) as an accepted
risk.

Tests: TestSenderLineHeldToTheSameRule (own address, a name, another
application's, a stranger's, two addresses, two lines, From: first), the
recovery of a Sender: refusal line, the tag, the message page; e2e
sender_check_holds_the_sender_line (own Sender: passes and is signed,
another domain's refused at DATA and logged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 07:41:42 +03:00
mixandClaude Opus 5.5 d9d9af3932 design: help - the sender check holds Sender: too
The owner's decision (2026-10-16, security review of panel-dkim-signing,
F1): a Sender: line, shown beside From: as "on behalf of", is held to the
same rule when a message has one. The topic says how a system that writes
for people outside the server's domains does it instead (its own From:, the
person in Reply-To:), and the Outbound log topic names Sender: among the
refusals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 07:22:31 +03:00
mixandClaude Opus 5.5 2cfd239d16 docs: panel-dkim-signing stage 3 - the documents without OpenDKIM
test / test (push) Canceled after 0s
architecture § OpenDKIM becomes § DKIM signing (keys, the selector table
and when it is rewritten, the two sockets, what is signed and the limits,
the old tree); the process table, start order, liveness, the mail-path
diagram, § Sender check, Persistence and the restore Resync follow the
code. security.md: the panel signs with the login's key, the keys are the
panel's alone, group selfpost and CAP_KILL as they are now; OpenDKIM's
accepted risks go; new ones: what sendmail(1) submits is signed as the
server, Sender: is signed but not judged (the owner's decision), and
go-milter keeps every header per session, so the signer's 1 MiB refusal
does not bound the library's copy. guide: Health, the container log's
dkim-signer lines, backups, the long-line limit, and upgrading to 2.8.0 -
with the rollback to 2.7.x: its start reads the queue as its own postfix
(uid 101) before it hands the queue over, so the hand-over by name is run
in the 2.7.x image first (measured on a copy of the test server's queue:
postsuper failed before, passed after). development, README, SECURITY,
product, the assumptions register's header: what left the image.
entrypoint.sh: a comment, the rollback note as the guide gives it. Plan
stage 3 lines ticked, roadmap 12/14.

Written by four agents, one document each, against a brief of what the
code does; reviewed here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:56:56 +03:00
mixandClaude Opus 5.5 f0dd9f943e build: go-msgauth is a direct requirement (go mod tidy)
internal/milter/sign.go imports it since stage 1; go.mod still had it as
indirect, as the e2e suite's dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:56:56 +03:00
mixandClaude Opus 5.5 c2f63d2789 panel-dkim-signing: the queue by name, and findings of the reviews
test / test (push) Canceled after 0s
The first rollout of faa3c11 to the test server crash-looped: without
opendkim the image's package accounts got other numbers (postfix 101 ->
100, postdrop 103 -> 102), the queue under /data kept the old ones, and
postsuper, which "postfix check" runs as the mail owner
(src/postsuper/postsuper.c:1442), could not open it. Rolled back within
five minutes. The entrypoint now hands an existing queue to Postfix's users
by name before postfix-config.sh, and again after it (own_queue).

Technical review (F1-F10):
- F1 high: a backup of an upgraded installation carried /data/opendkim, a
  restore left it root's, and the panel refused every start. The tree is
  left out of archives, and the entrypoint makes it the panel's alone
  (0700/0600; also the security review's F3 - nothing of 2.8 needs the
  group any more; a 2.7.x start re-normalises it).
- F2: List-Unsubscribe-Post is signed (RFC 8058); Sender too (security F1).
- F3: SetServerName keeps the name in lower case - a key written under
  "Mail.Example.com" was never found. Only the DKIM side: the SASL realm
  is compared as written.
- F4: the signer rewrites the selector table at every start
  (WriteSelectors, replacing EnsureServerSigned), and tries again every
  10 s while a rewrite fails.
- F5: e2e signature_over_awkward_headers (folded, tab-led, no space, two
  To:, 8-bit body, List-Unsubscribe-Post) checked by dkimpy;
  TestStartsOnA27Data starts on a 2.7 /data (old key tree, a queue of
  foreign numbers) and checks the copied key signs, the old tree's modes
  and the queue's owners; unit tests for repeated headers and the header
  limit.
- F6: the table's writers are serialised (tableMu).
- F7: the table is read for every message; keys the table no longer names
  are dropped from memory.
- F8: the signature's value brings its own space (SMFIP_HDR_LEADSPC gets
  none from Postfix, cleanup_milter.c:1085).
- F9: a body line over 998 bytes is logged with its domain: Postfix's SMTP
  client breaks it after signing (smtp_proto.c:1266-1274).
- F10: the keys are copied while /data/dkim holds none (not only when it
  is absent), after the old tree is made the panel's (an install from 2.6
  had another uid); the plan's rollback note says what a rollback keeps.

Security review: F2 a header section over 1 MiB is refused (552 5.3.4)
and the kept headers freed once the block is built - fifty connections of
header-only messages could take the panel down; F5 the shown headers a
message has (Subject, Date, To, Cc, Reply-To, Sender) are over-signed,
only when present, since cleanup adds a missing Date: after the milters.
F1 (holding Sender: to the login like From:) changes what is refused and
waits for the owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:44:59 +03:00
mixandClaude Opus 5.5 faa3c11ae7 panel-dkim-signing stage 2: OpenDKIM removed
test / test (push) Canceled after 0s
The panel's signer is the only one. Gone: the opendkim package and its
configuration, the syslog-relay and opendkim programs, /dev/log and
/run/opendkim, the reload by USR1 through supervisord
(internal/supervisor/supervisor.go), internal/syslogrelay, the port of
OpenDKIM's From: parser (internal/milter/mailparse.go and its probe,
harness and test/upstream scripts), OPENDKIM_SOCKET and OPENDKIM_DIR.

Keys: internal/domain/keys.go (Keys, NewKeys, DKIM_DIR=/data/dkim) keeps
the keys and writes a selector table, /data/dkim/selectors ("<name>
<selector>"), which the signer reads in place of the KeyTable - it reads
no database, as the sender check reads none. The table is written before
the signer serves (EnsureServerSigned moved there from the notifier).
Keys and table 0600, directories 0700, the panel's. The entrypoint copies
/data/opendkim/keys once, as the panel user, into /data/dkim.new renamed
when whole, and stops the start if the copy fails; /data/opendkim is left
for a rollback to 2.7.x.

Sender check: one RFC 5322 address as a mail program reads it, held
against the bindings; the 256-byte refusal and the texts naming OpenDKIM
go. Allowed() folded the domain through strings.ToLower, which makes the
Kelvin sign a "k" - the comparison with OpenDKIM's reading had hidden it;
it folds ASCII only now.

Health: two dkim-signer rows (applications, notifications) in Milter
sockets, opendkim out of the liveness set; the card no longer takes any
row with a note for the spam filter. Texts as in the design commit.

KILL stays in deploy/docker-compose.yml: measured on the test server,
root without it cannot signal a process of another uid - supervisord
stopping the panel, Postfix's master its children. The plan's "only use
was the reload" was wrong; the comment now gives the real uses.

e2e: one signature, the panel's, verified by dkimpy; the /data/dkim modes;
the OpenDKIM log and process checks, testNeverSignedForAnother and the
cited opendkim, libopendkim11 and libmilter1.0.1 go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:05:39 +03:00
mixandClaude Opus 5.5 005922bc7d design: panel-dkim-signing stage 2 - the pages without OpenDKIM
Health: the opendkim process row goes; the Milter sockets card shows the
panel's signer on its two sockets, dkim-signer · applications (sign.sock)
and · notifications (sign-local.sock) - Q3 of the plan; Configuration
says it reloads Postfix. Domain delete: "stop signing mail as the
domain" for the OpenDKIM reload. System log: mail.log is Postfix's log
(OpenDKIM never wrote to it), its sample line goes. Message page: the
sample's OpenDKIM line becomes a qmgr line. Help, sender check: the
From: line is read as a mail program reads it and must be one address;
DMARC holds the signature to it; the sentence the mockup carried twice
is once. Overview: 5 of 5 sockets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 00:15:46 +03:00
mixandClaude Opus 5.5 fa97deb677 docs: drop the Cursor rule of the removed panel-ui mockups
test / test (push) Canceled after 0s
.cursor/rules/panel-ui-system.mdc named docs/assets/panel-ui the source of
truth for mockups; that folder is gone (6099716) and the redesign's rule
(.cursor/rules/panel-design.mdc) is the one in force. The two plans that
mention the removed folders say they were removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 00:10:03 +03:00
mixandClaude Opus 5.5 6099716bf4 design: remove the 1.x panel-ui mockups and the redesign's stage-1 directions
docs/assets/panel-ui (the 1.x experiment, superseded by the redesign) and
docs/assets/panel-redesign/{a,b,c} (the three stage-1 directions; C was
taken) are history - the plan says so - and nothing builds, checks or
links them but the page that compared the directions. That page,
docs/assets/panel-redesign/index.html, which the documents link as the
way into the mockups, is now that: the screen map and what the design
holds, with a line saying where the removed folders are (git history).
build.py and check.py: 32 screens, 0 violations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 00:09:53 +03:00
mixandClaude Opus 5.5 43ebbc9b20 plan: panel-dkim-signing - stage 1 done (14de9a0)
test / test (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:49:26 +03:00
mixandClaude Opus 5.5 14de9a05c8 panel-dkim-signing stage 1: the panel's DKIM signer, beside OpenDKIM
internal/milter/sign.go: a milter on two sockets (cmd/panel/signer.go):
sign.sock for 465/587, the key of the domain the SASL login belongs to
(Bindings.DomainOf, the sender_login_maps Postfix reads), and
sign-local.sock for the panel's own sendmail(1) mail, the server's name.
go-msgauth/dkim v0.7.0, relaxed/simple, OpenDKIM's dkim_should_signhdrs[]
(libopendkim/dkim.c:223-246) in message order plus From once more; keys
and selectors from the KeyTable the panel writes, a key path outside the
key directory refused; strict - whatever cannot be signed is deferred.

From the sources: Postfix 3.10.13 milter8.c:2329-2400 hands a folded
header \n-separated, so the signer joins its lines with CRLF; inserts are
1-based in cleanup_ins_header() (cleanup_milter.c:1193), so index 0 is the
top, where OpenDKIM puts its own (opendkim.c:3673). go-milter v0.4.1 calls
no Abort when a connection ends, so a watchdog closes go-msgauth's
streaming signer after an hour and a late end of message is deferred.

Postfix: smtpd_milters = sender check, signer, OpenDKIM, journal;
non_smtpd_milters = the signer's local socket, OpenDKIM - both sign every
message until stage 2. disable_mime_output_conversion=yes (default off,
mail_params.h:3088): an 8-bit message is no longer re-encoded after it is
signed.

Tests: unit tests sign and verify folded, tab-led and space-free headers,
an added From:, a changed body, two messages on a connection, odd bodies,
and five cases deferred. e2e: the sink image has python3-dkim (dkimpy
1.1.8-2) and the stand's DNS; on an application's message and on a
notification the two signatures must agree on h=, bh=, c=, s=, a= and each
must pass dkimverify. On the server: make e2e exit 0 (714 s), go vet + go
test 27 packages ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:49:14 +03:00
mixandClaude Opus 5.5 a07ba5776c plan: panel-dkim-signing - agreed; Health shows the signer's two sockets
test / test (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:14:27 +03:00
mixandClaude Opus 5.5 a556a13f67 release: 2.7.0: base-trixie - Debian trixie, Postfix 3.10.13
Cut without asking, as the owner said on 2026-10-15.

The image runs debian:trixie-slim with Postfix 3.10.13, libmilter
8.18.1, OpenSSL 3.5; every citation re-read against the trixie sources.
smtpd_hide_client_session=yes on 465/587 (owner's decision). The
selfpost group is 10998 and the panel 10999:10999, outside the system
range; the entrypoint re-owns /data by name. The panel's database is
0600; the entrypoint removes symbolic links the panel could leave under
/data. Commits 68ec8b2, 1c6e04b, a3050af.

Rolled out to the test server as 2.6.1-post.a3050af (the same code),
2026-10-15 17:07 UTC: healthy, every program RUNNING, no restarts, three
messages sent, the queue empty, no warning but outside password guessing.
The canary: one message from an application through 465 to Gmail, Yandex
and the test mailbox - Received: in the short form everywhere; Gmail
dkim=pass spf=pass dmarc=pass; Yandex spf=pass dkim=pass, not spam.
No migration, schema 11.

CHANGELOG cut; pin 2.7.0 in deploy/docker-compose.yml, README, guide (and
its sample restore message); development.md "from pin" and the rows of
base-trixie and panel-dkim-signing; roadmap row and recommended order.
No tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:14:14 +03:00
mixandClaude Opus 5.5 a27f8332ab plan: panel-dkim-signing - Q2 decided, the keys move to /data/dkim
test / test (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:50:37 +03:00
mixandClaude Opus 5.5 466a3d2089 plan: panel-dkim-signing - Q1 decided, the signer on sockets of its own
test / test (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:48:05 +03:00
mixandClaude Opus 5.5 3b440c2a53 plan: panel-dkim-signing, the draft with three open questions
test / test (push) Canceled after 0s
The signer in the panel's milter with go-msgauth/dkim v0.7.0 (sign.go
read: streaming NewSigner, HeaderKeys over-signing, last-first picking),
relaxed/simple, OpenDKIM's dkim_should_signhdrs[] plus From, the key by
the login's domain and by the server's name for local mail, strict. From
Postfix 3.10.13 milter8.c: multi-line headers come \n-separated, so the
signer rebuilds them with CRLF. disable_mime_output_conversion=yes, unset
until now. The oracle dkimpy (python3-dkim 1.1.8-2 in trixie). Three
stages, 2.8.0. Open for the owner: where the signer runs, the key
directory's name, Health's label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:44:58 +03:00
mixandClaude Opus 5.5 a3050afb66 base-trixie: findings of the reviews
test / test (push) Canceled after 0s
Security review (1c6e04b):
- The panel's database and its -wal/-shm were 0644 under a traversable
  /data, holding the panel users' password hashes and session token
  hashes. store.Open now makes them 0600 (SQLite gives the next -wal/-shm
  the database's mode: findCreateFileMode(), unixOpenSharedMemory());
  TestOpenKeepsTheDatabasePrivate.
- The entrypoint removes a symbolic link the panel user could leave where
  a /data directory, table, map, the queue or mail.log is expected, before
  its root steps chmod or create them (drop_links).
- The panel user is 10999:10999, outside the system range: in trixie
  useradd gave it group 998, the host's systemd-network on the test
  server; bookworm's 999 was the host's systemd-journal.
- security.md said the database was 0640; it was 0644.

Technical review (1c6e04b):
- mail_helpers.go: smtp-sink does add a Received: header, "(smtp-sink)"
  (smtp-sink.c:520-531); the "(Postfix)" filter leaves it out.
- CHANGELOG/roadmap: OpenDKIM's signing and From:-parsing code is
  unchanged, not all of it; the retry-policy bug predates 3.9 (any
  postconf warning); three entries moved from Fixed to Changed, as no
  released image had them.
- internal/app/sasl.go: db_dump's stdout alone is parsed.
- mynetworks comment: postinst:180, fset_all_changed.

Tests: on the server, make e2e exit 0 (712 s), go vet + go test 27
packages ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:05:01 +03:00
mixandClaude Opus 5.5 1c6e04b7d3 base-trixie: the image on Debian trixie, Postfix 3.10.13; Received: hidden on 465/587
test / test (push) Canceled after 0s
The runtime base is debian:trixie-slim, the build stage golang:1.26-trixie:
Postfix 3.10.13 with upstream's 2026 security fixes up to the August
batch, libmilter 8.18.1, OpenSSL 3.5. Every citation of another program in
code, scripts and documents was re-read against the trixie sources
(dev/upstream; bookworm's kept in dev/upstream-bookworm) and updated;
development.md's cited-package table holds the trixie versions.

What the re-reading changed in behaviour:
- logtail: reject lines with sasl_method/sasl_username/sasl_sender
  (smtpd_check.c:1019-1026); cleanup's "removed (canceled)" forgets a
  port-25 id (cleanup_api.c:363-364).
- internal/postfix/retry.go reads postconf's stdout only: since 3.9 a
  deprecated-parameter warning on stderr made one value too many and the
  panel fell back to the compiled-in retry policy.
- logrotate-loop.sh: logrotate 3.22 exits 1 when it ignores a config;
  the loop refuses on that output whatever the status.
- postfix-config.sh: cyrus_sasl_config_path set explicitly (bookworm's
  07_sasl_config.diff is gone); maillog_file_permissions=0640; mynetworks
  explicit; Dockerfile: SYNC_CHROOT= in /etc/default/postfix,
  opendkim-tools dropped, openssl named, systemd-standalone-sysusers so
  cron does not bring the whole of systemd.
- The selfpost group is 10998, outside the system range 100-999: in
  trixie a package took 998 and the build failed (owner's decision:
  a conflict-free id, the distribution's own ids untouched). The
  entrypoint hands /data back by name when the user or the group differs.
  Guide, backup refusal text, help (design commit 68ec8b2), e2e.
- smtpd_hide_client_session=yes on smtps and submission (owner's
  decision): Received: names no client, TLS, login or protocol; port 25
  keeps the full header. e2e checks both.
- test/upstream/opendkim-probe.sh makes keys with openssl genrsa.

Tests: on the server, make e2e exit 0 (674 s, the image built on trixie),
go vet + go test 27 packages ok. CHANGELOG [Unreleased], guide,
architecture.md, security.md, roadmap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:31:35 +03:00
mixandClaude Opus 5.5 68ec8b274c design: help - the selfpost group is 10998 (base-trixie)
The private key in certs/ is given to group 10998 on the host, no longer
998: in the trixie image systemd's packages take ids in 100-999, and 998
is systemd-network on the test server's host. The page's help.go follows
in the base-trixie commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:02:51 +03:00
mixandClaude Opus 5.5 b76f1ef1a0 release: 2.6.1: port 25 ends DATA only at CRLF.CRLF
test / test (push) Canceled after 0s
Cut without asking, as the owner said on 2026-10-15.

The inbound relay's smtp/inet listener sets
smtpd_forbid_bare_newline=normalize, the SMTP-smuggling defence Postfix
3.7.11 leaves off (aaa9412, port25-bare-newline, the first of the three
rows from own-mta-builds).

Rolled out to the test server as 2.6.0-post.aaa9412 (the same code): the
container healthy, every program RUNNING; the server runs the inbound
relay, and postconf -P shows smtp/inet/smtpd_forbid_bare_newline =
normalize next to syslog_name postfix/inbound; no warning or fatal in
docker logs. No migration, schema 11.

CHANGELOG cut; pin 2.6.1 in deploy/docker-compose.yml, README, guide (and
its sample restore message); development.md "from pin" and the row;
roadmap row shipped and the recommended order. No tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:30:33 +03:00
mixandClaude Opus 5.5 aaa94120be port 25 ends DATA only at CRLF.CRLF: smtpd_forbid_bare_newline=normalize (port25-bare-newline)
Postfix 3.7.11 leaves its 2023 SMTP-smuggling defence off (default "no"
before 3.9, src/smtpd/smtpd.c:807), and the inbound relay's smtp/inet
listener did not turn it on: a "." line ended by a bare LF ended DATA and
what followed was read as the next transaction. The listener now sets
normalize (bare_lf_mask_table, smtpd.c:1656; the DATA loop drops such a
"." line, 3649-3652), the setting Postfix recommends and 3.9's default.
The submission ports keep the default (own-mta-builds, section 5).

Tests: e2e inbound_bare_newline_is_one_message sends the smuggling
sequence over raw SMTP (net/smtp's DotWriter would rewrite the bare LF)
and finds one message at the upstream with the smuggled part inside;
port25 config check asserts the parameter on smtp/inet. On the server:
make e2e exit 0 (511 s), go vet + go test 27 packages ok.

Docs: CHANGELOG [Unreleased] Security, security.md, architecture.md,
guide.md; roadmap: port25-bare-newline, base-trixie and
panel-dkim-signing agreed by the owner in this order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:24:29 +03:00
mixandClaude Fable 5.1 2c8aec79b4 roadmap: own-mta-builds analysed - no own builds; three rows proposed instead
test / test (push) Canceled after 0s
docs/own-mta-builds-2026-10-15.md weighs patched builds of Postfix and
OpenDKIM against what the panel works around. No Postfix limit needs a
patch (the panel reads it through the interfaces it offers); every limit
worked around is OpenDKIM's, a project with no release since 2018, three
2026 CVEs open in every Debian suite and no one answering (read in
dev/upstream: dkim_process_set runs on a tenant's DKIM-Signature header
in signing mode too). Bookworm is in LTS since 2026-07-12 and its Postfix
3.7.11 lacks the June, August and September 2026 upstream security
batches, which carry no CVE ids.

Proposed as candidates: port25-bare-newline (smtpd_forbid_bare_newline
defaults to no on 3.7; our smtp/inet block does not set it), base-trixie
(postfix 3.10.13; 331 citation lines to re-read), panel-dkim-signing
(go-msgauth/dkim in the panel's milter; OpenDKIM, the syslog relay and
the parser port removed, about 1,500 lines).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 15:49:25 +03:00
mixandClaude Opus 5.5 6023feb405 audit 6: documents, comments, dead code and staticcheck; the test gaps closed
test / test (push) Canceled after 0s
The last stage of the 2026-10-12 audit; no cut.

Doc drift: architecture.md (the sender check in § OpenDKIM, the limit:
row's 48 hours, the inbound log and filter-list routes and who may open
them, migrations to 0011 and every table, KitPage as production data,
Reload configuration as a restore writes, the panel's roles counted),
schema-migrations.md (tables and columns since v2), guide.md (a delivery's
lines go at the next prune; INBOUND_JOURNAL_MILTER_SOCKET moved to
architecture.md's internal list), development.md (the env guard, the
deferral the e2e proves, its fixed sleeps), roadmap.md.

Comments say what the code does now; TestBaselineIsTheOnlyMigration is
TestChainStartsAtTheBaseline; the milter package doc names its three
milters.

Dead code gone: view.Facts, dmarc HostedSuggestion and
ValidateHostedAddress, app UpdateMode (each with the test that alone kept
it), the method checks the method-bound routes made dead;
Settings.WithResult calls resultFlash. staticcheck 2026.1 in test.yml on
both modules, clean: the e2e package builds without its tests now (a test
and two constants moved out of non-test files), three findings fixed.
The test moved, TestImageBuildPreservesLogrotateMode, had sat in a
non-test file and never run; run, it failed on the entrypoint (docker run
without a hostname) and now asks with --entrypoint stat.

The test gaps no step named, each closed: follow() with mail.log gone past
blindMin and made again; CheckRestore with a manifest that is not JSON;
the origin check with a port; every /server/ pattern in web.go through
server() and in the routes table (it found GET /server/health/fragment
missing from the table - guarded, not tested); L9 as the owner decided it
on 2026-10-12, the comment on senderSession.Headers and the test passing
an h that holds the earlier messages' From:.

Plan stage 6 ticked; roadmap audit row done, 81/81.

Tested on the test server: go vet + go test of 27 packages, make e2e (all
pass, 513 s) on this tree; staticcheck locally on both modules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:41:26 +03:00
mixandClaude Opus 5.5 dc78b0ed76 release: 2.6.0: audit stage 5 - image and Postfix hardening
test / test (push) Canceled after 0s
Cut without asking, as the owner said on 2026-10-15.

The supervisord socket is root:supervisor, the panel the group's one member
(M17). Postfix takes TLS 1.2 at least, the client too where TLS is
required; VRFY off; the banner without the distribution; the start refuses
a rate, size or milter timeout that is not a number, under its minimum or
past an int (L31). OpenDKIM logs through the syslog relay to the container
log and oversigns From: (L30). crashexit takes the container down after
five unexpected exits of a mail-path program in ten minutes (L32).
logrotate keeps its state under /data/log. extract-cert.sh refuses what is
not PEM and renames whole files (M21). Plus the findings of both reviews
(a39eda5). Commits dab7715, a39eda5, b09e525.

Rolled out to the test server as 2.5.4-post.95af363 (the same code): the
container healthy, every program RUNNING, syslog-relay among them;
OpenDKIM's start line in docker logs under "syslog: "; the postconf values
as set; /run/supervisor.sock root:supervisor 0770 and the panel reaches it;
/data/log/.logrotate.status written. No migration, schema 11.

CHANGELOG cut; pin 2.6.0 in deploy/docker-compose.yml, README, guide (and
its sample restore message); development.md "from pin" and the stage's row;
roadmap row and recommended order; stage 5 marked shipped in the plan. No
tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:58:10 +03:00
mixandClaude Opus 5.5 95af363214 roadmap: own-mta-builds, an analysis of patched Postfix and OpenDKIM builds
test / test (push) Canceled after 0s
The owner asked on 2026-10-15 whether our own builds would lift limits we
work around; recorded as a candidate for analysis with the starting points:
the limits met so far, for, against, and the middle ways to compare.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:53:38 +03:00
mixandClaude Opus 5.5 b09e525521 deploy: extract-cert.sh is executable
The guide runs it as ./traefik/extract-cert.sh, and git held it 100644 from
its first commit (review of audit stage 5, L3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:53:38 +03:00
mixandClaude Opus 5.5 a39eda5a0b audit 5: findings of the two reviews
- The e2e's numeric-settings test ran a container that passed every gate
  and never ended: the good values are checked beside a bad one after
  them; each run is named, bounded to a minute and removed.
- The gates refuse a value under the parameter's minimum (a zero window
  kills anvil, a zero milter timeout smtpd) and one past an int, in
  seconds for a time value (src/global/conv_time.c, src/anvil/anvil.c).
- OpenDKIM waits, five seconds at most, for the relay's socket.
- The relay marks its lines `syslog: `, writes control characters out,
  reads into a queue at once and drops past 200 lines a second, saying
  how many (security F1, F2; a stuck relay would hold OpenDKIM in
  syslog(3)).
- smtp_tls_mandatory_protocols >=TLSv1.2 too.
- extract-cert.sh keeps the old files' group and sets its trap before
  mktemp.
- crashexit spares cert-reload; architecture.md "four times running" and
  what else exits the container; security.md the restart after five panel
  exits and the /dev/log socket.
- Measured against the technical review: smtpd has the selfpost group
  (Groups 104 998), so it did reach the supervisor socket before this
  stage.
Accepted, reasons in the plan: the logrotate state file in the postfix
user's directory, envdoc_test's reading of `${VAR:-}` only, crashexit's
window pruning untested.

Tested on the test server: go vet + go test of 27 packages, make e2e (all
pass, 592 s) on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:53:14 +03:00
mixandClaude Opus 5.5 dab7715e80 audit 5: the supervisor socket the panel's alone, OpenDKIM logs and oversigns From
test / test (push) Canceled after 0s
M17: the supervisord control socket is root:supervisor 0770, a group whose
one member is the panel; OpenDKIM and Postfix keep `selfpost` for the files
under /data and no longer reach the socket.

L30, read in OpenDKIM 2.11.0~beta2's source first: it logs through
syslog(3) only and has no Logfile option, and nothing in the container
served /dev/log. internal/syslogrelay (the panel binary with
-syslog-relay=/run/syslog/log, program syslog-relay before OpenDKIM,
/dev/log a link to its socket) passes the datagrams to stdout, error and
worse marked. opendkim.conf: Syslog yes, Canonicalization relaxed/simple,
OversignHeaders From (RFC 6376 § 3.4, § 8.15). e2e: OpenDKIM's start line
reaches the container log; every program RUNNING; DKIM verifies.

Tested on the test server: go vet + go test of 26 packages, make e2e (all
pass, 626 s) on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:07:28 +03:00
mixandClaude Opus 5.5 d6ed6bcb30 release: 2.5.4: audit stage 4 - panel behaviour
test / test (push) Canceled after 0s
Cut without asking, as the owner said on 2026-10-15.

Signing in costs the same for an unknown name (M8) and ends the session the
browser presented (L16); the Account password check has its own limit
(L15). The HTTP server bounds the request, the answer and idle connections;
the full backup moves its own deadline (L14). Monitoring refreshes are
scheduled from htmx:afterRequest with a back-off and a 30 s timeout, and a
lost session sends htmx to sign-in (M11). The Outbound log clamps its page
(L10) and filters by status and day; the multiplier refuses NaN (L11); a
rate-limited refusal is red (L18); Recalculate asks before it drops an
unsaved edit (L19); Reload configuration writes what a restore writes and
removes the keys of deleted domains (L13, L17). The cards layout orders by
number. A notification is owed to each recipient (migration 0011). A DNS
outage is unknown, each resolver with its own time (M14, M15). DMARC
reports too large are refused for their size. An inbound upstream has one
TLS mode, changed for all its domains (L5). Plus the findings of both
reviews (eaa7de0). Commits 07be0cf, 643892a, ca876c1, 80e3979, 2b8e0c0,
b90a2f0, eee089a, 006bed7, 32dfe4a, eaa7de0.

Rolled out to the test server as 2.5.3-post.eaa7de0 (the same code):
migration 0011 applied, schema 11; all programs RUNNING, the container
healthy. Resync's key cleanup checked against the live data: every key
directory is a domain's or the hostname's, nothing to remove.

CHANGELOG cut; pin 2.5.4 in deploy/docker-compose.yml, README, guide (and
its sample restore message); development.md "from pin" and the stage's row;
roadmap row and recommended order; stage 4 marked shipped in the plan. No
tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:51:50 +03:00
mixandClaude Opus 5.5 eaa7de0754 audit 4: findings of the two reviews
test / test (push) Canceled after 0s
- Two inbound domains on one upstream could never change its TLS mode,
  each save refused for the other (medium): SetTransportAs changes the
  mode for every domain on the upstream when the one saving may change
  them all; a domain user who may not is refused with the mode, not the
  other tenant's domain name (security F1: the first version named it).
- A deletion's leftover DKIM key is removed by Resync (Reload
  configuration, a restore's first start) for names no domain has.
- The backup's write deadline moves before Check.
- hasUnsavedEdit skips password and file fields and compares a multiple
  select's options; htmx.config.timeout 30 s, so a refresh never answered
  ends and is tried again; a failed refresh's promise is handled.
- An empty filtered Outbound log says no message matches the filters.
- The dummy bcrypt hash is made at start; a DMARC part over 8 MiB is
  refused for its size like the other two limits.
- Health's Configuration box says what Reload writes (design first,
  32dfe4a).
- Docs: CHANGELOG, guide § Inbound, security.md (the shared upstream's
  mode, the profile form's "username taken"), architecture.md (owed to an
  address), the plan's notes corrected.
Tests: the shared upstream (service, handler), the leftover key, all eight
Health boxes ordered, the subject from before migration 0011, the new JS
cases.

Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 636 s) on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:46:52 +03:00
mixandClaude Opus 5.5 32dfe4a2cf design: Health's Configuration box says what Reload configuration writes
Since stage 4 the button also writes the spam filter's lists and the DMARC
maps (Postfix maps), and removes the keys of deleted domains; the box said
OpenDKIM and Postfix only (review of audit stage 4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:32:39 +03:00
mixandClaude Opus 5.5 006bed7022 audit 4: DNS outages unknown, DMARC limits said, typed Health, one TLS mode per upstream
test / test (push) Canceled after 0s
M14, M15: queryEach gives each resolver 2 s within the check's context and
asks the one that answered last first; an address or PTR lookup not
answered is Unknown, an answered mismatch beside it still an error;
Checker.Server keeps the last answer's addresses, so SPF does not go Unknown
with it. Notifications report faults only, now tested with SPF unknown
alone and beside a wrong DKIM.

DMARC reports: tests of malformed XML, gzip and zip bombs, a message over
the limit, the service's Resync, hosted suggestion and validation. The
finding: both limits cut silently and an oversized report failed as
damaged; readAtMost refuses it for its size (MaxMessage 10 MiB, moved into
IngestMessage).

healthReadings replaces map[string]any for Health and Overview.

L5: an inbound upstream has one TLS mode - a second domain to the same
[host]:port with another mode is refused, naming the first; the TLS policy
map holds each next hop once.

Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 631 s) on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:15:58 +03:00
mixandClaude Opus 5.5 eee089ad4b audit 4: cards ordered by number, a notification owed to each recipient
test / test (push) Canceled after 0s
The cards layout (owner, 2026-10-13; the mockup first, b90a2f0): box_open
writes data-order for a box with InCards and a number, and panel.css orders
by it up to twelve - Health's eight and the kit's three.
TestCardsBoxesCarryTheirNumberAsTheirOrder reads the rendered pages.

Notifications (owner, 2026-10-14): migration 0011, notify_addressees. open
sends a subject's first message to every recipient and records who was
handed it; followUp, on every check while the subject is open and still
wrong, hands it to the owed and untold who are still recipients; closed
sends the end to the told (all recipients for a subject from before the
table). Tests: a failed copy goes out at the next check, once; the hourly
ceiling holds one back; a user added mid-subject hears nothing of it.

Also in the roadmap, from the owner today: dns-record-collapse,
notification-mail-recheck, settings-save-row (agreed, design first).

Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 644 s) on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:49:50 +03:00
mixandClaude Opus 5.5 b90a2f0321 design: the cards layout orders its boxes by their numbers, not their places
On a phone the stylesheet ordered a cards box by its place in its stack,
four to a stack; a fifth box got no rule and landed out of place with
nothing failing (owner, 2026-10-13). Each box of the layout carries its
number as data-order, and theme.css orders by it, up to twelve: an
attribute, since check.py refuses classes the pages do not use, and not a
style, which the CSP forbids. Health and the kit's cards carry it; the look
is the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:35:13 +03:00
mixandClaude Opus 5.5 2b8e0c086d audit 4: a deleted domain says so, the Outbound log filters by status and day
test / test (push) Canceled after 0s
L17: past DeleteDomain both rebuilds run, their errors joined under
domain.ErrDeletedIncomplete; the key is removed only when OpenDKIM's tables
were rebuilt (they may still name it otherwise); the domain list says the
domain is deleted and that Reload configuration finishes it, not "internal
error". Tests in domain (the tables failing, the sender map failing) and
handlers.

The Outbound log's status and day filters (owner, 2026-10-13; the mockup
first, 80e3979): view.OutLogFilter carried by the poll, the pages, the rows'
links and the message's way back; SendLogFilter.Status, Since, Until (whole
UTC days, as the Inbound log's); rejected is the three refusals together; a
status not in the list is no narrowing. Tests: the filters through the
handler, the poll and the way back; the day boundaries in store.

The plan's milter-socket step is ticked: done in 2.5.2 (98e09fe).

Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 642 s) on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:34:24 +03:00
mixandClaude Opus 5.5 80e3979748 design: the Outbound log filters by status and by day
The status list is the statuses of a row - queued, sent, deferred, bounced,
rejected, the last the three kinds of refusal together (owner, 2026-10-13).
The mockup's own list was wrong: it offered rate-limited, a reason, and had
neither queued nor rejected. And the days, from and to, the Inbound log's
control.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:21:13 +03:00
mixandClaude Opus 5.5 ca876c1631 audit 4: log paging at its edges, NaN refused, a red rate limit, recalculation asks, reload writes all
test / test (push) Canceled after 0s
Step 4 of stage 4 (L10, L11, L13, L18, L19 and a row of
review-2026-08-followups):

- The Outbound log counts first and clamps the page to the last; a number
  too large for an int (strconv's ErrRange with the largest int) is the
  last page on both logs. Tested at the edges: first, last, past the last
  however far, nonsense, an empty log asked for page 5.
- checkAutoMultiplier is "not within", so NaN and the infinities are out.
- rate-limited is red (components.go; the mockup first, 643892a).
- data-unsaved on both recalculate buttons: panel.js asks on submit
  (ev.submitter) when a field of the page differs from what it was drawn
  with; testdata/unsaved_test.js under TestPanelJS.
- HandleReload writes what a restore's first start writes - the filter
  lists and the DMARC maps too - and a failed step is an error flash on
  Health that names it, not plain text; the success flash says what was
  written.

Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 695 s) on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:20:18 +03:00
mixandClaude Opus 5.5 643892ab91 design: a refusal under a rate limit is red, as its two siblings
out-log drew the rate-limited tag grey beside wrong From and bounced in red
(audit 2026-10-12, L18); it is is-danger now. The kit's status table names
the red statuses as the panel does: reject and rate-limited join error,
fail, mismatch, bounced (reject was in the code's list and not here).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:08:14 +03:00
mixandClaude Opus 5.5 07be0cfb58 audit 4: sign-in timing and sessions, server timeouts, polling that recovers
test / test (push) Canceled after 0s
Steps 1-3 of stage 4, tested together (their docs share files).

Step 1 (M8, L15, L16): an unknown name pays a bcrypt comparison at the
panel's work factor too (noSuchUserHash, made once) -
TestLoginTakesAsLongForAnUnknownName holds the medians within a factor of
two; the Account form's current-password check has its own limiter, per
user (AllowPasswordChange); a sign-in destroys every session the request
presents before it issues its own.

Step 2 (L14): newHTTPServer - ReadTimeout 30 s, WriteTimeout 60 s,
IdleTimeout 2 min beside ReadHeaderTimeout; the full backup moves its write
deadline to two hours through http.ResponseController (e2e: the log shows
no failure to move it).

Step 3 (M11), read in htmx 2.0.4's source (dev/upstream/htmx-2.0.4, the npm
build byte-equal to the shipped htmx.min.js): the poll passes source: box,
and the next refresh is scheduled from htmx:afterRequest, which htmx fires
on load, error, abort and timeout, and after a swap on the nearest parent
left (the box found by detail.target's id); failures in a row double the
wait to 60 s. RequireAuth answers htmx with 401 + HX-Redirect: /login,
which htmx follows before it looks at the status. testdata/poll_test.js
runs panel.js in node against a stand-in DOM and clock (TestPanelJS; skipped
without node, which the test server lacks - run here, and it fails on the
old panel.js).

Also measured: the stage-3 e2e regression for H1 fails as it should on HEAD
with the old entrypoint ("check data/postfix/e2e_extra_map: permission
denied", the panel restarting).

Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 639 s) on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:07:02 +03:00
mixandClaude Opus 5.5 48832d223a release: 2.5.3: audit stage 3 - persistence and restore
test / test (push) Canceled after 0s
Cut without asking, as the owner said on 2026-10-15: "Вопросы про резку не
задавай, просто режь."

A restore is ended by its Resync: the manifest stays until the first start
has regenerated the maps (M1). Every archive ends with data/contents.json and
the first start after a restore compares /data with it before it opens the
database: half an archive, a damaged file, a journal left beside the
database is refused, named. The entrypoint hands every map under
/data/postfix to the panel (a restore unpacked as root left the DMARC maps
unreadable - found by the review). Migration 0010: the send log's sweep,
refusal-line check, level-2 count and pages each by its index, pinned by
EXPLAIN tests on the statements the store runs (M2). The backup CLI refuses
a missing database (M3) and a password under 12 (M5), writes -o whole,
checks -decrypt's password before the output (L1), warns of what it leaves
out and refuses an empty certs/ (L2). An encrypted file may ask scrypt for
128 MiB at most, r <= 64, p <= 2, and nothing may follow its last chunk
(M4, L3). TRUSTED_PROXY_CIDR's bare ::ffff:a.b.c.d is one host (L4). Maps
are durable across a power loss: the directory is synced (L7). Commits
8e3241e, 508ac96, 242515c, 76b21b3.

Rolled out to the test server as 2.5.2-post.76b21b3 (the same code):
migration 0010 applied, schema 10, the four new indexes present; all
programs RUNNING, Postfix with its three milters, nothing in the logs. On
the live container: a CLI backup ends with data/contents.json, a wrong
SELFPOST_DB_PATH is refused and creates nothing, a short password is
refused.

CHANGELOG cut; pin 2.5.3 in deploy/docker-compose.yml, README, guide (and
its sample restore message); development.md "from pin" and the stage's row;
roadmap row and recommended order; stage 3 marked shipped in the plan. No
tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:44:08 +03:00
mixandClaude Opus 5.5 76b21b3597 audit 3: the reviews' findings and what was accepted, in the plan
test / test (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:38:44 +03:00
mixandClaude Opus 5.5 242515c305 audit 3: backup CLI, envelope budget, directory sync, docs; findings of the reviews
Steps 3-6 of stage 3 and what the two reviews found in steps 1-6, in one
commit: the review fixes sit in the same hunks.

Step 3, CLI backup (M3, M5, L1, L2; L4 of the stage's list):
- backup.snapshotDB refuses a database that is not there and opens the
  source mode=ro (measured: mode=ro creates no file).
- selfpost-backup holds an encrypting password to
  validate.MinSecretFilePasswordLen; -o of both directions writes a
  temporary file beside the name and renames it whole; -decrypt peeks the
  first chunk (password and type authenticated) before the output exists.
- what a backup leaves out (a link under data/, a link under certs/ to a
  directory, a socket...) is told to OnWarn; certs/ with no file is
  backup.ErrNoCerts, refused by Check and Create, named by the panel.
- parseTrustedProxies: a bare address takes /128 when written with a
  colon (::ffff:10.0.0.1/32 was ::/32).
Step 4, secretfile (M4, L3): scrypt's V at most 128 MiB, r <= 64, p <= 2;
bytes after the last chunk are ErrCorrupt.
Step 5 (L7): atomicfile.SyncDir after the rename, used by Write,
replaceSASLDB and the CLI; atomicfile_test.go.
Step 6: security.md and guide on backup and restore.

Reviews (technical, security):
- H1: a restore of a server with DMARC on was refused on every start - the
  archive records no owner, tar as root left the DMARC maps root's, and the
  entrypoint only chowned the maps it named. It now hands every file beside
  queue/ in /data/postfix to panel:selfpost 0640; e2e writes an extra map
  before the backup and finds it panel:selfpost 640 after a root unpack.
- the comparison reads regular files only (Lstat, size, LimitReader); a
  record unpacked again wins over the earlier check's mark; ConsumeRestore
  removes both; the mark is synced; a journal file beside a freshly unpacked
  database is refused.
- AttachRefusalLine refuses an empty line as the old statement did; skipped
  entries named in words; -decrypt of an empty payload is no failure; guide
  wording on the shell's '>' and a killed CLI; the in-place restore's
  'mv .' (older than this stage) fixed.

Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 643 s) on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:38:39 +03:00
mixandClaude Opus 5.5 508ac96c79 audit 3: every per-message question of the send log has its index
test / test (push) Canceled after 0s
Migration 0010 (DDL only; 0009 went to stage 2b). EXPLAIN QUERY PLAN of
every send_log statement showed, beyond M2 as written (stage 2 had already
moved the by-domain counts onto (domain_id, created_at)):

- the tailer's sweep of open rows (every 5 min) and its count scanned the
  table: partial idx_send_log_open (created_at) WHERE status IN ('queued',
  'deferred'), the statuses literal in the statements, ORDER BY +id (else
  SQLite walks the table in id order to skip a sort);
- AttachRefusalLine's NOT EXISTS (... refusal_line = ?1) scanned the table
  for every refusal line: partial idx_send_log_refusal_line, the query
  carrying the index's WHERE; its row found by idx_send_log_rejected
  (status literal, +queue_id) instead of every refusal without a queue id;
- an application's level-2 count went through its domain's index:
  +domain_id in appByLogin;
- a page of one domain's or one application's log sorted all of it:
  idx_send_log_domain_id, idx_send_log_app_login (id order; several domains
  still sort, top-N). idx_send_log_domain stays for the global filter.

The statements are constants and builders (countMessagesQuery,
peakPerHourQuery, listOpenQuery, countOpenQuery, sendLogPageQuery,
attachRefusalLineQuery, limitRefusalsQuery) and
TestSendLogQueriesUseTheirIndexes explains exactly those: the index each
must use, no SCAN of send_log, no sort where none is wanted; the literal
statuses checked against the constants. It replaces the hand-written plan
check of TestLimitRefusalsSince.

Tested on the test server: go vet + go test of 24 packages, make e2e (all
pass) on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:10:52 +03:00
mixandClaude Opus 5.5 8e3241ef98 audit 3: a restore is ended by its Resync, and half an archive is refused
test / test (push) Canceled after 0s
The restore manifest was consumed as soon as its version was checked,
before store.Open and the Resync; a start that failed in between left a
data directory the next start took for an ordinary one (M1). openData now
runs CheckRestore, store.Open, the Resync, and only then ConsumeRestore.

A truncated archive had nothing to refuse it: the manifest is the first
entry, so a download cut short or a tar stopped by a damaged block was a
restore on half a database. Every archive now ends with data/contents.json,
the size and SHA-256 of each data/ file (deploy files are the operator's to
edit), and CheckRestore compares /data with it before the database is
opened, then renames it contents.checked.json so a start after a failed one
does not compare again unless the manifest changed.

Tests: a start whose Resync fails, then a start that resyncs and consumes;
an archive cut in the database, before the deploy files and in its last
entry (refused, database untouched, no -wal); a damaged key named; the
record's names confined to data/; the domain import of a cut plain export,
a cut and an altered .spde (refused, nothing created). e2e on the test
server: full_backup_restore now also sees contents.json in the archive and
gone after the first start, through the real entrypoint and daemons (all
pass, 762 s); go vet + go test of 24 packages pass there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:53:46 +03:00
mixandClaude Opus 5.5 68b3a7b23f release: 2.5.2: audit stage 2c - what SelfPost relies on, read in the sources
test / test (push) Canceled after 0s
The owner's word, 2026-10-15: "Да, режем."

Every place SelfPost relies on another program was read in that program's
source, of the version the image runs, and is cited in the code (621
entries, docs/assumptions-2026-10-14.md); what the sources contradicted is
fixed: rspamd signing a sender's own verdict, the sender map's first key,
queue expiry and Postfix's time values, refusal and recipient lines as
Postfix writes them, the rotated log read until postlogd lets it go,
inbound mail over 10 MB, local delivery to an address literal, supervision
(Postfix stopped, give-ups FATAL, the reload's exit read), sasldb2 through
a copy, Health dialling sockets and showing the certificate Postfix
serves, Referrer-Policy same-origin, migrations under BEGIN IMMEDIATE,
certbot's links followed, about forty sentences of the documents, and ten
measurements on the stand. The sender check reads From: with OpenDKIM's
own parser, ported and checked against the C and against OpenDKIM's
signing; L8 is closed; an address OpenDKIM would cut short is refused.
Plus the findings of both reviews of the stage (9eddd49).

Rolled out to the test server as 2.5.1-post.9eddd49 (the same code): no
migration, schema 9; all programs RUNNING, the container healthy,
Postfix with its three milters, nothing in the logs.

CHANGELOG cut; pin 2.5.2 in deploy/docker-compose.yml, README, guide (and
its sample restore message); development.md "from pin"; roadmap row and
recommended order; stage 2c marked shipped in the plan. No tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:22:18 +03:00
mixandClaude Opus 5.5 9eddd49602 audit 2c: findings of the two reviews
test / test (push) Canceled after 0s
Technical review (Opus, 11 findings, 2 medium) and security review (Fable,
no high or medium) of 3c86280..HEAD. Both confirm the port gives
dkim_mail_parse()'s result on every input it parses, and that no line the
check passes is signed with another domain's key.

- F1 (medium): OpenDKIM's parser takes lines no mail program reads as one
  address - "ceo@bank.com:me@mine.com", "Name <ceo@bank.com:me@mine.com>",
  "<b@evil.com, a@mine.com>", "ceo@bank.com <me@mine.com>",
  "ceo@bank.com@mine.com" - and signs for the last while a lenient reader
  shows the first. The line must now also be one address in RFC 5322's
  grammar (net/mail, display names in any charset passed through), at the
  domain OpenDKIM signs for. A quoted display name may still hold anything.
- F2 (medium): the bindings were asked about OpenDKIM's form of the
  address, quotes and all, so "x"@mine.com passed for an application
  holding @mine.com while x@mine.com belonged to another. They are now
  asked about the reader's address, its local part unquoted, as Postfix
  holds the envelope's.
- F3 / security (low): Bindings.Allowed and DomainOf fold case in ASCII
  only: Postfix folds as Unicode (casefold.c, ICU), but every key and
  login the panel writes is ASCII, and the Unicode fold let the Kelvin sign
  pass for "k" (net/mail then refuses such a line on the domain test).
- F4 (low): a TERM between "postfix start-fg &" and "child=$!" is no longer
  lost: the wrapper stops Postfix as soon as the pid is known.
- F5 (low): for startsecs after a start any failed reload passed as "not
  running". postfix-reload is now build/postfix-reload.sh: exit 75 when
  master -t says no master runs, else exec postfix reload; RunOnce returns
  EndError with the status, and only 75 is taken as done. supervisor.State
  goes.
- F6 (low): crashexit spares postfix-reload too - a reload that could not
  be spawned is reported to whoever asked, not a reason to stop the
  container.
- F7 (low): a certs/ that is itself a link is resolved before the walk
  (filepath.WalkDir does not follow its root).
- F8 (low): at shutdown the tailer reads what is left of the rotated
  file before saving its place. Not changed: a refusal line still waiting
  on the old file at shutdown is not kept - the panel writes refused rows
  itself, only a kill loses one, and that window is the minute's grace.
- F9 (low): pct read as text - empty or unreadable is 100, the rest held
  to 0..100.
- F10 (low): the directory is synced after sasldb2's rename.
- F11 (low): POSTFIX_STARTSECS by expr, so "010" or "08" in
  MILTER_WAIT_TIMEOUT are decimal, not octal or an error.
- Security (low): pagehide also empties defaultValue - the input's value
  attribute and the textarea's text - not only .value. A sasldb2.new left
  by a killed panel is removed at start (entrypoint) and never archived.
  security.md says that links under certs/ are followed and by whom they
  can be placed.

Tests: sender check cases for F1 (four lines), F2 (a bound address
quoted, for its owner and for another), F3; EndError's state and status;
pct cases. architecture.md and CHANGELOG describe the reader rule. Stand
(2026-10-15, make e2e, exit 0); go vet and go test ./... pass on the
server (24 packages).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:17:33 +03:00
mixandClaude Opus 5.5 ec08c8606b audit 2c: the From: parser port checked against OpenDKIM's signing itself
test / test (push) Canceled after 0s
The last check of the port, and the plan's last line of stage 2c. Through
Postfix the sender check refuses most lines before OpenDKIM sees them, so
TestPortAgainstOpenDKIM (internal/milter) talks to OpenDKIM directly:
test/upstream/opendkim-probe.sh starts OpenDKIM from the SelfPost image in
a throwaway container, with keys and a refile SigningTable for three
names, and the test plays it each From: line through go-milter's client,
as a client OpenDKIM trusts (127.0.0.1, InternalHosts' default). For each
line the d= of the signature OpenDKIM adds must be the domain the port
predicts - its address, the domain cut to DKIM_MAXHOSTNAMELEN, the whole
cut to MAXADDRESS, matched against "^.*@<domain>$" case-insensitive as
dkimf_apply_signtable() matches - or neither signs.

Run on the test server, 2026-10-15 (OpenDKIM Filter v2.11.0 from the
image): 385 lines - both shared files and lines composed from display
names plain, quoted, escaped and encoded, comments nested and open,
groups, lists, brackets, source routes, domain literals and quoted local
parts, with the other sender's name and the server's in each place - 385
agree, 287 signed. The two lines padded so that, cut at 256 bytes, they
end in "@other.e2e.test" are signed by OpenDKIM with other.e2e.test's key:
the danger the sender check's 256-byte rule (3584adc) closes is real, and
the test now asserts it.

Measured while writing it: OpenDKIM answers a step it is done with by
accepting the message and then nothing more, and go-milter v0.4.1's client
needs read timeouts set in ClientOptions (the defaults apply only to its
default options) - the probe reads every step's action.

The differential test of the parser and this probe are now scripts in the
repository, test/upstream/mailparse-diff.sh and opendkim-probe.sh, both
run from it on the server: 200 019 lines agree with the C, 385 with the
signing. go vet and go test ./... pass on the server (24 packages). Stage
2c's plan lines are all ticked; roadmap 49/81.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 03:07:54 +03:00
mixandClaude Opus 5.5 3584adcbc2 sender check: From: read with OpenDKIM's own parser; L8 closed
test / test (push) Canceled after 0s
Audit 2026-10-12, stage 2c, the owner's decision of 2026-10-14: the check
decides by the address OpenDKIM signs for, whatever a display name or a
comment holds.

internal/milter/mailparse.go ports libopendkim's dkim_mail_parse() with
dkim_mail_first_special(), dkim_mail_token() and
dkim_mail_matching_paren() from Debian's opendkim 2.11.0~beta2-8+deb12u1,
line for line: the '<' form, group and list punctuation, an '@' before the
first ':', ';' or ',' deciding where the address starts, source routes
dropped, quoted strings and domain literals kept with their delimiters,
comments skipped, whitespace between atoms one space, no encoded word
decoded; and the C's own quirks ("@d" gives d as the local part too).
testdata/mailparse_harness.c compiles the C from the image's source; the
test feeds it and the port 200 019 lines (the hand-written cases and
200 000 random ones over the specials) - 93 443 parse and agree byte for
byte, 106 576 fail and agree on the error (on an error the C leaves its
in-place result unterminated, and OpenDKIM uses neither part then).

judge(): one From: field and one address stay required (moreAfter: only
list or group punctuation may follow); what OpenDKIM cannot read is
refused (mlfi_eoh() would not sign it); the address is lower-cased in
ASCII only, as dkimf_lowercase() and Postfix's tables do. Found reading
dkimf_apply_signtable(): OpenDKIM matches its refile SigningTable against
"user@domain" printed into MAXADDRESS + 1 = 257 bytes, so a longer address
is matched cut short - a quoted local part padded to end there in
"@another.tenant" would be signed with that tenant's key while its real
domain is the application's. Such an address (over 256 bytes) is refused;
one that fits is matched whole. The display-name and comment scan, the
"one @ outside quotes" rule and the SigningTable reader (Signers) go; the
check asks for header values with their leading space as OpenDKIM does
(OptHeaderLeadingSpace; Postfix keeps a v2 milter's event mask, milter8.c
milter8_negotiate()).

L8 closed: refused or passed no longer depends on other domains of the
server; security.md's accepted risk is struck. The shared other-sender
lines are now held to "never signed for the other sender": unit test (a
passed line is read at the application's domain) and e2e (a passed line
arrives signed for the application's domain, none for the other).
Documents: architecture "How the header is read", security's sender
check paragraph, the guide; the help topic per 2cf118c.

Stand (2026-10-15, make e2e, exit 0): sender_check_holds_the_from_line -
another sender's address in the display name now passes; a From: whose
quoted local part is padded so that, cut at 256 bytes, it ends in
"@other.e2e.test" is refused 550 5.7.1 "the address is longer than
OpenDKIM reads whole"; lines_naming_another_sender_never_signed_for_it
and passed_from_lines_signed_for_their_domain_only pass. go vet and go
test ./... pass on the server (24 packages); the differential test ran
there against the compiled C.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 02:48:02 +03:00
mixandClaude Opus 5.5 2cf118cafc design: help says the sender check reads From: as OpenDKIM does
Audit 2026-10-12, stage 2c: the sender check now reads the From: line with
OpenDKIM's own parser, so the help topic says what that means for the
reader - only the address counts; a display name or a comment around it is
not judged; a line with more than one address is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 02:28:45 +03:00
mixandClaude Opus 5.5 cad1182150 audit 2c: what only the stand could answer, measured and written down
test / test (push) Canceled after 0s
All 2026-10-15; each written beside the code or text it settles.

- A-43: /usr/sbin/postlog is -rwxr-xr-x root:root in the image (postfix
  3.7.11-0+deb12u1; dpkg-statoverride lists postdrop and postqueue only):
  Debian does not install it set-gid, so it is no use to dmarc-ingest
  (cmd/panel/dmarc_ingest.go).
- C-X1: OpenSSL 3.0.22 in the image prints the same public key from
  `x509 -pubkey` and `pkey -pubout` for RSA-2048, P-256, P-384 and a SEC1
  P-256 key - cert-reload's same_pair holds for certbot's ECDSA keys.
- C-K5, D-guide:933/:233: on the live server (Docker 29.6.1, userland
  proxy on 0.0.0.0 and ::, no IPv6 on the network) an outside IPv4 client
  and the host's own public IPv4 address are logged as themselves; the
  host's IPv6 address and 127.0.0.1 arrive as 172.18.0.1. Over IPv6 every
  client is the gateway: one level-1 budget, an application IP list cannot
  name them, rspamd sees the gateway. Guide paragraph under level 1, notes
  at the milter and allow-list, comments at the anvil setup and authip.go,
  and a Security entry in the CHANGELOG (an allow-list naming the gateway
  would admit every IPv6 client).
- D-guide:61: a published port nothing listens on (a throwaway container,
  -p 20999:25) is refused from outside over IPv4 and from the host's public
  IPv4, accepted and closed through 127.0.0.1 - Docker's proxy, which IPv6
  goes through too (not probed over IPv6 for this).
- C-M8: the live installation has received one DMARC report, of one
  record: nothing to set maxReportRecords by yet - said at the constant.
- D-S7, D-arch:516, D-arch:532: a new database filled through the store to
  120 days at 1 900 messages a day (228 000 messages, 1.37 million lines,
  464 MB) on the test server (2 cores): the longest write is the line prune
  after a 30-day gap, 1.9 s for 342 000 lines (0.4 s with nothing to
  delete); the send-log delete 0.6 s; 2 000 lines of a tailer pass 18 ms;
  VACUUM INTO 1.7 s; a second connection writing every 50 ms waited 1.8 s
  at most, no SQLITE_BUSY - inside busy_timeout's 5 s. architecture.md's
  "about a second per million lines" was five times too fast; its "six to
  ten lines of 200 bytes" is 6.0 lines of 146 bytes on the live data.
- D-B16: which favicon is fetched, by the probe server's log: Edge 155
  the SVG, the desktop app's embedded Chromium 152 the PNG, with or
  without sizes="any" - layout.html's "vector wins where supported, raster
  catches the rest" holds; Firefox and Safari not measured.
- D-arch:121: the allow/deny trial again, in rspamd/rspamd:3.11.1-3 with
  today's multimap.conf: every mix of allow and deny entries matching one
  message ended reject; a changed map was used about 10 s later.
- D-sec:424: rspamd runs as _rspamd, uid 11333; the panel is uid 999.

Stand: go vet and go test ./... pass on the server (24 packages); make
e2e exit 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 02:14:58 +03:00
mixandClaude Opus 5.5 fbd0acd035 audit 2c: the rest the sources contradict, and the documents
test / test (push) Canceled after 0s
Code and config:
- C-M7: a DMARC report's explicit <pct>0</pct> stays 0; only an absent
  pct takes RFC 7489 § 6.3's default 100 (Pct *int).
- C-D1: docs/development.md now tables the Debian versions the citations
  were read at (postfix 3.7.11-0+deb12u1, opendkim and libopendkim11
  2.11.0~beta2-8+deb12u1, libmilter1.0.1 8.17.1.9-2+deb12u2, libsasl2-2 and
  sasl2-bin 2.1.28+dfsg-10, supervisor 4.2.5-1, logrotate 3.21.0-1 - read off
  the image with dpkg-query, 2026-10-15); the e2e stand's new
  checkCitedPackageVersions fails when the image it built differs.
- C-E2: the e2e zone's SOA serial only grows (CoreDNS v1.14.6's file
  plugin reloads on a grown serial, not on mtime); Corefile comment says
  so; TestZoneSerialGrows.
- C-K8, C-K9: the e2e compose and stage comments say how
  --project-directory and .env resolve.
- D-arch:154: postfix-config.sh's "Postfix insists" comment says what
  TLS_README advises and that smtpd reads the key as root.
- B-G7: inflight.go's reservationTTL comment: a dropped client does give
  ABORT (smtpd_proto() -> mail_reset() -> milter_abort()); the TTL is for
  smtpd dying or the milter connection breaking.
- D-guide:301: the nginx fragment's first-certificate command now reaches
  certbot (--entrypoint certbot, --standalone, port 80 free), and the
  renewal loop passes --webroot -w /var/www/certbot - a standalone
  certificate would otherwise renew standalone while nginx holds port 80
  (certbot docs, "Renewing certificates": the issuing plugin is used
  "unless you specify other plugins or options", and options of a
  successful renewal are saved). The register's proposal missed that.

Documents: every D-arch, D-guide and D-sec claim the register's Answers E
found false or partly true, rewritten - by three Sonnet agents, one per
document, then reviewed: two proposals were obsolete after this stage's
fixes and not used (a bounce's From: line - extract_from = "smtp" now;
"Origin: null" - Referrer-Policy is same-origin now); a stand
observation the agent had dated from a commit is now tied to the e2e test
that checks it; the SQLite writers paragraph describes _txlock=immediate;
the log tailer's rotation paragraph describes the minute's grace; the
guide's nginx steps were put in working order (links and modes before
`up`, no reconfigure step). "Deferred" is kept for Postfix's queue in the
guide too. guide.md's CRLF from the agent normalised.

Stand (2026-10-15, make e2e, exit 0, the version gate and zone serial
test included); go vet and go test ./... pass on the server (24 packages).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:53:04 +03:00
mixandClaude Opus 5.5 2e1544dac5 certbot: certs/ links into /etc/letsencrypt, mounted at the same path; the backup follows them
test / test (push) Canceled after 0s
C-A1, C-Q4, D-guide:258, :291, :722. certbot's live/<host>/*.pem are
relative links into ../../archive/<host>/ (eff-certbot docs, "Where are
my certificates?": point a server at them "or create symlinks"). The
guide had `ln -s /etc/letsencrypt/live/<host> certs` and the nginx
fragment mounted live/<host> alone: inside the container the links led
into a directory it did not have - postfix-config.sh's [ -f ] false, no
copy, Postfix without a certificate - and the panel's backup, reading
certs/ through /selfpost-deploy, skipped links silently.

Now certs/fullchain.pem and certs/privkey.pem are links to
/etc/letsencrypt/live/<host>/, and /etc/letsencrypt is mounted read-only
at the same path: a commented line in deploy/docker-compose.yml for the
Apache scenario, mounted in the nginx fragment (./nginx/certbot-etc). The
guide's steps add certbot's own commands for a server that is not root -
chmod 0755 live archive, chgrp 998 + chmod 0640 the key, which certbot
keeps on renewal - so the panel reads them (Health, backup). Postfix's
side follows links already ([ -f ], cp, cmp, openssl). The vhost comment
says the same. The backup table: links are followed by the panel's
backup; a stopped tar stores them as links unless -h (GNU tar).

internal/backup: addTree follows, for certs/ only, a link to a regular
file and archives that file under the link's name; a link to nothing
reachable is an error naming it and the mount.

Tests: TestCertsLinksAreFollowed (certs/ -> live/ -> archive/, the
archive holds the files; the tree gone, Check names the dangling link;
Linux). Stand (2026-10-15, make e2e, exit 0): the stand mounts
./letsencrypt at /etc/letsencrypt; new certbot_layout_followed puts a new
pair into letsencrypt/archive/, relative links in live/, absolute links
in certs/, recreates the container: 465 serves the new serial, Health
names no renewal waiting, and the full backup holds certs/*.pem as
regular files with the archive's bytes. go vet and go test ./... pass on
the server (24 packages); the Linux-only tests of this session run there
and pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:34:19 +03:00
mixandClaude Opus 5.5 3c63ecc1ad store: migrations under BEGIN IMMEDIATE, the version read again inside
test / test (push) Canceled after 0s
D-S3. A deferred BEGIN starts nothing until the first access (SQLite,
lang_transaction.html § 2.2), and migrate() read user_version outside the
transaction: after an upgrade the panel and dmarc-ingest, opening the file
at once, could both read the old version, both begin, and the second
applied the next migration again on the migrated file - "already exists",
that Open failing (nothing damaged: its transaction rolled back). The DSN
now has _txlock=immediate (modernc.org/sqlite v1.60.1, sqlite.go
applyQueryParams() sets beginMode, tx.go newTx() issues "begin immediate"
for any transaction not read-only), so a migration holds the write lock
from its BEGIN, and migrate() reads user_version again inside it and
skips one another process applied meanwhile.

Test TestTwoOpensMigrateOnce: a raw connection holds BEGIN IMMEDIATE on a
new file while two Opens read version 0 and queue on their first BEGIN;
released, both succeed and user_version is the number of migrations.
Without the fix it fails as the register said: "apply migration
0001_init.sql: SQL logic error: table settings already exists (1)".
Stand (2026-10-15): go vet and go test ./... pass on the server (24
packages); make e2e exit 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:19:18 +03:00
mixandClaude Opus 5.5 9ca50d33fd web: Referrer-Policy same-origin; a password shown once emptied on pagehide
test / test (push) Canceled after 0s
D-B3. Fetch Standard § 3.2 "append a request `Origin` header": under
Referrer-Policy no-referrer a request that is not GET/HEAD and not CORS -
a form POST, a navigation - gets Origin "null". originAllowed() refuses
"null" whenever Sec-Fetch-Site is absent, and Fetch Metadata § 3 sends
Sec-Fetch-Site only to a potentially trustworthy URL: every form,
sign-in included, failed in Safari before 16.4 and in any browser on a
plain-HTTP panel at a name. same-origin nulls Origin only for a request
not same origin with its URL and still sends no referrer to another
site. The comments of the origin check say so.

D-B13. noStore keeps the page out of HTTP caches (RFC 9111 § 5.2.2.5);
a history mechanism "might honor" it (§ 6), and Pragma in a response
"was never specified" (§ 5.4) - it goes. CopyField.Secret marks the
password of out-app-created data-secret, and panel.js empties such
fields on pagehide (HTML § 7.5.9, "unload a Document", step 10.2: fired
whether or not the document is kept for history traversal).

Measured 2026-10-15 in Chrome 152 (the desktop app's browser), a local
probe page with Cache-Control: no-store and a data-secret field: Back
reloads it (pageshow persisted=false, a new load), so this browser keeps
no-store pages out of its back/forward cache; the handler, fired on the
same page, empties the field. Firefox and Safari not measured.

Tests: the header value; the created page has the password data-secret
and the login not, no Pragma. Stand (2026-10-15, make e2e, exit 0): new
form_post_with_the_browsers_origin - Referrer-Policy: same-origin on a
page; a POST carrying the page's own Origin and no Sec-Fetch-Site, what
a browser sends now, passes; one carrying Origin: null, what it sent
before, is 403. (A first run failed where the e2e client and a view test
scraped the old input markup; both read data-secret now.) go vet and go
test ./... pass on the server (24 packages).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:04:45 +03:00
499 changed files with 24493 additions and 11241 deletions
-22
View File
@@ -1,22 +0,0 @@
---
description: Panel UI mockups use the design system primitives, not a reskin of live templates.
globs: docs/assets/panel-ui/**
alwaysApply: false
---
# Panel UI system
Source of truth: [docs/assets/panel-ui/system.html](docs/assets/panel-ui/system.html) and `system.css`.
Do not copy `internal/web/view/templates` into mockups. Do not mark `<main>` as `ops`/`form` to pick width. Do not fix empty columns, Host/Type height, or split Save/Delete with one-off CSS.
Compose screens from `stack`, `pair`, `measure`, `fill`, `field-row`, `actions-row`:
- Two peer jobs → `pair` (one child shrinks to `measure`).
- Tables/logs/DNS → `fill` (nowrap + overflow-x on the card). Add-to-list is a `measure` row inside that card, not a second card above it.
- Confirm/login/user form → `measure`.
- Host ‖ Type and two equal inputs → `field-row` (shared grid row, not two `.code` paddings).
- Save + Delete → `actions-row` inside the card; never submit inside `<form>` and danger after `</form>`.
- Danger zone is the second column of the last `pair`, never a full-width `fill`.
- Card chrome is title + optional `?` + body + actions. Help is not sprinkled later.
- Phone: `pair` stacks; `field-row` does not; tables become lists.
- One screen is one HTML file. Shared chrome is `shell.js`. Do not put every screen in one hash-SPA.
+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
+23 -9
View File
@@ -6,7 +6,7 @@ name: release
# You can also run it manually via workflow_dispatch with an explicit SemVer X.Y.Z. # You can also run it manually via workflow_dispatch with an explicit SemVer X.Y.Z.
# #
# Native per-architecture builds (see docs/development.md), not qemu: running # Native per-architecture builds (see docs/development.md), not qemu: running
# the full Postfix/OpenDKIM stack under emulation for the e2e gate below is # the full Postfix and panel stack under emulation for the e2e gate below is
# impractically slow. Each arch builds, e2e-gates and pushes its own tag on its # impractically slow. Each arch builds, e2e-gates and pushes its own tag on its
# own native runner; a merge job then combines them into the one manifest tag # own native runner; a merge job then combines them into the one manifest tag
# documented in deploy/docker-compose.yml. "test, then push" (not push-by-digest # documented in deploy/docker-compose.yml. "test, then push" (not push-by-digest
@@ -66,14 +66,14 @@ jobs:
arch: arm64 arch: arm64
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with: with:
ref: v${{ needs.prepare.outputs.version }} 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) - 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: with:
context: . context: .
file: build/Dockerfile file: build/Dockerfile
@@ -83,7 +83,7 @@ jobs:
VERSION=${{ needs.prepare.outputs.version }} VERSION=${{ needs.prepare.outputs.version }}
tags: selfpost:e2e tags: selfpost:e2e
- uses: actions/setup-go@v5 - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with: with:
go-version: "1.26" go-version: "1.26"
cache-dependency-path: test/e2e/go.sum cache-dependency-path: test/e2e/go.sum
@@ -110,8 +110,22 @@ jobs:
fi fi
done 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 - name: Log in to ghcr.io
uses: docker/login-action@v3 uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -152,14 +166,14 @@ jobs:
needs: [prepare, build] needs: [prepare, build]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with: with:
ref: v${{ needs.prepare.outputs.version }} 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 - name: Log in to ghcr.io
uses: docker/login-action@v3 uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
+23 -2
View File
@@ -5,11 +5,16 @@ on:
branches: [main] branches: [main]
pull_request: pull_request:
# The job reads the repository and nothing else (security audit 2026-10-17,
# review of stages 2-6).
permissions:
contents: read
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with: with:
# design-first.sh reads each commit's parent and the template history. # design-first.sh reads each commit's parent and the template history.
fetch-depth: 0 fetch-depth: 0
@@ -20,7 +25,7 @@ jobs:
HEAD: ${{ github.event.pull_request.head.sha || github.sha }} HEAD: ${{ github.event.pull_request.head.sha || github.sha }}
run: bash .github/scripts/design-first.sh "$BASE" "$HEAD" run: bash .github/scripts/design-first.sh "$BASE" "$HEAD"
- uses: actions/setup-go@v5 - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with: with:
go-version: "1.26" go-version: "1.26"
cache: true cache: true
@@ -40,9 +45,24 @@ jobs:
- name: Vet - name: Vet
run: go vet ./... run: go vet ./...
# Unused code, dead branches and the usual slips go vet does not look
# for. The version is pinned: a new release adds checks, and a check
# that appears under a push is a red build nobody asked for. 2026.1 is
# the release for Go 1.26. test/e2e is a module of its own, so ./...
# skips it; it is checked in its own step below.
- name: Staticcheck
run: go run honnef.co/go/tools/cmd/staticcheck@2026.1 ./...
- name: Test - name: Test
run: go 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; # 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 # 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 # running it: `go test -run` would still run TestMain, which brings up
@@ -52,3 +72,4 @@ jobs:
run: | run: |
go vet ./... go vet ./...
go test -c -o /dev/null . go test -c -o /dev/null .
go run honnef.co/go/tools/cmd/staticcheck@2026.1 ./...
+1022 -7
View File
File diff suppressed because it is too large Load Diff
+27 -12
View File
@@ -6,9 +6,9 @@
Self-hosted SMTP relay with a web panel — one Docker image, no provider. Self-hosted SMTP relay with a web panel — one Docker image, no provider.
Postfix, OpenDKIM, and a small Go panel run together under `supervisord`; you Postfix and a small Go panel, which also signs outgoing mail with DKIM, run
configure domains, DKIM keys, and SASL applications once, then point your apps together under `supervisord`; you configure domains, DKIM keys, and SASL
at the SMTP endpoint. applications once, then point your apps at the SMTP endpoint.
SelfPost sends mail straight to the internet from **your own IP**, with SelfPost sends mail straight to the internet from **your own IP**, with
per-domain DKIM signing. No mailboxes or webmail. An optional inbound relay per-domain DKIM signing. No mailboxes or webmail. An optional inbound relay
@@ -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 inbound relay on port 25 (backup-MX / forwarder; off by default)
- Optional DMARC aggregate report ingest on port 25 (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 - 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) - Web panel grouped by mail direction — Overview, Outbound (domains, log, queue, DMARC reports), Inbound, Server (health, system log, backup, users, settings)
- Multi-domain relay — each SASL application is bound to one sending domain - Multi-domain relay — each SASL application is bound to one sending domain
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check - DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
- Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides - Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and per-application overrides
- Full-server backup and single-domain export/import (optional password encryption) - 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) - Single Docker image; production data in a `./data` bind mount (the quick start below uses a named Docker volume instead)
@@ -44,7 +44,7 @@ send log and DNS checks in the panel, encrypted backups.
| [Architecture](docs/architecture.md) | As-built technical design | | [Architecture](docs/architecture.md) | As-built technical design |
| [Security design](docs/security.md) | Mandatory requirements, accepted risks, the CSRF ADR | | [Security design](docs/security.md) | Mandatory requirements, accepted risks, the CSRF ADR |
| [Development](docs/development.md) | Building, testing, docs rules, model routing, commits | | [Development](docs/development.md) | Building, testing, docs rules, model routing, commits |
| [Roadmap](docs/roadmap.md) | Open work (1.x+) — direction, not commitments | | [Roadmap](docs/roadmap.md) | Open work — direction, not commitments |
| [CHANGELOG](CHANGELOG.md) | Release history | | [CHANGELOG](CHANGELOG.md) | Release history |
Found a vulnerability? Do not open an issue — [SECURITY.md](SECURITY.md) has Found a vulnerability? Do not open an issue — [SECURITY.md](SECURITY.md) has
@@ -62,9 +62,19 @@ missing PTR record for you. Details: [Operator guide](docs/guide.md).
- Docker + Compose v2 on the host - Docker + Compose v2 on the host
- A reverse proxy in front of the panel (SelfPost never terminates HTTPS itself) - A reverse proxy in front of the panel (SelfPost never terminates HTTPS itself)
- Rough sizing: **1 vCPU**, **512 MB–1 GB RAM**, **8–10 GB disk** (send log and - Machine, by what is switched on (measured; details and disk growth in the
rotated `mail.log` are the main growth drivers; both sit in the `./data` guide's [Machine requirements](docs/guide.md#machine-requirements)):
volume, and both are capped — 90 days and 14 files by default)
| Switched on | Host memory, at least | Images on disk |
|---|---|---|
| Outbound only (465/587), or with port 25 (inbound relay, DMARC reports) | 512 MB | 315 MB |
| With the spam filter (rspamd sidecar) | 1 GB | 626 MB |
One vCPU is enough. **Building the image needs 1.5 GB of memory and about
3 GB of free disk** (the Go compiler: a 1 GB build was killed) - on a
smaller machine, build elsewhere and `docker load` the image. The send log
is what grows: about 2-3 KB a message for the retention (90 days by
default) - 10 000 messages a day keep about 2.5 GB.
### Network and IP ### Network and IP
@@ -112,8 +122,8 @@ outbound mail will not reach the real internet without DNS, PTR, and port 25.
> local image and pulls nothing: > local image and pulls nothing:
> >
> ```sh > ```sh
> docker build -f build/Dockerfile --build-arg VERSION=2.5.1 \ > docker build -f build/Dockerfile --build-arg VERSION=2.16.0 \
> -t ghcr.io/mixeme/selfpost:2.5.1 . > -t ghcr.io/mixeme/selfpost:2.16.0 .
> ``` > ```
```sh ```sh
@@ -122,9 +132,14 @@ docker run --rm -d --name selfpost-try \
-e SELFPOST_HOSTNAME=mail.local.test \ -e SELFPOST_HOSTNAME=mail.local.test \
-e PANEL_COOKIE_SECURE=false \ -e PANEL_COOKIE_SECURE=false \
-v selfpost-try-data:/data \ -v selfpost-try-data:/data \
ghcr.io/mixeme/selfpost:2.5.1 ghcr.io/mixeme/selfpost:2.16.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): **Get the setup URL** (pick one):
```sh ```sh
+12 -4
View File
@@ -3,15 +3,22 @@
## Supported versions ## Supported versions
SelfPost follows SemVer. Fixes are issued for the **latest minor release of the 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`. reporting if you are behind — the image tag is `ghcr.io/mixeme/selfpost:X.Y.Z`.
| Version | Supported | | Version | Supported |
|---|---| |---|---|
| latest 1.x | yes | | latest 2.x | yes |
| earlier 1.x | no — upgrade first | | earlier 2.x | no — upgrade first |
| 1.x | no — upgrade to 2.x (guide § Upgrading) |
| 0.x | no (pre-release) | | 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 ## Reporting a vulnerability
**Do not open a public issue.** Use GitHub's private vulnerability reporting: **Do not open a public issue.** Use GitHub's private vulnerability reporting:
@@ -70,7 +77,8 @@ These are the operator's responsibility or accepted trade-offs, documented in
- Deliverability outcomes: mail rejected or filtered by a receiving provider is - Deliverability outcomes: mail rejected or filtered by a receiving provider is
a policy decision of that provider, not a defect a policy decision of that provider, not a defect
- Denial of service through sheer volume against a single-tenant relay - Denial of service through sheer volume against a single-tenant relay
- Vulnerabilities in upstream Postfix, OpenDKIM, or the base image — report - Vulnerabilities in upstream Postfix, the Go libraries SelfPost builds on,
or the base image — report
those upstream; if SelfPost's configuration makes an upstream issue those upstream; if SelfPost's configuration makes an upstream issue
exploitable when it otherwise would not be, that *is* in scope exploitable when it otherwise would not be, that *is* in scope
+100 -38
View File
@@ -1,20 +1,23 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# #
# SelfPost — single Debian-slim image running postfix + opendkim + panel under # SelfPost — single Debian-slim image running postfix + panel under
# supervisord. Build from the repository root: # supervisord. Build from the repository root:
# #
# docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev . # docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .
# ---- build stage ------------------------------------------------------------- # ---- build stage -------------------------------------------------------------
FROM golang:1.26-bookworm AS build FROM golang:1.26-trixie AS build
WORKDIR /src WORKDIR /src
# Version stamped into both binaries; MUST match the image tag. # Version stamped into both binaries; MUST match the image tag.
ARG VERSION=dev ARG VERSION=dev
# Module metadata first for layer caching. # Module metadata first for layer caching. third_party/go-milter is a module
# go.mod replaces go-milter with (third_party/go-milter/PATCHES.md): the
# download needs it.
COPY go.mod go.sum ./ COPY go.mod go.sum ./
COPY third_party ./third_party
RUN go mod download RUN go mod download
COPY cmd ./cmd COPY cmd ./cmd
@@ -30,61 +33,114 @@ RUN go vet ./... \
-o /out/selfpost-backup ./cmd/selfpost-backup -o /out/selfpost-backup ./cmd/selfpost-backup
# ---- runtime stage ----------------------------------------------------------- # ---- runtime stage -----------------------------------------------------------
FROM debian:bookworm-slim AS runtime FROM debian:trixie-slim AS runtime
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
# Preseed Postfix so its install is non-interactive and yields a working # Preseed Postfix so its install is non-interactive and yields a working
# main.cf. The real relay configuration is generated by the panel. # main.cf. The real relay configuration is generated by the panel.
# (postfix 3.7.11-0+deb12u1, debian/templates: postfix/main_mailer_type and # (postfix 3.10.13-0+deb13u1, debian/templates:14 and :50:
# postfix/mailname are the questions the install asks.) # postfix/main_mailer_type and postfix/mailname are the questions the install
# What else arrives through Depends, and is used here: openssl, by # asks; debian/postfix.postinst:156-171 writes main.cf for any answer but "No
# ca-certificates ("Depends: openssl (>= 1.1.1)", packages.debian.org/bookworm/ # configuration".)
# ca-certificates) - postfix-cert-reload.sh; python3, by supervisor (supervisor # openssl is named because postfix-cert-reload.sh runs it, not left to arrive
# 4.2.5-1, debian/control, ${python3:Depends}) - crashexit.py. sasl2-bin brings # with ca-certificates. python3 arrives with supervisor (supervisor 4.2.5-3,
# saslpasswd2 and libsasl2-modules the PLAIN and LOGIN plugins; the sasldb # debian/control:29, ${python3:Depends}) - crashexit.py. sasl2-bin brings
# auxprop plugin is in libsasl2-modules-db, a Depends of libsasl2-2 # saslpasswd2 (cyrus-sasl2 2.1.28+dfsg1-9, debian/rules:207,
# (cyrus-sasl2 2.1.28+dfsg-10, debian/control and debian/*.install). db-util # debian/sasl2-bin.install:3, usr/sbin) and depends on db-util
# ships /usr/bin/db_dump (packages.debian.org/bookworm/amd64/db-util/filelist). # (debian/control:36); libsasl2-modules brings the PLAIN and LOGIN plugins
# (debian/libsasl2-modules.install:4 and :6); the sasldb auxprop plugin is in
# libsasl2-modules-db (debian/libsasl2-modules-db.install:1), a Depends of
# libsasl2-2 (debian/control:65). db-util is named for /usr/bin/db_dump, which
# the panel runs (internal/app/sasl.go); that db-util ships that path on
# trixie is not in a source read here - the image build and the e2e sasldb
# test say whether it does.
# No DKIM package is installed: the panel signs itself (internal/milter,
# sign.go) and makes the keys in Go (internal/domain/dkim.go); OpenDKIM left
# the image in 2.8.0 (docs/architecture.md § DKIM signing).
# systemd-standalone-sysusers is named so that cron-daemon-common, which
# logrotate brings (logrotate 3.22.0-1, debian/control: "Depends: cron |
# anacron | cron-daemon | systemd-sysv"), creates its group with it rather
# than with the whole of systemd, the first alternative of its "Depends:
# systemd | systemd-standalone-sysusers | systemd-sysusers" (dpkg -s in the
# image, 2026-10-15). Nothing here runs systemd.
# tzdata is named for TZ (local-time): the zone of every time the panel shows
# and of the stamps Postfix writes in mail.log. supervisor's python3.13 brings
# it today (apt-cache rdepends --installed tzdata in the image, 2026-10-17);
# a TZ the image did not have would leave both in UTC without a word.
RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \ RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \
&& echo "postfix postfix/main_mailer_type string Internet Site" | debconf-set-selections \ && echo "postfix postfix/main_mailer_type string Internet Site" | debconf-set-selections \
&& apt-get update \ && apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
postfix \ postfix \
opendkim \
opendkim-tools \
sasl2-bin \ sasl2-bin \
libsasl2-modules \ libsasl2-modules \
db-util \ db-util \
supervisor \ supervisor \
logrotate \ logrotate \
ca-certificates \ ca-certificates \
openssl \
curl \ curl \
systemd-standalone-sysusers \
tzdata \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Unprivileged user for the panel process. # Debian's postfix runs its chroot sync on every "postfix check" and "postfix
RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel # start-fg" (postfix 3.10.13-0+deb13u1, debian/patches/
# debian-run-configure-instance-from-create-missing.patch); with no service
# chrooted (build/postfix-config.sh) it would only copy /etc/resolv.conf and
# the like into the queue under /data on each start. SYNC_CHROOT empty turns
# it off (debian/configure-instance.in:22-24 and :44; debian/README.Debian:136-138).
RUN printf 'SYNC_CHROOT=\n' > /etc/default/postfix
# Shared group bridging the unprivileged services: the panel generates # Unprivileged user for the panel process, with ids of its own outside the
# per-domain DKIM keys, application SASL accounts (sasldb2) and the Postfix # system range for the same reason as selfpost's below: its files under
# sender map, while OpenDKIM and Postfix (different users) must read them. # /data show on the host by number, and a number from that range is a
# Membership in this group — plus setgid dirs under /data (set up in # system group of the host's too (useradd gave the panel group 998 in the
# entrypoint.sh) — lets OpenDKIM read the panel-owned keys and lets Postfix read # trixie image, systemd-network on the test server's host; bookworm's
# the sasldb2/sender map, and lets the panel reach the supervisor control socket # 999:999 was the host's systemd-journal group). The entrypoint gives /data
# to signal OpenDKIM/Postfix reloads without any process running as root. # back to it by name on every start, so files of the old ids follow.
# The id is fixed, and it is the one the group has always had: an operator RUN groupadd --system --gid 10999 panel && useradd --system --uid 10999 --gid panel --no-create-home --shell /usr/sbin/nologin panel
# 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). # Shared group bridging the unprivileged services: the panel writes the
# groupadd(8) refuses a --gid already in use, so a package that took 998 would # application SASL accounts (sasldb2) and the Postfix sender map and opens the
# milter sockets, while Postfix (another user) must read and reach them.
# Membership in this group - plus setgid dirs under /data and /run/selfpost
# (set up in entrypoint.sh) - lets Postfix read the sasldb2/sender map and
# connect to the panel's milters. The DKIM keys are the panel's alone. (The
# supervisor control socket has a group of its own, `supervisor`, below.)
# The id is fixed, because the host 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
# read in the image, 2026-10-15), so no package can take it, and far above
# the 1000 a host starts its users' groups at, so a host is unlikely to have
# a group of that number that would read the key too. Until 2.7.0 it was
# 998, inside that range: in the trixie image systemd took 998 for
# systemd-network and the build failed, and on the test server's host 998
# is systemd-network too (seen 2026-10-15).
# groupadd(8) refuses a --gid already in use, so an id taken after all would
# fail this build rather than share the group. # fail this build rather than share the group.
RUN groupadd --system --gid 998 selfpost \ RUN groupadd --system --gid 10998 selfpost \
&& usermod -aG selfpost panel \ && usermod -aG selfpost panel \
&& usermod -aG selfpost opendkim \
&& usermod -aG selfpost postfix && 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 # 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 # queue card). postqueue is setgid postdrop, which normally gives it the group
# needed to reach Postfix's showq socket — but the documented deployment runs # needed to reach Postfix's showq socket - but the documented deployment runs
# with `no-new-privileges`, which disables setgid transitions, so the panel # with `no-new-privileges`, which disables setgid transitions, so the panel
# would always see "Permission denied". Making `panel` a real member of postdrop # would always see "Permission denied". Making `panel` a real member of postdrop
# grants the same access without relying on a setgid escalation the hardening # grants the same access without relying on a setgid escalation the hardening
@@ -93,14 +149,20 @@ RUN groupadd --system --gid 998 selfpost \
# to write the maildrop directory (internal/notify/mailer.go). It does not let # to write the maildrop directory (internal/notify/mailer.go). It does not let
# the panel bypass any Postfix restriction that a local user does not already # the panel bypass any Postfix restriction that a local user does not already
# have through the world-executable sendmail. # have through the world-executable sendmail.
# (Postfix 3.7.11, conf/postfix-files: postdrop and postqueue are # (Postfix 3.10.13, conf/postfix-files:140-141: postdrop and postqueue are
# root:$setgid_group 2755. Linux, Documentation/userspace-api/no_new_privs.rst: # root:$setgid_group 2755; Debian sets them root:postdrop 02555, postfix
# 3.10.13-0+deb13u1, debian/postfix.postinst:134-141. Linux,
# Documentation/userspace-api/no_new_privs.rst:
# with no_new_privs, execve() no longer changes the gid for a setgid file.) # with no_new_privs, execve() no longer changes the gid for a setgid file.)
RUN usermod -aG postdrop panel RUN usermod -aG postdrop panel
# The supervisord control socket's group (supervisord.conf, [unix_http_server]):
# the panel alone - not Postfix's user, which shares `selfpost` for the files
# under /data.
RUN groupadd --system supervisor \
&& usermod -aG supervisor panel
# Runtime directories: milter sockets and the consolidated persistent root. # Runtime directories: milter sockets and the consolidated persistent root.
RUN mkdir -p /run/opendkim /run/selfpost /data \ RUN mkdir -p /run/selfpost /data \
&& chown opendkim:opendkim /run/opendkim \
&& chown panel:panel /run/selfpost /data && chown panel:panel /run/selfpost /data
COPY --from=build /out/panel /usr/local/bin/panel COPY --from=build /out/panel /usr/local/bin/panel
@@ -117,9 +179,9 @@ COPY --chmod=0644 internal/web/view/static/OFL.txt /usr/share/doc/selfpost/OFL.t
# POSIX permissions (e.g. Windows tar sync) cannot land group-writable config # POSIX permissions (e.g. Windows tar sync) cannot land group-writable config
# that logrotate would silently ignore — see docs/development.md § Building # that logrotate would silently ignore — see docs/development.md § Building
# binaries and the image. # binaries and the image.
COPY --chmod=0644 build/opendkim.conf /etc/opendkim.conf
COPY --chmod=0644 build/logrotate-mail.conf /etc/logrotate.d/mail COPY --chmod=0644 build/logrotate-mail.conf /etc/logrotate.d/mail
COPY --chmod=0755 build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh COPY --chmod=0755 build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh
COPY --chmod=0755 build/postfix-reload.sh /usr/local/bin/postfix-reload.sh
COPY --chmod=0755 build/postfix-config.sh /usr/local/bin/postfix-config.sh COPY --chmod=0755 build/postfix-config.sh /usr/local/bin/postfix-config.sh
COPY --chmod=0755 build/postfix-cert-reload.sh /usr/local/bin/postfix-cert-reload.sh COPY --chmod=0755 build/postfix-cert-reload.sh /usr/local/bin/postfix-cert-reload.sh
COPY --chmod=0755 build/logrotate-loop.sh /usr/local/bin/logrotate-loop.sh COPY --chmod=0755 build/logrotate-loop.sh /usr/local/bin/logrotate-loop.sh
@@ -132,7 +194,7 @@ COPY --chmod=0644 build/supervisord.conf /etc/supervisor/supervisord.conf
# Outbound delivery dials remote MXs on 25 as a client even when inbound is off. # Outbound delivery dials remote MXs on 25 as a client even when inbound is off.
EXPOSE 8080 465 587 25 EXPOSE 8080 465 587 25
# Liveness probe: panel HTTP plus mail-path processes (opendkim, panel, postfix). # Liveness probe: panel HTTP plus mail-path processes (panel, postfix).
# Does not verify TLS, DNS, or end-to-end delivery — see docs/guide.md Operations. # Does not verify TLS, DNS, or end-to-end delivery — see docs/guide.md Operations.
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1 CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1
+65 -22
View File
@@ -2,30 +2,50 @@
"""Supervisor event listener: bring the container down on unrecoverable failure. """Supervisor event listener: bring the container down on unrecoverable failure.
If a managed program of the mail path exhausts its restart retries and If a managed program of the mail path exhausts its restart retries and
enters FATAL, this listener signals supervisord (its parent) to terminate, so enters FATAL, or keeps crashing - CRASH_LIMIT unexpected exits within
the whole container exits and Docker's `restart` policy starts it again — CRASH_WINDOW seconds - this listener signals supervisord (its parent) to
rather than lingering "alive" with a dead Postfix/OpenDKIM/panel. logrotate is terminate, so the whole container exits and Docker's `restart` policy starts it
not on the mail path: its FATAL (a config it would ignore) stays on again, rather than lingering "alive" with a dead Postfix or panel.
Server > Health, and mail keeps flowing (assumptions C-V5). FATAL alone did not cover the second: a program reaches FATAL only through
exits before its startsecs, and one that crashed after it was restarted for
ever (audit 2026-10-12, L32; supervisor 4.2.5, supervisor/process.py,
Subprocess.finish()). logrotate is not on the mail path: its FATAL (a config
it would ignore) stays on Server > Health, and mail keeps flowing
(assumptions C-V5).
Communication with supervisord uses the event listener protocol over stdin and Communication with supervisord uses the event listener protocol over stdin and
stdout, so stdout must carry only protocol tokens. stdout, so stdout must carry only protocol tokens.
supervisor 4.2.5, docs/events.rst, "Event Listener Notification Protocol": the supervisor 4.2.5, docs/events.rst, "Event Listener Notification Protocol": the
listener writes READY\\n, reads one header line of key:value tokens whose len listener writes READY\\n, reads one header line of key:value tokens whose len
is the payload's length, reads the payload, and answers "RESULT 2\\nOK". is the payload's length and eventname the event's type, reads the payload, and
A PROCESS_STATE_FATAL payload is a token set, "processname:cat groupname:cat answers "RESULT 2\\nOK". A PROCESS_STATE_FATAL payload is a token set,
from_state:BACKOFF" (docs/events.rst; supervisor/events.py, "processname:cat groupname:cat from_state:BACKOFF"; a PROCESS_STATE_EXITED one
ProcessStateEvent.payload()). adds "expected:0" for an exit code the program's exitcodes do not list, "1"
otherwise (docs/events.rst; supervisor/events.py, ProcessStateEvent.payload()).
supervisord starts the listener as its own child, and on SIGTERM it shuts down supervisord starts the listener as its own child, and on SIGTERM it shuts down
(docs/running.rst, "Signals"); it is PID 1 here (build/entrypoint.sh, exec). (docs/running.rst, "Signals"); it is PID 1 here (build/entrypoint.sh, exec).
CRASHEXIT_DRYRUN=1 writes "crashexit: would shut down" instead of signalling,
for the test (build/crashexit_test.go).
""" """
import os import os
import signal import signal
import sys import sys
import time
# Programs whose FATAL does not take the container down (build/supervisord.conf). # Programs whose failure does not take the container down
SPARED = {"logrotate"} # (build/supervisord.conf): logrotate is not on the mail path, postfix-reload
# runs on demand - a reload it could not start is reported to whoever asked for
# it - and cert-reload looks for a renewed certificate once a day (a crash of
# it leaves the one Postfix serves).
SPARED = {"logrotate", "postfix-reload", "cert-reload"}
# CRASH_LIMIT unexpected exits of one program within CRASH_WINDOW seconds are a
# program that does not stay up. A program on the mail path restarts in
# seconds; five times in ten minutes is no passing fault.
CRASH_LIMIT = 5
CRASH_WINDOW = 600
def write_stdout(s): def write_stdout(s):
@@ -33,7 +53,20 @@ def write_stdout(s):
sys.stdout.flush() sys.stdout.flush()
def say(s):
sys.stderr.write("crashexit: " + s + "\n")
sys.stderr.flush()
def shut_down():
if os.environ.get("CRASHEXIT_DRYRUN") == "1":
say("would shut down")
return
os.kill(os.getppid(), signal.SIGTERM)
def main(): def main():
crashes = {} # program -> times of its unexpected exits in the window
while True: while True:
# Tell supervisord we are ready for the next event. # Tell supervisord we are ready for the next event.
write_stdout("READY\n") write_stdout("READY\n")
@@ -48,22 +81,32 @@ def main():
pair.split(":", 1) for pair in payload.split() if ":" in pair pair.split(":", 1) for pair in payload.split() if ":" in pair
) )
name = tokens.get("processname", "") name = tokens.get("processname", "")
event = headers.get("eventname", "PROCESS_STATE_FATAL")
# We only subscribe to PROCESS_STATE_FATAL, so any event means a managed if event == "PROCESS_STATE_EXITED":
# program can no longer be restarted. if name not in SPARED and tokens.get("expected") == "0":
if name in SPARED: now = time.monotonic()
sys.stderr.write( times = [t for t in crashes.get(name, []) if now - t < CRASH_WINDOW]
"crashexit: %s entered FATAL; it is not on the mail path, the " times.append(now)
"container stays up\n" % name crashes[name] = times
if len(times) >= CRASH_LIMIT:
say(
"%s exited unexpectedly %d times in %d seconds; shutting down container"
% (name, len(times), CRASH_WINDOW)
)
shut_down()
crashes[name] = []
elif name in SPARED:
say(
"%s entered FATAL; it is not on the mail path, the container stays up"
% name
) )
sys.stderr.flush()
else: else:
sys.stderr.write( say(
"crashexit: %s entered FATAL; shutting down container\n" "%s entered FATAL; shutting down container"
% (name or "a managed process") % (name or "a managed process")
) )
sys.stderr.flush() shut_down()
os.kill(os.getppid(), signal.SIGTERM)
write_stdout("RESULT 2\nOK") write_stdout("RESULT 2\nOK")
+95
View File
@@ -0,0 +1,95 @@
// Package build holds the image's scripts; this file tests crashexit.py, the
// supervisor event listener that takes the container down.
package build
import (
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"testing"
)
// event is one event as supervisord writes it to a listener's stdin
// (supervisor 4.2.5, docs/events.rst: a header line whose len is the
// payload's length, then the payload).
func event(name, payload string) string {
return fmt.Sprintf("ver:3.0 server:supervisor serial:1 pool:crashexit poolserial:1 eventname:%s len:%d\n%s", name, len(payload), payload)
}
// runListener feeds events to crashexit.py in dry run and returns what it
// wrote to stderr.
func runListener(t *testing.T, events ...string) string {
t.Helper()
// python3 on Linux; on Windows "python3" may be the Store's stub, which
// runs nothing.
names := []string{"python3", "python"}
if runtime.GOOS == "windows" {
names = []string{"python", "python3"}
}
python := ""
for _, n := range names {
if p, err := exec.LookPath(n); err == nil {
python = p
break
}
}
if python == "" {
t.Skip("no python")
}
cmd := exec.Command(python, "crashexit.py")
cmd.Env = append(os.Environ(), "CRASHEXIT_DRYRUN=1")
cmd.Stdin = strings.NewReader(strings.Join(events, ""))
var stderr strings.Builder
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
t.Fatalf("crashexit.py: %v\n%s", err, stderr.String())
}
// Python on Windows writes its text stdout with \r\n.
if n := strings.Count(strings.ReplaceAll(string(out), "\r\n", "\n"), "RESULT 2\nOK"); n != len(events) {
t.Errorf("answered %d of %d events:\n%q", n, len(events), out)
}
return stderr.String()
}
// A program on the mail path that keeps crashing after its startsecs - never
// FATAL - takes the container down at the fifth unexpected exit (audit
// 2026-10-12, L32); expected exits, spared programs and fewer crashes do not.
func TestCrashexitCountsCrashes(t *testing.T) {
exit := func(name, expected string) string {
return event("PROCESS_STATE_EXITED", "processname:"+name+" groupname:"+name+" from_state:RUNNING expected:"+expected+" pid:7")
}
var four []string
for i := 0; i < 4; i++ {
four = append(four, exit("panel", "0"))
}
if got := runListener(t, four...); strings.Contains(got, "would shut down") {
t.Errorf("four crashes shut down:\n%s", got)
}
if got := runListener(t, append(four, exit("panel", "0"))...); !strings.Contains(got, "panel exited unexpectedly 5 times") || !strings.Contains(got, "would shut down") {
t.Errorf("five crashes did not shut down:\n%s", got)
}
var calm []string
for i := 0; i < 6; i++ {
calm = append(calm, exit("panel", "1"), exit("logrotate", "0"))
}
calm = append(calm, exit("postfix", "0"), exit("postfix", "0"), exit("postfix", "0"))
if got := runListener(t, calm...); strings.Contains(got, "would shut down") {
t.Errorf("expected exits, spared programs and three crashes of another shut down:\n%s", got)
}
}
// FATAL of a mail-path program shuts down at once; of a spared one, not.
func TestCrashexitFatal(t *testing.T) {
fatal := func(name string) string {
return event("PROCESS_STATE_FATAL", "processname:"+name+" groupname:"+name+" from_state:BACKOFF")
}
if got := runListener(t, fatal("postfix")); !strings.Contains(got, "postfix entered FATAL; shutting down") || !strings.Contains(got, "would shut down") {
t.Errorf("postfix FATAL:\n%s", got)
}
if got := runListener(t, fatal("logrotate"), fatal("cert-reload")); strings.Contains(got, "would shut down") {
t.Errorf("a spared program's FATAL shut down:\n%s", got)
}
}
+143 -72
View File
@@ -58,39 +58,80 @@ esac
# — the one place still running as root — before handing off to supervisord, # — the one place still running as root — before handing off to supervisord,
# which starts the panel as the panel user. # which starts the panel as the panel user.
# #
# Mode must stay world-traversable (0755): OpenDKIM and Postfix reach their # Mode must stay world-traversable (0755): Postfix reaches its trees under
# trees under /data as other users. Go's testing.TempDir is 0700, and a bare # /data as another user. Go's testing.TempDir is 0700, and a bare chown would
# chown would leave that mode in place — opendkim then cannot read KeyTable and # leave that mode in place — OpenDKIM, which read its tables there until
# the container crash-loops (e2e TestHostnameGate/valid_hostname_starts). # 2.8.0, then could not, and the container crash-looped (e2e
# TestHostnameGate/valid_hostname_starts).
# (Go: testing.TempDir uses os.MkdirTemp, src/os/tempfile.go: "created with # (Go: testing.TempDir uses os.MkdirTemp, src/os/tempfile.go: "created with
# mode 0o700".) # mode 0o700".)
chown panel:panel /data chown panel:panel /data
chmod 755 /data chmod 755 /data
# What follows runs as root over a tree the panel user writes: /data is the
# panel's, and so is /data/postfix. A symbolic link the panel put where a
# directory or a file of its own is expected would lead root's mkdir, ": >",
# chmod and chgrp to whatever it names (chmod follows a link; so do a
# redirect and mkdir -p; chown -R and find do not descend into one given as
# their operand). None of these paths is ever a link in a working
# installation, so a link there is removed and the path made anew below.
# (Security review of base-trixie, 2026-10-15: no escalation found, as the
# TLS key copy and the read-only mounts stand, but nothing for root to
# follow either.)
drop_links() {
for p in "$@"; do
if [ -L "$p" ]; then
echo "entrypoint: removing the symbolic link $p" >&2
rm -f "$p"
fi
done
}
drop_links /data/dkim /data/dkim.new /data/opendkim /data/sasl /data/postfix /data/antispam /data/log \
/data/dkim/keys /data/dkim/selectors \
/data/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 # Restored backups or previously-created state may contain panel-owned files
# under /data; make sure they stay writable without disturbing anything that a # under /data; make sure they stay writable without disturbing anything that a
# later phase deliberately hands to another service. /data/log is exempt: it is # later phase deliberately hands to another service. /data/log is exempt: it is
# deliberately owned by postfix (postlogd writes the delivery log there) and is # deliberately owned by postfix (postlogd writes the delivery log there) and is
# normalised on its own below. # normalised on its own below, as are the trees shared through group selfpost.
find /data -mindepth 1 -maxdepth 1 ! -user panel ! -name log ! -name postfix -exec chown -R panel:panel {} + # A file's owner is a number: an entry whose user or group is not the panel's
# by that number - unpacked by root, or written under another image whose
# image gave the panel other ids (999:999 until 2.7.0, 10999:10999 since) - is
# handed over whole.
find /data -mindepth 1 -maxdepth 1 ! -name log ! -name postfix ! -name sasl ! -name antispam \
\( ! -user panel -o ! -group panel \) -exec chown -R panel:panel {} +
# DKIM key tree. The panel (user `panel`) generates keys and writes # DKIM keys and the selector table, /data/dkim: the panel's alone - it makes
# the OpenDKIM tables; OpenDKIM (user `opendkim`) must read them. Normalise the # the keys, writes the table and signs (internal/domain, Keys; internal/milter,
# tree on every start so it is correct whether /data is fresh, restarted, or # sign.go). The selector table is written by the panel before its signer
# just restored from a backup: # serves (cmd/panel/signer.go). Until 2.8.0 the keys were under
# - group `selfpost` + setgid on directories so anything the panel creates # /data/opendkim, shared with OpenDKIM; 2.8.x copied them to /data/dkim at its
# inherits the shared group OpenDKIM traverses (inode(7), "The # first start and kept the old tree for a rollback to 2.7.x until the next
# set-group-ID bit": files created in such a directory take its group); # release, this one (CHANGELOG [2.9.0]). It is removed
# - private keys and tables group-readable (0640); # once /data/dkim holds a key. A /data that never ran 2.8.x - keys under the
# - both table files present (empty is fine) BEFORE OpenDKIM starts, so the # old tree only - stops the start instead: the panel would make the server's
# daemon comes up cleanly with no domains yet. # own name a new key, and its published record would no longer match; 2.8.x
mkdir -p /data/opendkim/keys # copies them (guide § Upgrading). rm -rf removes a link, not what it points
for t in /data/opendkim/KeyTable /data/opendkim/SigningTable; do # to, and follows none inside (GNU coreutils rm(1)); a link at the path is
[ -e "$t" ] || : > "$t" # gone already (drop_links). (e2e TestStartsOnA28Data, TestRefusesA27Data.)
done has_keys() { [ -d "$1" ] && [ -n "$(find "$1" -type f -name '*.private' | head -n 1)" ]; }
chown -R panel:selfpost /data/opendkim if [ -d /data/opendkim ]; then
find /data/opendkim -type d -exec chmod 2750 {} + if ! has_keys /data/dkim/keys && has_keys /data/opendkim/keys; then
chmod 0640 /data/opendkim/KeyTable /data/opendkim/SigningTable echo "entrypoint: the DKIM keys are only under /data/opendkim, as 2.7.x kept them; start 2.8.x once first - it copies them to /data/dkim - then this image (guide, § Upgrading)" >&2
find /data/opendkim/keys -type f -name '*.private' -exec chmod 0640 {} + exit 1
fi
rm -rf /data/opendkim
echo "entrypoint: removed /data/opendkim, the key tree of 2.7.x; the keys are under /data/dkim" >&2
fi
rm -rf /data/dkim.new
mkdir -p /data/dkim/keys
chown -R panel:panel /data/dkim
find /data/dkim -type d -exec chmod 0700 {} + -exec chmod g-s {} +
find /data/dkim -type f -exec chmod 0600 {} +
# Application SASL accounts. The panel (user `panel`) writes the # Application SASL accounts. The panel (user `panel`) writes the
# sasldb2 via saslpasswd2; Postfix (user `postfix`) reads it to authenticate SMTP # sasldb2 via saslpasswd2; Postfix (user `postfix`) reads it to authenticate SMTP
@@ -101,18 +142,29 @@ mkdir -p /data/sasl
chown -R panel:selfpost /data/sasl chown -R panel:selfpost /data/sasl
chmod 2750 /data/sasl chmod 2750 /data/sasl
[ -e /data/sasl/sasldb2 ] && chmod 0640 /data/sasl/sasldb2 [ -e /data/sasl/sasldb2 ] && chmod 0640 /data/sasl/sasldb2
# The copy a password change is made on (internal/app, replaceSASLDB), left
# by a panel killed mid-change: a second copy of every password, which the
# next change would remove anyway.
rm -f /data/sasl/sasldb2.new
# Postfix state under /data (architecture.md § Persistence). The panel writes # Postfix state under /data (architecture.md § Persistence). The panel writes
# sender_login_maps; Postfix owns the on-disk queue tree under queue/. # sender_login_maps; Postfix owns the on-disk queue tree under queue/.
mkdir -p /data/postfix/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). # 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. # 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 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"
chown panel:selfpost "/data/postfix/$f"
chmod 0640 "/data/postfix/$f"
done done
# Every map here is the panel's to write and Postfix's to read - the sender
# map, the inbound and the DMARC maps alike - so each file beside queue/ is
# handed to the panel, group selfpost, whatever its name. A restored archive's
# files arrive owned by whoever unpacked them (a backup records no owner, and
# tar run as root makes them root's), and the first start after a restore
# reads every one of them to compare it with the archive's record: one left
# to root alone is a restore refused on every start (review of audit stage 3,
# H1 - the DMARC maps were not among the names listed here).
find /data/postfix -mindepth 1 -maxdepth 1 -type f -exec chown panel:selfpost {} + -exec chmod 0640 {} +
chown panel:selfpost /data/postfix chown panel:selfpost /data/postfix
chmod 2750 /data/postfix chmod 2750 /data/postfix
@@ -122,45 +174,43 @@ chmod 2750 /data/postfix
# so the directory and its files are readable by anyone: they hold sender # so the directory and its files are readable by anyone: they hold sender
# addresses and networks, no secrets. # addresses and networks, no secrets.
mkdir -p /data/antispam mkdir -p /data/antispam
chown panel:selfpost /data/antispam chown -R panel:selfpost /data/antispam
chmod 0755 /data/antispam chmod 0755 /data/antispam
chown panel:selfpost /data/postfix/sender_login_maps
chmod 0640 /data/postfix/sender_login_maps
# Delivery log (architecture.md § Log tailer). postlogd writes it as user # Delivery log (architecture.md § Log tailer). postlogd writes it as user
# `postfix`; the panel reads it for the log-tailer and the System log page. It # `postfix`; the panel reads it for the log-tailer and the System log page. It
# lives under /data — not the ephemeral /var/log — so the delivery lines that # lives under /data — not the ephemeral /var/log — so the delivery lines that
# resolve a "queued" send-log row survive a container recreate. # resolve a "queued" send-log row survive a container recreate.
# #
# postlogd creates a missing log itself, but at 0600, which the unprivileged # postlogd creates a missing log itself, by default at 0600, which the
# panel cannot read; so create it here (and re-normalise an existing one, plus # unprivileged panel cannot read (postfix-config.sh now asks it for 0640,
# whatever logrotate left behind) at 0640 owned postfix:selfpost. The setgid # maillog_file_permissions); so create it here (and re-normalise an existing
# one, plus whatever logrotate left behind) at 0640 owned postfix:selfpost. The setgid
# directory keeps the shared group on anything created inside it later, and # directory keeps the shared group on anything created inside it later, and
# 2750 keeps it group-traversable but not group-writable — logrotate refuses to # 2750 keeps it group-traversable but not group-writable — logrotate refuses to
# rotate a log whose directory is writable by a non-root group. # rotate a log whose directory is writable by a non-root group.
# Postfix 3.7.11: src/util/logwriter.c, logwriter_open_or_die(), creates the # Postfix 3.10.13: src/util/logwriter.c:99, logwriter_open_or_die(), creates the
# file 0644, under master's umask(077) (src/master/master.c) - 0600. logrotate # file with logwriter_perms, 0600 (:86) unless maillog_file_permissions says
# 3.21.0, logrotate.c, findNeedRotating(): "skipping ... because parent # otherwise (proto/postconf.proto:18500, default 0600; Postfix 3.9 and later).
# directory has insecure permissions" when the directory's group is not root # logrotate 3.22.0, logrotate.c:1406-1408, findNeedRotating(): "skipping ...
# and has write permission. # because parent directory has insecure permissions" when the directory's
# group is not root and has write permission.
mkdir -p /data/log 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 chown -R postfix:selfpost /data/log
chmod 2750 /data/log chmod 2750 /data/log
find /data/log -type f -exec chmod 0640 {} + find /data/log -type f -exec chmod 0640 {} +
# Milter socket directories. Postfix (user `postfix`) must actually CONNECT to # The milter socket directory. Postfix (user `postfix`) must actually CONNECT
# the milter sockets — OpenDKIM's and the panel's — not just probe them at # to the panel's milter sockets, not just probe them at start-up. The panel
# start-up. The sockets are created by the opendkim and panel # user creates them, so they are bridged to `postfix` through the shared
# users respectively, so bridge them to `postfix` through the shared `selfpost` # `selfpost` group: a group-owned + setgid dir means each socket created
# group: group-owned + setgid dirs mean each socket created inside inherits # inside inherits group `selfpost`, and group-traversable (2750) lets postfix
# group `selfpost`, and group-traversable (2750) lets postfix reach it. Without # reach it. Without this, smtpd cannot talk to the signer and, because
# this, smtpd cannot talk to OpenDKIM and, because signing is strict # signing is strict (default_action=tempfail), defers all mail.
# (default_action=tempfail), rejects all mail. mkdir -p /run/selfpost
mkdir -p /run/opendkim /run/selfpost
chown opendkim:selfpost /run/opendkim
chown panel:selfpost /run/selfpost chown panel:selfpost /run/selfpost
chmod 2750 /run/opendkim /run/selfpost chmod 2750 /run/selfpost
# A container that was killed leaves its sockets behind, and a file nobody # A container that was killed leaves its sockets behind, and a file nobody
# listens on would satisfy postfix-wrapper.sh's wait: Postfix would start # listens on would satisfy postfix-wrapper.sh's wait: Postfix would start
# before the sender check does and defer mail until it is there. The check's # before the sender check does and defer mail until it is there. The check's
@@ -169,7 +219,41 @@ chmod 2750 /run/opendkim /run/selfpost
# downtime (guide § Health) - a mark left from before would count the whole # downtime (guide § Health) - a mark left from before would count the whole
# outage when supervisord cannot say since when Postfix runs. # outage when supervisord cannot say since when Postfix runs.
# (test(1), -S: true for any socket file, whether or not anything listens.) # (test(1), -S: true for any socket file, whether or not anything listens.)
rm -f /run/opendkim/*.sock /run/selfpost/*.sock /run/selfpost/*.alive rm -f /run/selfpost/*.sock /run/selfpost/*.alive
# The queue tree belongs to Postfix's users by name. Their numbers are what
# the package's install hands out and depend on what else the image installs:
# without opendkim (2.8.0) postfix went from uid 101 to 100 and postdrop from
# gid 103 to 102, and a queue of an earlier image kept the old numbers. So an
# existing tree is handed over before postfix-config.sh runs "postfix check",
# whose postsuper drops to the mail owner before it reads the queue and
# otherwise stops the start ("scan_dir_push: open directory defer: Permission
# denied", the test server, 2026-10-16; Postfix 3.10.13,
# src/postsuper/postsuper.c:1442, set_ugid(var_owner_uid, var_owner_gid);
# conf/postfix-script:342). After the check it is done again for a tree the
# check has just built.
own_queue() {
[ -d /data/postfix/queue/active ] || return 0
chown -R postfix:postfix /data/postfix/queue
# The recursive chown would leave public/ and maildrop/ in group postfix.
# Postfix keeps those two in its setgid group: postqueue and postdrop are
# setgid postdrop and reach the showq socket and the maildrop directory
# through that group alone. With the wrong group `postqueue -p` run by the
# panel answers "Connect to the Postfix showq service: Permission denied"
# and the panel cannot read the queue (the Queue card and the Outbound
# queue page).
chgrp postdrop /data/postfix/queue/public /data/postfix/queue/maildrop
# It would also leave the queue directory itself and pid/ owned by
# postfix. Postfix keeps those two owned by root (master, which runs as
# root, is the only writer) and logs "warning: not owned by root" for each
# at every start. (Postfix 3.10.13, conf/postfix-files:51-68:
# $queue_directory root 755, maildrop $setgid_group 730, public
# $setgid_group 710, pid root 755, the other queue directories
# $mail_owner 700; conf/postfix-script:354-356, "check-warn": "not owned
# by root".)
chown root /data/postfix/queue /data/postfix/queue/pid
}
own_queue
# Generate the outbound-relay Postfix configuration from the environment (spec # Generate the outbound-relay Postfix configuration from the environment (spec
# 5). Kept out of the image build so cert paths, rate limits, hostname and the # 5). Kept out of the image build so cert paths, rate limits, hostname and the
@@ -177,28 +261,13 @@ rm -f /run/opendkim/*.sock /run/selfpost/*.sock /run/selfpost/*.alive
# start the same way the /data normalisation above is. # start the same way the /data normalisation above is.
/usr/local/bin/postfix-config.sh /usr/local/bin/postfix-config.sh
# The queue tree, on a first start or after a restore, is already built here: # The queue tree, on a first start or after a restore, is built here:
# postfix-config.sh ends with "postfix check", whose check-fatal runs # postfix-config.sh ends with "postfix check", whose check-fatal runs
# "post-install create-missing" (Postfix 3.7.11, conf/postfix-script; # "post-install create-missing" (Postfix 3.10.13, conf/postfix-script:314;
# conf/postfix-files, "$queue_directory/active:d:$mail_owner:-:700:ucr"). # conf/postfix-files:55, "$queue_directory/active:d:$mail_owner:-:700:ucr").
# A "postfix set-permissions" for a missing active/ stood here and never ran # A "postfix set-permissions" for a missing active/ stood here and never ran
# (assumptions C-P34). # (assumptions C-P34).
chown -R postfix:postfix /data/postfix/queue own_queue
# The recursive chown above would leave public/ and maildrop/ in group postfix.
# Postfix keeps those two in its setgid group: postqueue and postdrop are setgid
# postdrop and reach the showq socket and the maildrop directory through that
# group alone. With the wrong group `postqueue -p` run by the panel answers
# "Connect to the Postfix showq service: Permission denied" and the panel cannot
# read the queue (the Queue card and the Outbound queue page).
chgrp postdrop /data/postfix/queue/public /data/postfix/queue/maildrop
# It would also leave the queue directory itself and pid/ owned by postfix.
# Postfix keeps those two owned by root (master, which runs as root, is the only
# writer) and logs "warning: not owned by root" for each at every start.
# (Postfix 3.7.11, conf/postfix-files: $queue_directory root 755, maildrop
# $setgid_group 730, public $setgid_group 710, pid root 755, the other queue
# directories $mail_owner 700; conf/postfix-script, "check-warn": "not owned by
# root".)
chown root /data/postfix/queue /data/postfix/queue/pid
# [program:postfix]'s startsecs (build/supervisord.conf): longer than the # [program:postfix]'s startsecs (build/supervisord.conf): longer than the
# wrapper's wait for the milter sockets, so its exit at the end of the wait is # wrapper's wait for the milter sockets, so its exit at the end of the wait is
@@ -211,6 +280,8 @@ case "$wait_timeout" in
exit 1 exit 1
;; ;;
esac esac
export POSTFIX_STARTSECS=$((wait_timeout + 10)) # expr, not $((...)): dash's arithmetic reads "010" as octal and refuses
# "08"; expr takes decimal (review of stage 2c, F11).
export POSTFIX_STARTSECS=$(expr "$wait_timeout" + 10)
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
+44 -27
View File
@@ -7,35 +7,49 @@
# before the reload is lost. `create` (rather than `nocreate`) matters here # before the reload is lost. `create` (rather than `nocreate`) matters here
# beyond timing: a postlogd-triggered recreate lands the file at 0600 owned by # beyond timing: a postlogd-triggered recreate lands the file at 0600 owned by
# postfix, which the unprivileged panel process cannot read — confirmed on a # postfix, which the unprivileged panel process cannot read — confirmed on a
# live container (date not recorded), and so in the source: Postfix 3.7.11, # live container (date not recorded, Postfix 3.7.11), and so in the source:
# src/util/logwriter.c, logwriter_open_or_die(), creates it 0644 under master's # Postfix 3.10.13, src/util/logwriter.c:99, logwriter_open_or_die(), creates it
# umask(077), src/master/master.c — so logrotate must be the one to create it. # with logwriter_perms, 0600 (:86) unless maillog_file_permissions says
# The reload: Postfix 3.7.11, conf/postfix-script, "logrotate" (mv, then kill # otherwise (Postfix 3.9 and later). build/postfix-config.sh now sets that to
# -HUP master); master_conf.c, master_config() -> master_restart_service() -> # 0640, so a file postlogd makes is readable too; logrotate still creates it,
# master_status_cleanup(), on which postlogd exits (src/master/dgram_server.c, # so that the new log is there, with its mode and owner, at once.
# dgram_server_abort() on MASTER_STATUS_FD), and the next postlogd opens the # The reload: Postfix 3.10.13, conf/postfix-script:535-566, "logrotate" (mv,
# path anew (src/postlogd/postlogd.c, logwriter_open_or_die()). # then kill -HUP master); master_conf.c:92, master_config() -> :147,
# master_restart_service() -> master_status_cleanup(), on which postlogd exits
# (src/master/dgram_server.c:241, dgram_server_abort(), :640 on
# MASTER_STATUS_FD), and the next postlogd opens the path anew
# (src/postlogd/postlogd.c:236, logwriter_open_or_die()).
# #
# logrotate itself only rotates once the configured "daily" period has elapsed # logrotate itself only rotates once the configured "daily" period has elapsed
# (tracked in /var/lib/logrotate/status), so it is safe to invoke this more # (tracked in its state file), so it is safe to invoke this more often than
# often than daily — polling merely bounds how late a legitimate rotation runs. # daily — polling merely bounds how late a legitimate rotation runs. The state
# (logrotate 3.21.0-1: debian/rules, --with-state-file-path=/var/lib/logrotate/ # file is beside the log, under /data, not at the default
# status. That file is in the container's own filesystem: a recreated container # /var/lib/logrotate/status (logrotate 3.22.0-1: debian/rules:11,
# starts without it, and logrotate.c, newState(), records a log it has no state # --with-state-file-path) in the container's own filesystem: a recreated
# for as rotated today, so the first rotation after a recreate comes a day # container started without it, logrotate.c:456, newState(), records a log it
# later.) # has no state for as rotated today, and so a host that recreates the container
# daily - an .env change, an image bump - never rotated mail.log at all (audit
# 2026-10-12, M19). (logrotate 3.22.0, logrotate.8.in:74: "-s, --state
# statefile".) The directory is postfix:selfpost 2750 and logrotate runs as
# root.
set -eu set -eu
CONFIG=/etc/logrotate.d/mail CONFIG=/etc/logrotate.d/mail
STATE=/data/log/.logrotate.status
INTERVAL="${LOGROTATE_INTERVAL_SECONDS:-21600}" INTERVAL="${LOGROTATE_INTERVAL_SECONDS:-21600}"
# logrotate refuses configs writable by group or others and exits 0 while # logrotate refuses configs writable by group or others and goes on without
# ignoring them — fail here so supervisord reports the fault. (logrotate # them — fail here so supervisord reports the fault. (logrotate 3.22.0,
# 3.21.0, config.c, readConfigFile(): as root, mode & 0022 gives "Ignoring ... # config.c:1099, readConfigFile(): as root, mode & 0022 gives (:1160-1165)
# because it is writable by group or others." and returns 0, not an error; a # "Ignoring ... because it is writable by group or others." and returns 1; a
# config not owned by root is ignored the same way. "Potentially dangerous # config not owned by root is ignored the same way (:1168-1173). Since 3.22.0
# mode" is only a warning there, for any mode & 07533 other than 0400 - this # that makes logrotate's exit status 1 (logrotate.c:3278-3279 and :3301;
# loop chooses to treat it as fatal too.) # ChangeLog.md, 3.22.0: "in case a configuration file was skipped due to
# unsafe permissions the exit status after rotattion will be `1` (#508)");
# 3.21.0 returned 0 there and exited 0. So the output is looked at whatever
# the status. "Potentially dangerous mode" is only a warning there
# (:1154-1157), for any mode & 07533 other than 0400 - this loop chooses to
# treat it as fatal too.)
logrotate_config_ok() { logrotate_config_ok() {
mode=$(stat -c '%a' "$CONFIG") mode=$(stat -c '%a' "$CONFIG")
mode=${mode#0} mode=${mode#0}
@@ -56,16 +70,19 @@ if ! logrotate_config_ok; then
fi fi
run_logrotate() { run_logrotate() {
out=$(logrotate "$CONFIG" 2>&1) || { status=0
echo "$out" >&2 out=$(logrotate -s "$STATE" "$CONFIG" 2>&1) || status=$?
return 1
}
case "$out" in case "$out" in
*Ignoring*|*Potentially\ dangerous\ mode*) *Ignoring*|*Potentially\ dangerous\ mode*)
echo "$out" >&2 echo "$out" >&2
logrotate_config_fatal echo "logrotate-loop: refusing to run: logrotate did not take $CONFIG as it is (above)" >&2
exit 1
;; ;;
esac esac
if [ "$status" -ne 0 ]; then
echo "$out" >&2
return 1
fi
return 0 return 0
} }
+9 -1
View File
@@ -4,7 +4,7 @@
# build/postfix-config.sh (maillog_file) and build/entrypoint.sh: postlogd # build/postfix-config.sh (maillog_file) and build/entrypoint.sh: postlogd
# writes it as user postfix, the unprivileged panel reads it through the shared # writes it as user postfix, the unprivileged panel reads it through the shared
# selfpost group, hence create 0640 postfix selfpost rather than 0644 root root. # selfpost group, hence create 0640 postfix selfpost rather than 0644 root root.
# (logrotate 3.21.0, logrotate.8: "create mode owner group" makes the new log # (logrotate 3.22.0, logrotate.8:402: "create mode owner group" makes the new log
# right after the rotation; "delaycompress" leaves the previous log, .1, # right after the rotation; "delaycompress" leaves the previous log, .1,
# uncompressed until the next cycle.) # uncompressed until the next cycle.)
/data/log/mail.log { /data/log/mail.log {
@@ -15,7 +15,15 @@
compress compress
delaycompress delaycompress
create 0640 postfix selfpost create 0640 postfix selfpost
# A rotation due when the container starts runs before Postfix does
# (logrotate-loop.sh runs at once, the wrapper waits for the milter
# sockets): no postlogd holds the old file then, the one Postfix starts
# opens the new, and a reload would only fail - "fatal: the Postfix mail
# system is not running", seen at the test server's rollout of
# 2026-10-16. "postfix status" exits 1 while no master runs (Postfix
# 3.10.13, conf/postfix-script:297-307), reload then nothing.
postrotate postrotate
/usr/sbin/postfix status >/dev/null 2>&1 || exit 0
/usr/sbin/postfix reload /usr/sbin/postfix reload
endscript endscript
} }
-45
View File
@@ -1,45 +0,0 @@
# OpenDKIM — per-domain signing.
#
# Signing is strictly per-domain: each sending domain has its own key pair and
# selector. The panel generates keys under /data/opendkim/keys/<domain>/ and
# keeps KeyTable/SigningTable in sync as domains are added or removed, then asks
# supervisord to send this process SIGUSR1 to reload the tables. (OpenDKIM
# 2.11.0~beta2, opendkim/opendkim.8.in: on SIGUSR1 the configuration file is
# re-read; opendkim/opendkim.c, dkimf_config_reload() -> dkimf_config_load(),
# opens SigningTable and KeyTable again with dkimf_db_open().)
#
# Both table files are created (empty) by entrypoint.sh before OpenDKIM starts,
# so the daemon comes up cleanly on a fresh /data with no domains yet and simply
# signs nothing until the first domain is added. (opendkim/opendkim-db.c,
# dkimf_db_open(): a file or refile table is read line by line; an empty file
# is an empty table, not an error.)
Syslog no
UMask 007
Mode s
# SigningTable uses refile: so the "*@example.com" left-hand patterns match any
# local-part for a domain; KeyTable maps each domain to its key and selector.
# (opendkim/opendkim.conf.5.in, SigningTable: with "refile" the keys are
# wildcard patterns matched against the From: address, in file order.)
KeyTable /data/opendkim/KeyTable
SigningTable refile:/data/opendkim/SigningTable
# The private keys are owned by `panel` and read by `opendkim` through the shared
# `selfpost` group, so they are deliberately group-readable (mode 0640). That is
# safe on this single-tenant, private bind mount, but it trips OpenDKIM's default
# key-safety check, so the check is disabled here by design. (opendkim/
# opendkim.c, dkimf_checkfsnode(): a key file must be owned by root or by the
# executing uid, and a group-readable one must be in a group of no other user -
# `panel` owns these and `selfpost` has three members.)
RequireSafeKeys no
# UMask 007 above leaves the socket group read-write (opendkim.conf.5.in,
# UMask: it applies to the socket); its group is selfpost by the setgid
# /run/opendkim (build/entrypoint.sh). UserID: the process takes the user's
# groups (opendkim.conf.5.in, UserID); with Background no and -f
# (build/supervisord.conf, opendkim.8.in) the supervised pid is OpenDKIM itself.
Socket local:/run/opendkim/opendkim.sock
PidFile /run/opendkim/opendkim.pid
UserID opendkim
Background no
+9 -4
View File
@@ -11,9 +11,9 @@
# copies - not the mounted files, which a renewer may be rewriting - are a # copies - not the mounted files, which a renewer may be rewriting - are a
# certificate and the key that belongs to it, renames both over the targets # certificate and the key that belongs to it, renames both over the targets
# and reloads Postfix. When nothing changed it does nothing. # and reloads Postfix. When nothing changed it does nothing.
# (Postfix 3.7.11: smtpd loads the certificate in pre_jail_init(), once per # (Postfix 3.10.13: smtpd loads the certificate in pre_jail_init(), once per
# process, src/smtpd/smtpd.c; "postfix reload" is SIGHUP to master, # process, src/smtpd/smtpd.c:6442; "postfix reload" is SIGHUP to master,
# conf/postfix-script, after which the old smtpd processes exit on the closed # conf/postfix-script:277, after which the old smtpd processes exit on the closed
# status pipe, src/master/master_conf.c, master_config() -> # status pipe, src/master/master_conf.c, master_config() ->
# master_restart_service(), and new ones load the files again.) # master_restart_service(), and new ones load the files again.)
# #
@@ -41,7 +41,12 @@ reload() {
} }
# same_pair says whether a certificate file and a private key file belong # same_pair says whether a certificate file and a private key file belong
# together. # 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.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() { same_pair() {
cert_pub=$(openssl x509 -noout -pubkey -in "$1" 2>/dev/null) || return 1 cert_pub=$(openssl x509 -noout -pubkey -in "$1" 2>/dev/null) || return 1
key_pub=$(openssl pkey -pubout -in "$2" 2>/dev/null) || return 1 key_pub=$(openssl pkey -pubout -in "$2" 2>/dev/null) || return 1
+345 -125
View File
@@ -24,18 +24,19 @@ set -eu
# passed here is written with commas only; a value with whitespace is a bug in # passed here is written with commas only; a value with whitespace is a bug in
# this script and stops the container. # this script and stops the container.
# #
# Postfix 3.7.11, man/man5/master.5, "-o name=value (short form)", NOTE 2: no # Postfix 3.10.13, man/man5/master.5:215-226, "-o name=value (short form)":
# whitespace in a short-form value (the long form "-o { name = value }" would # "The "short form" does not support whitespace around the "=" or in
# keep it; this script does not write it). src/postconf/postconf_edit.c, # parameter values" (the long form "-o { name = value }" would keep it; this
# script does not write it). src/postconf/postconf_edit.c:415,
# pcf_edit_master(): "-Pe does not accept whitespace in parameter value". # pcf_edit_master(): "-Pe does not accept whitespace in parameter value".
# src/smtpd/smtpd_check.c, smtpd_check_parse(), and src/milter/milter.c, # src/smtpd/smtpd_check.c:664, smtpd_check_parse(), and src/milter/milter.c:646,
# milter_new(), split their lists on CHARS_COMMA_SP: commas or whitespace. # milter_new(), split their lists on CHARS_COMMA_SP: commas or whitespace.
# #
# master.5, SYNTAX: a line that starts with whitespace continues a logical # master.5:41-42, SYNTAX: a line that starts with whitespace continues a
# line, so the "-o" line awk inserts below the header belongs to the service. # logical line, so the "-o" line awk inserts below the header belongs to the
# A later postconf -F/-P re-parses a matched entry with its continuation lines # service. A later postconf -F/-P re-parses a matched entry with its
# (postconf_edit.c, pcf_gobble_cf_line()) and prints it whole again, keeping # continuation lines (postconf_edit.c:134, pcf_gobble_cf_line()) and prints
# these options. # it whole again, keeping these options.
master_cf_set_smtp_inet_o() { master_cf_set_smtp_inet_o() {
param="$1" param="$1"
value="$2" value="$2"
@@ -75,7 +76,7 @@ master_cf_set_smtp_inet_o() {
# sasldb2 accounts are looked up under. The panel creates accounts under realm # sasldb2 accounts are looked up under. The panel creates accounts under realm
# $SELFPOST_HOSTNAME (SASL_REALM), so myhostname MUST match or authentication # $SELFPOST_HOSTNAME (SASL_REALM), so myhostname MUST match or authentication
# fails. Fall back to the container hostname only outside a real deployment. # fails. Fall back to the container hostname only outside a real deployment.
# Postfix 3.7.11, src/xsasl/xsasl_cyrus_server.c, xsasl_cyrus_server_create(): # Postfix 3.10.13, src/xsasl/xsasl_cyrus_server.c:339, xsasl_cyrus_server_create():
# sasl_server_new() gets var_myhostname as serverFQDN; Cyrus SASL 2.1.28, # sasl_server_new() gets var_myhostname as serverFQDN; Cyrus SASL 2.1.28,
# plugins/sasldb.c, sasldb_auxprop_lookup(): with no user realm the account is # plugins/sasldb.c, sasldb_auxprop_lookup(): with no user realm the account is
# looked up under serverFQDN. # looked up under serverFQDN.
@@ -83,16 +84,19 @@ HOSTNAME_VALUE="${SELFPOST_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}"
# TLS material supplied by the reverse-proxy through a read-only bind mount. The # TLS material supplied by the reverse-proxy through a read-only bind mount. The
# relay requires TLS on 465; if these files are absent the master still starts # relay requires TLS on 465; if these files are absent the master still starts
# but TLS handshakes on 465 fail until they appear. Postfix 3.7.11, # but TLS handshakes on 465 fail until they appear. Postfix 3.10.13,
# src/smtpd/smtpd.c: pre_jail_init() only warns when the TLS context cannot be # src/smtpd/smtpd.c: pre_jail_init() only warns when the TLS context cannot be
# made, and smtpd_proto() then drops each wrapper-mode connection ("Wrapper-mode # made, and smtpd_proto() then drops each wrapper-mode connection (:5846,
# request dropped ... TLS context initialization failed"); every new smtpd # "Wrapper-mode request dropped ... TLS context initialization failed"); every
# process loads the files again in pre_jail_init(). # new smtpd process loads the files again in pre_jail_init().
# #
# Postfix insists the private key is root-owned and mode 0600. The bind mount # Postfix's TLS_README advises a private key owned by root and readable by no
# is often :ro and owned by the host user (e2e TempDir / CI runner UID), so # other user; Postfix does not enforce it (smtpd loads the key as root in
# copy into a writable internal dir and normalise ownership before postconf # pre_jail_init(), Postfix 3.10.13 src/smtpd/smtpd.c), but the mount cannot
# and `postfix check`. # give that mode - it is :ro and owned by the host's user, often readable by
# 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}" TLS_CERT_SRC="${TLS_CERT_FILE:-/etc/postfix/tls/fullchain.pem}"
TLS_KEY_SRC="${TLS_KEY_FILE:-/etc/postfix/tls/privkey.pem}" TLS_KEY_SRC="${TLS_KEY_FILE:-/etc/postfix/tls/privkey.pem}"
TLS_INTERNAL_DIR=/etc/postfix/tls-internal TLS_INTERNAL_DIR=/etc/postfix/tls-internal
@@ -114,17 +118,81 @@ fi
RATE_MSGS="${RATE_LIMIT_MESSAGES_PER_IP:-100}" RATE_MSGS="${RATE_LIMIT_MESSAGES_PER_IP:-100}"
RATE_WINDOW="${RATE_LIMIT_WINDOW_SECONDS:-3600}" RATE_WINDOW="${RATE_LIMIT_WINDOW_SECONDS:-3600}"
# Milter sockets: OpenDKIM (signing, strict), the panel's sender check # The settings below reach main.cf and master.cf as numbers or time values.
# (strict) and its journal-milter (monitoring, fail-open). Fixed container # A value that is neither, or one under the parameter's minimum, passes
# paths, matched by postfix-wrapper.sh. # `postfix check` and kills the daemon that reads it - every smtpd at its
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}" # first connection, or anvil and with it the rate limit (audit 2026-10-12,
# L31; review of stage 5) - so the start stops here, saying which and why.
# Counts and sizes are whole numbers from min up to 2147483647, an int's top
# (Postfix 3.10.13 reads the counts with get_mail_conf_int*, src/global/
# mail_conf_int.c, check_mail_conf_int(): an int, held to the parameter's own minimum;
# message_size_limit is a long, src/global/mail_params.h:2014, and is held to
# the int's top here all the same). A time value
# is a whole number from 1 with an optional unit, s m h d or w, at most
# 2147483647 seconds
# (proto/postconf.proto:266-268: "Specify a non-zero time value (an integral value
# plus an optional one-letter suffix ...). Time units: s (seconds), m
# (minutes), h (hours), d (days), w (weeks)"; one unit - "1h30m" is read as
# 1h, src/global/conv_time.c - so it is refused here).
need_number() {
case "$2" in
'' | *[!0-9]*)
echo "postfix-config: $1 must be a whole number, got '$2'" >&2
exit 1
;;
esac
if [ "${#2}" -gt 10 ] || [ "$2" -lt "$3" ] || [ "$2" -gt 2147483647 ]; then
echo "postfix-config: $1 must be from $3 to 2147483647, got '$2'" >&2
exit 1
fi
}
need_time() {
if ! printf '%s' "$2" | grep -Eq '^[1-9][0-9]{0,8}[smhdw]?$'; then
echo "postfix-config: $1 must be a time value - a whole number from 1 with an optional s, m, h, d or w - got '$2'" >&2
exit 1
fi
# conv_time multiplies by the unit and refuses what passes an int.
n=${2%[smhdw]}
case "$2" in
*w) n=$((n * 604800)) ;;
*d) n=$((n * 86400)) ;;
*h) n=$((n * 3600)) ;;
*m) n=$((n * 60)) ;;
esac
if [ "$n" -gt 2147483647 ]; then
echo "postfix-config: $1 is more than 2147483647 seconds, got '$2'" >&2
exit 1
fi
}
# A rate of 0 is no limit, a size of 0 none (proto/postconf.proto,
# smtpd_client_message_rate_limit, message_size_limit); the window is
# anvil_rate_time_unit, at least 1 second (src/anvil/anvil.c, main(): its
# time table's minimum 1).
need_number RATE_LIMIT_MESSAGES_PER_IP "$RATE_MSGS" 0
need_number RATE_LIMIT_WINDOW_SECONDS "$RATE_WINDOW" 1
need_number INBOUND_RATE_LIMIT_MESSAGES_PER_IP "${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}" 0
need_number INBOUND_MESSAGE_SIZE_LIMIT "${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}" 0
need_number DMARC_RATE_LIMIT_MESSAGES_PER_IP "${DMARC_RATE_LIMIT_MESSAGES_PER_IP:-20}" 0
need_number DMARC_MESSAGE_SIZE_LIMIT "${DMARC_MESSAGE_SIZE_LIMIT:-5242880}" 0
need_time MILTER_CONNECT_TIMEOUT "${MILTER_CONNECT_TIMEOUT:-15s}"
need_time MILTER_COMMAND_TIMEOUT "${MILTER_COMMAND_TIMEOUT:-15s}"
need_time MILTER_CONTENT_TIMEOUT "${MILTER_CONTENT_TIMEOUT:-30s}"
# Milter sockets, all the panel's: its journal-milter (monitoring,
# fail-open), the sender check and the DKIM signer (both strict). Fixed
# container paths, matched by postfix-wrapper.sh.
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}" JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
# The panel's sender check (the From: line against the login, strict). # The panel's sender check (the From: line against the login, strict).
SENDER_SOCK="${SENDER_CHECK_SOCKET:-/run/selfpost/sender.sock}" SENDER_SOCK="${SENDER_CHECK_SOCKET:-/run/selfpost/sender.sock}"
# The panel's DKIM signer (strict): applications' mail, signed with the key
# of the login's domain, and the panel's own, with the server's name's
# (cmd/panel/signer.go). Matched by postfix-wrapper.sh.
SIGN_SOCK="${DKIM_SIGN_SOCKET:-/run/selfpost/sign.sock}"
SIGN_LOCAL_SOCK="${DKIM_SIGN_LOCAL_SOCKET:-/run/selfpost/sign-local.sock}"
# Persistent panel-managed sender map; texthash needs no postmap, so the # Persistent panel-managed sender map; texthash needs no postmap, so the
# unprivileged panel can rewrite it and just ask for a reload. Postfix 3.7.11, # unprivileged panel can rewrite it and just ask for a reload. Postfix 3.10.13,
# html/DATABASE_README.html, "texthash": no postmap(1), and the file is read # html/DATABASE_README.html:458-464, "texthash": no postmap(1), and the file is read
# once by each process - a reload makes the new processes read it again. # once by each process - a reload makes the new processes read it again.
SENDER_LOGIN_MAPS="${POSTFIX_SENDER_LOGIN_MAPS:-/data/postfix/sender_login_maps}" SENDER_LOGIN_MAPS="${POSTFIX_SENDER_LOGIN_MAPS:-/data/postfix/sender_login_maps}"
# Transit mail queue under /data so deferred/active messages survive container # Transit mail queue under /data so deferred/active messages survive container
@@ -178,25 +246,37 @@ postconf -PX "cleanup/unix/message_size_limit" 2>/dev/null || true
# maillog_file lives under the persistent /data bind mount (architecture.md § # maillog_file lives under the persistent /data bind mount (architecture.md §
# Log tailer). Postfix's default maillog_file_prefixes are only /var and # Log tailer). Postfix's default maillog_file_prefixes are only /var and
# /dev/stdout — without /data, `postfix check` rejects the path. # /dev/stdout — without /data, `postfix check` rejects the path.
# inet_protocols=all in a bridge network without IPv6: Postfix 3.7.11, # inet_protocols=all in a bridge network without IPv6: Postfix 3.10.13,
# src/util/inet_proto.c, inet_proto_init(), keeps IPv6 when socket(PF_INET6) # src/util/inet_proto.c:200, inet_proto_init(), keeps IPv6 when socket(PF_INET6)
# succeeds, and src/smtp/smtp_connect.c, smtp_connect_inet(), goes on to # succeeds, and src/smtp/smtp_connect.c:1102-1142, smtp_connect_inet(), goes on
# the next address when a connection fails - an AAAA that cannot be reached # to the next address when a connection fails - an AAAA that cannot be reached
# costs one failed attempt, then IPv4. Postfix # costs one failed attempt, then IPv4. Postfix
# 3.7.11, src/global/mail_params.h, DEF_MAILLOG_FILE_PFXS "/var, /dev/stdout"; # 3.10.13, src/global/mail_params.h:4406, DEF_MAILLOG_FILE_PFXS "/var, /dev/stdout";
# src/global/maillog_client.c, maillog_client_init(): fatal "does not match any # src/global/maillog_client.c:232, maillog_client_init(): fatal "does not match any
# prefix", in every Postfix program that logs, postfix(1) itself included. # prefix", in every Postfix program that logs, postfix(1) itself included.
#
# maillog_file_permissions=0640: the log is the panel's to read, through
# group selfpost (the setgid /data/log, build/entrypoint.sh). entrypoint.sh
# and logrotate's "create" make it 0640 themselves; this is for a file that
# Postfix makes - one removed while the container runs - which would
# otherwise be 0600 and unreadable to the panel. (Postfix 3.10.13,
# src/global/mail_params.h:4418, DEF_MAILLOG_FILE_PERMS "0600";
# src/global/mail_params.c:997, set_logwriter_create_perms(); src/util/
# logwriter.c:143-148: "644", "640" or "600", leading zeros ignored, anything
# else a warning and 0600; :99, logwriter_open_or_die() creates with it;
# Postfix 3.9 and later, proto/postconf.proto:18500-18509.)
postconf -e \ postconf -e \
"myhostname=${HOSTNAME_VALUE}" \ "myhostname=${HOSTNAME_VALUE}" \
"maillog_file=${MAIL_LOG_PATH}" \ "maillog_file=${MAIL_LOG_PATH}" \
"maillog_file_prefixes=/var,/dev/stdout,/data" \ "maillog_file_prefixes=/var,/dev/stdout,/data" \
"maillog_file_permissions=0640" \
"mydestination=" \ "mydestination=" \
"relayhost=" \ "relayhost=" \
"inet_interfaces=all" \ "inet_interfaces=all" \
"inet_protocols=all" "inet_protocols=all"
# postlogd is mandatory whenever maillog_file is set (Postfix 3.7.11, # postlogd is mandatory whenever maillog_file is set (Postfix 3.10.13,
# html/MAILLOG_README.html, "Configuring logging to file"; conf/postfix-script, # html/MAILLOG_README.html:42, "Configuring logging to file"; conf/postfix-script:333,
# "check-fatal": "missing 'postlog' service in master.cf"). Debian's # "check-fatal": "missing 'postlog' service in master.cf"). Debian's
# stock master.cf usually has it; pin it explicitly so a stripped/upgraded # stock master.cf usually has it; pin it explicitly so a stripped/upgraded
# image cannot lose the service. # image cannot lose the service.
@@ -205,10 +285,10 @@ postconf -M "postlog/unix-dgram=postlog unix-dgram n - n - 1 postlogd"
# This is an outbound relay: no local delivery, no per-user aliases. Empty # This is an outbound relay: no local delivery, no per-user aliases. Empty
# these so a misfiled recipient never gets delivered locally. An empty # these so a misfiled recipient never gets delivered locally. An empty
# mydestination is not enough: an address literal of an interface the # mydestination is not enough: an address literal of an interface the
# container listens on - x@[127.0.0.1] - is local too (Postfix 3.7.11, # container listens on - x@[127.0.0.1] - is local too (Postfix 3.10.13,
# src/global/resolve_local.c, resolve_local(), own_inet_addr()), so local(8) # src/global/resolve_local.c, resolve_local(), own_inet_addr()), so local(8)
# itself is replaced by a refusal, as Postfix's null-client example does # itself is replaced by a refusal, as Postfix's null-client example does
# (README_FILES/STANDARD_CONFIGURATION_README, "local_transport = error:local # (README_FILES/STANDARD_CONFIGURATION_README:209, "local_transport = error:local
# mail delivery is disabled"; assumptions C-P8). # mail delivery is disabled"; assumptions C-P8).
postconf -e \ postconf -e \
"local_recipient_maps=" \ "local_recipient_maps=" \
@@ -217,14 +297,24 @@ postconf -e \
"local_transport=error:local mail delivery is disabled" "local_transport=error:local mail delivery is disabled"
# Outbound delivery: straight to the recipient MX, opportunistic TLS. # 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 \ postconf -e \
"smtp_tls_security_level=may" \ "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 # TLS server material shared by every inbound service. auth_only guarantees
# credentials are never accepted before TLS is up on any port. Postfix 3.7.11, # credentials are never accepted before TLS is up on any port. Postfix 3.10.13,
# src/smtpd/smtpd_sasl_proto.c, smtpd_sasl_auth_cmd(): "504 5.5.4 Encryption # src/smtpd/smtpd_sasl_proto.c:188, smtpd_sasl_auth_cmd(): "504 5.5.4 Encryption
# required" while auth_only is set and TLS is not up; src/smtpd/smtpd.c, # required" while auth_only is set and TLS is not up; src/smtpd/smtpd.c:6546-6547,
# pre_jail_init(): wrappermode implies enforce, and enforce implies auth_only. # pre_jail_init(): wrappermode implies enforce, and enforce implies auth_only.
postconf -e \ postconf -e \
"smtpd_tls_cert_file=${TLS_CERT}" \ "smtpd_tls_cert_file=${TLS_CERT}" \
@@ -233,12 +323,39 @@ postconf -e \
"smtpd_tls_auth_only=yes" \ "smtpd_tls_auth_only=yes" \
"smtpd_tls_loglevel=1" "smtpd_tls_loglevel=1"
# TLS 1.2 at least, opportunistic or required - and the client's too where an
# inbound domain requires TLS to its upstream (smtp_tls_mandatory_protocols,
# the security review of stage 5) (audit 2026-10-12, L31; Postfix
# 3.10.13, proto/postconf.proto:13240-13250, smtpd_tls_protocols: since 3.6 the
# default is ">=TLSv1", and a lower bound is written ">=version"). A sender that
# offers less on port 25 sends in the clear, as one without TLS does. VRFY
# answers no question about who is accepted on port 25 (relay_recipient_maps),
# and the banner names no distribution (Debian's adds "(Debian)": postfix
# 3.10.13-0+deb13u1, debian/main.cf.in:15 "(@DEB_VENDOR@)", filled in by
# debian/rules:118; the upstream default is "$myhostname ESMTP $mail_name").
# 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 # SASL: Cyrus with the local sasldb2 the panel maintains. The realm is left
# implicit (smtpd_sasl_local_domain empty) so the authenticated name Postfix # implicit (smtpd_sasl_local_domain empty) so the authenticated name Postfix
# uses for sender_login_maps is the BARE login the panel writes into the map; # uses for sender_login_maps is the BARE login the panel writes into the map;
# the sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname as # the sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname as
# the server realm, which equals the realm the accounts were created under. # the server realm, which equals the realm the accounts were created under.
# (Postfix 3.7.11, src/smtpd/smtpd_sasl_glue.c, smtpd_sasl_activate(): an empty # (Postfix 3.10.13, src/smtpd/smtpd_sasl_glue.c:249, smtpd_sasl_activate(): an empty
# smtpd_sasl_local_domain is a NULL user realm; see HOSTNAME_VALUE above.) # smtpd_sasl_local_domain is a NULL user realm; see HOSTNAME_VALUE above.)
postconf -e \ postconf -e \
"smtpd_sasl_auth_enable=yes" \ "smtpd_sasl_auth_enable=yes" \
@@ -253,7 +370,7 @@ postconf -e \
# up in this map with mail_addr_find(), the order being smtpd's and not # up in this map with mail_addr_find(), the order being smtpd's and not
# texthash's: the full address, the address without its extension, then the # texthash's: the full address, the address without its extension, then the
# "@domain" wildcard - so both address modes work from the same map (Postfix # "@domain" wildcard - so both address modes work from the same map (Postfix
# 3.7.11, src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch(); # 3.10.13, src/smtpd/smtpd_check.c:3933, reject_auth_sender_login_mismatch();
# src/global/mail_addr_find.c, mail_addr_find_opt() with MA_FIND_DEFAULT). # src/global/mail_addr_find.c, mail_addr_find_opt() with MA_FIND_DEFAULT).
# #
# recipient_delimiter is said here and not left to the distribution's main.cf: # recipient_delimiter is said here and not left to the distribution's main.cf:
@@ -270,9 +387,9 @@ postconf -e \
# Restrictions: authenticated clients only, no relay to foreign destinations, # Restrictions: authenticated clients only, no relay to foreign destinations,
# and every authenticated sender address must be owned by its login. NO # and every authenticated sender address must be owned by its login. NO
# permit_mynetworks anywhere — authorisation is by credentials, never by # permit_mynetworks anywhere — authorisation is by credentials, never by
# network. This is what makes an open relay impossible. (Postfix 3.7.11, # network. This is what makes an open relay impossible. (Postfix 3.10.13,
# src/smtpd/smtpd_check.c, smtpd_check_rcpt(): the relay and recipient lists # src/smtpd/smtpd_check.c:5076, smtpd_check_rcpt(), :5160-5173: the relay and
# are evaluated in turn and the first REJECT ends them.) # recipient lists are evaluated in turn and the first REJECT ends them.)
postconf -e \ postconf -e \
"smtpd_helo_required=yes" \ "smtpd_helo_required=yes" \
"smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination" \ "smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
@@ -280,59 +397,98 @@ postconf -e \
"smtpd_sender_restrictions=reject_sender_login_mismatch, permit" "smtpd_sender_restrictions=reject_sender_login_mismatch, permit"
# Level-1 rate limit by client IP. Backstop that keeps working even if the # Level-1 rate limit by client IP. Backstop that keeps working even if the
# journal-milter (level 2) is down. Postfix 3.7.11, src/smtpd/smtpd.c, # journal-milter (level 2) is down. Postfix 3.10.13, src/smtpd/smtpd.c:2566,
# mail_cmd(): "450 4.7.1 Error: too much mail from", except for clients in # mail_cmd(), :2624: "450 4.7.1 Error: too much mail from", except for clients
# smtpd_client_event_limit_exceptions (default $mynetworks; Debian's debconf # in smtpd_client_event_limit_exceptions (:2619, hogger_list; default
# default, debian/templates "postfix/mynetworks", is loopback only). # $mynetworks, src/smtpd/smtpd.c:779).
# anvil_rate_time_unit is one global window for every anvil limit (postconf(5)). # anvil_rate_time_unit is one global window for every anvil limit (postconf(5)).
# "Client IP" is the address the container sees: an IPv4 client's own, but
# for a client over IPv6 the compose network's gateway, through Docker's
# proxy - measured on a live server, 2026-10-15, Docker 29.6.1 (assumptions
# C-K5; guide, "Rate limiting - level 1").
#
# mynetworks is said here, loopback only, and not left to the package: it is
# what the exceptions above and smtpd_forbid_bare_newline_exclusions (below)
# spare. The value is the one Debian's debconf default gives (postfix
# 3.10.13-0+deb13u1, debian/templates:124-126, "postfix/mynetworks", written
# by debian/postfix.postinst:249-262 on a new main.cf, where :180,
# fset_all_changed, marks every debconf value to be written; main.cf.in:28 has 127.0.0.0/8
# alone), so on this image it changes nothing.
postconf -e \ postconf -e \
"mynetworks=127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128" \
"smtpd_client_message_rate_limit=${RATE_MSGS}" \ "smtpd_client_message_rate_limit=${RATE_MSGS}" \
"anvil_rate_time_unit=${RATE_WINDOW}s" "anvil_rate_time_unit=${RATE_WINDOW}s"
# Milter chain. OpenDKIM signs and is treated strictly (default_action=tempfail: # Milter chain. The sender check comes first and is strict
# if it is unreachable, defer rather than send unsigned). The journal-milter is # (default_action=tempfail): it holds the From: line against the login, the
# monitoring only and is fail-open (default_action=accept): its failure must # address a receiver holds the signature to, so a message it could not judge
# never block the relay. Per-milter settings use Postfix 3.0+ brace syntax. # must not be signed. While the panel does not answer on that socket, mail is
# deferred (docs/security.md).
# #
# The sender check comes first and is strict as well: it holds the From: line # The panel's DKIM signer follows, strict too: unreachable, it defers rather
# against the login, which is what OpenDKIM then chooses the key by, so a # than send unsigned (docs/architecture.md § DKIM signing). It signs with the
# message it could not judge must not reach the signer. While the panel does # key of the login's domain. The journal-milter is monitoring only and is
# not answer on that socket, mail is deferred (docs/security.md). # fail-open (default_action=accept): its failure must never block the relay.
# Per-milter settings use Postfix 3.0+ brace syntax.
# #
# non_smtpd_milters is the chain for mail submitted locally with sendmail(1) - # non_smtpd_milters is the chain for mail submitted locally with sendmail(1) -
# the panel's own notifications, sent as postmaster@ the hostname. OpenDKIM # the panel's own notifications, sent as postmaster@ the hostname. The
# signs them with the key of the server's own name; the journal-milter is not # signer's own socket for it signs them with the key of the server's own
# on this chain, since they are not an application's mail. Bounces Postfix # name; the journal-milter is not on this chain, since they are not an
# application's mail. Bounces Postfix
# generates itself do not pass it (internal_mail_filter_classes is empty). # generates itself do not pass it (internal_mail_filter_classes is empty).
# #
# Postfix 3.7.11: html/MILTER_README.html, "Different settings for different # Postfix 3.10.13: html/MILTER_README.html:584, "Different settings for different
# Milter applications" (the braces, Postfix 3.0 and later) and "Signing # Milter applications" (the braces, Postfix 3.0 and later) and :449, "Signing
# internally-generated bounce messages"; src/milter/milter.c, milter_*_event() # internally-generated bounce messages"; src/milter/milter.c:399-564,
# and milter_message(): milters are asked in list order and the first non-empty # milter_*_event() and milter_message(): milters are asked in list order and
# reply ends the round; src/global/mail_params.h, DEF_INT_FILT_CLASSES # the first non-empty reply ends the round; src/global/mail_params.h:3599,
# INT_FILT_CLASS_NONE. # DEF_INT_FILT_CLASSES INT_FILT_CLASS_NONE.
postconf -e \ postconf -e \
"milter_protocol=6" \ "milter_protocol=6" \
"milter_default_action=tempfail" \ "milter_default_action=tempfail" \
"smtpd_milters={ unix:${SENDER_SOCK}, default_action=tempfail }, { unix:${OPENDKIM_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \ "smtpd_milters={ unix:${SENDER_SOCK}, default_action=tempfail }, { unix:${SIGN_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \
"non_smtpd_milters={ unix:${OPENDKIM_SOCK}, default_action=tempfail }" "non_smtpd_milters={ unix:${SIGN_LOCAL_SOCK}, default_action=tempfail }"
# A signed message is delivered as it was signed: Postfix's SMTP client would
# otherwise rewrite an 8-bit message as quoted-printable for a server that
# does not announce 8BITMIME, and every body hash over it would fail.
# Postfix 3.10.13, src/global/mail_params.h:3088, DEF_DISABLE_MIME_OCONV 0
# (the conversion is on by default); proto/postconf.proto:7992,
# disable_mime_output_conversion: "Disable the conversion of 8BITMIME format
# to 7BIT format". Not set until 2.8.0 - OpenDKIM's signatures, before the
# panel's, were exposed to it as well.
postconf -e "disable_mime_output_conversion=yes"
# Long, non-repeating queue ids (owner, 2026-10-16). The panel finds a
# message's rows, its stored lines and the inbound journal's upstream result
# by the queue id of mail.log; a short id is the microsecond of arrival and
# the queue file's inode in hex and repeats once the inode comes round, so a
# later message could meet an earlier one's rows. A long id adds the time in
# seconds and does not repeat. Postfix 3.10.13, src/global/mail_params.h:1010,
# DEF_LONG_QUEUE_IDS 0; src/global/mail_queue.h, MQID_SH_* and MQID_LG_*;
# proto/postconf.proto, enable_long_queue_ids: "Enable long, non-repeating,
# queue IDs", existing queue files keep their names, postqueue -p gets a
# wider Queue ID column. Every pattern of the panel takes both shapes
# (internal/logtail, internal/postfix/queue.go).
postconf -e "enable_long_queue_ids=yes"
# What Postfix tells a milter with MAIL FROM: its defaults plus the client's # What Postfix tells a milter with MAIL FROM: its defaults plus the client's
# address. The panel's milters judge every message by that address (an # address. The panel's milters judge every message by that address (an
# application's client-address list) and record it (the inbound journal), and # application's client-address list) and record it (the inbound journal), and
# the address given once at connect is not there for a connection's later # the address given once at connect is not there for a connection's later
# messages - see MailFrom in internal/milter/milter.go. The defaults: Postfix # messages - see MailFrom in internal/milter/milter.go. The defaults: Postfix
# 3.7.11, src/global/mail_params.h, DEF_MILT_MAIL_MACROS. # 3.10.13, src/global/mail_params.h:3532-3534, DEF_MILT_MAIL_MACROS.
postconf -e \ postconf -e \
"milter_mail_macros=i {auth_type} {auth_authen} {auth_author} {mail_addr} {mail_host} {mail_mailer} {client_addr}" "milter_mail_macros=i {auth_type} {auth_authen} {auth_author} {mail_addr} {mail_host} {mail_mailer} {client_addr}"
# Bounded milter timeouts: a *hung* milter (socket accepts but never replies) # Bounded milter timeouts: a *hung* milter (socket accepts but never replies)
# must fail open just like a crash, not stall mail acceptance until the Postfix # must fail open just like a crash, not stall mail acceptance until the Postfix
# defaults (300s content) elapse. With default_action per milter, a # defaults (300s content) elapse. With default_action per milter, a
# journal-milter hang then resolves to accept and an OpenDKIM hang to tempfail, # journal-milter hang then resolves to accept and a signer hang to tempfail,
# but within seconds rather than minutes. Values are well above any healthy # but within seconds rather than minutes. Values are well above any healthy
# response time (signing/DB insert are sub-second), so they never fire in normal # response time (signing/DB insert are sub-second), so they never fire in normal
# operation. The defaults: Postfix 3.7.11, src/global/mail_params.h, # operation. The defaults: Postfix 3.10.13, src/global/mail_params.h:3559-3567,
# DEF_MILT_CONN_TIME "30s", DEF_MILT_CMD_TIME "30s", DEF_MILT_MSG_TIME "300s". # DEF_MILT_CONN_TIME "30s", DEF_MILT_CMD_TIME "30s", DEF_MILT_MSG_TIME "300s".
postconf -e \ postconf -e \
"milter_connect_timeout=${MILTER_CONNECT_TIMEOUT:-15s}" \ "milter_connect_timeout=${MILTER_CONNECT_TIMEOUT:-15s}" \
@@ -343,12 +499,26 @@ postconf -e \
# smtps (465, implicit/wrapper TLS) — the primary, always-on submission # smtps (465, implicit/wrapper TLS) — the primary, always-on submission
# service. chroot=n so smtpd can read the sasldb2 and sender map under /data # service. chroot=n so smtpd can read the sasldb2 and sender map under /data
# and the Cyrus config outside any chroot. # and the Cyrus config outside any chroot.
# The names smtps, submission and smtp need no /etc/services: Postfix 3.7.11, # The names smtps, submission and smtp need no /etc/services: Postfix 3.10.13,
# src/global/mail_params.h, DEF_KNOWN_TCP_PORTS "lmtp=24, smtp=25, # src/global/mail_params.h:4450-4451, DEF_KNOWN_TCP_PORTS "lmtp=24, smtp=25,
# smtps=submissions=465, submission=587" (src/util/find_inet.c, # smtps=submissions=465, submission=587" (src/util/find_inet.c:94-99,
# find_inet_port() via filter_known_tcp_port()). # find_inet_port() via filter_known_tcp_port()).
#
# smtpd_hide_client_session=yes on both submission services (owner's
# decision, 2026-10-15): the Received: header Postfix adds names neither the
# application's HELO name, reverse name and address, nor its TLS session,
# SASL login or the protocol - only "Received: by <myhostname> (Postfix) id
# <queue id>", "for <rcpt>" when there is one recipient, and the date
# (Postfix 3.10.13, src/smtpd/smtpd.c:3495, the client part written only
# when the parameter is off; :3627-3645, the "by" line opening the header
# when it is on). proto/postconf.proto:19580-19594: "may be used for the
# port 587 and 465 MUA services"; port 25 must keep the default, RFC 5321's
# trace, and does (smtp/inet below sets nothing). The queue id stays in the
# header, so a complaint about a message still leads to its row in the
# panel's log and to smtpd's "client=" line in mail.log.
postconf -M "smtps/inet=smtps inet n - n - - smtpd" postconf -M "smtps/inet=smtps inet n - n - - smtpd"
postconf -P \ postconf -P \
"smtps/inet/smtpd_hide_client_session=yes" \
"smtps/inet/smtpd_tls_wrappermode=yes" \ "smtps/inet/smtpd_tls_wrappermode=yes" \
"smtps/inet/smtpd_sasl_auth_enable=yes" \ "smtps/inet/smtpd_sasl_auth_enable=yes" \
"smtps/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject" "smtps/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"
@@ -356,13 +526,15 @@ postconf -P \
# submission (587, STARTTLS) — optional. Same SASL/milter/limits; the only # submission (587, STARTTLS) — optional. Same SASL/milter/limits; the only
# difference is TLS is negotiated via STARTTLS, so require encryption before # difference is TLS is negotiated via STARTTLS, so require encryption before
# auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a restart # auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a restart
# after disabling it does not leave the port listening. (Postfix 3.7.11, # after disabling it does not leave the port listening. (Postfix 3.10.13,
# src/smtpd/smtpd.c, pre_jail_init(): smtpd_tls_security_level=encrypt sets # src/smtpd/smtpd.c:6525-6526, pre_jail_init(): smtpd_tls_security_level=encrypt
# enforce_tls, which implies smtpd_tls_auth_only; src/postconf/postconf_edit.c, # sets enforce_tls, which implies smtpd_tls_auth_only (:6547);
# pcf_edit_master(): -MX drops the matched entry, continuation lines included.) # src/postconf/postconf_edit.c:319, pcf_edit_master(): -MX drops the matched
# entry, continuation lines included.)
if [ "${SUBMISSION_ENABLE}" = "true" ]; then if [ "${SUBMISSION_ENABLE}" = "true" ]; then
postconf -M "submission/inet=submission inet n - n - - smtpd" postconf -M "submission/inet=submission inet n - n - - smtpd"
postconf -P \ postconf -P \
"submission/inet/smtpd_hide_client_session=yes" \
"submission/inet/smtpd_tls_security_level=encrypt" \ "submission/inet/smtpd_tls_security_level=encrypt" \
"submission/inet/smtpd_sasl_auth_enable=yes" \ "submission/inet/smtpd_sasl_auth_enable=yes" \
"submission/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject" "submission/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"
@@ -371,19 +543,23 @@ else
fi fi
# --- inbound smtpd on port 25 (optional inbound relay and/or DMARC ingest) ----- # --- inbound smtpd on port 25 (optional inbound relay and/or DMARC ingest) -----
# Debian's stock master.cf enables smtp/inet (postfix 3.7.11-0+deb12u1, # Debian's stock master.cf enables smtp/inet (postfix 3.10.13-0+deb13u1,
# debian/patches/40_chroot_by_default.diff: "smtp inet n - y - - smtpd", # debian/patches/40_chroot_by_default.diff:16: "smtp inet n - y - - smtpd",
# uncommented). When both flags are off, remove # uncommented; conf/master.cf:12). When both flags are off, remove
# that listener so port 25 is not an inbound smtpd (outbound delivery uses # that listener so port 25 is not an inbound smtpd (outbound delivery uses
# smtp/unix). When either is on: no SASL, no OpenDKIM; relay accepts only # smtp/unix). When either is on: no SASL, no signing; relay accepts only
# configured domains/recipients; DMARC accepts only allow-listed report addresses. # configured domains/recipients; DMARC accepts only allow-listed report addresses.
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
RELAY_DOMAINS_SETTING="" RELAY_DOMAINS_SETTING=""
TRANSPORT_SETTING="" TRANSPORT_SETTING=""
RECIPIENT_RESTRICTIONS="reject" RECIPIENT_RESTRICTIONS="reject"
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then 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 for f in "$RELAY_DOMAINS_MAP" "$TRANSPORT_MAP" "$RELAY_RECIPIENTS_MAP" "$TLS_POLICY_MAP"; do
[ -e "$f" ] || : > "$f" [ -e "$f" ] || [ -L "$f" ] || : > "$f"
done done
RELAY_DOMAINS_SETTING="texthash:${RELAY_DOMAINS_MAP}" RELAY_DOMAINS_SETTING="texthash:${RELAY_DOMAINS_MAP}"
TRANSPORT_SETTING="texthash:${TRANSPORT_MAP}" TRANSPORT_SETTING="texthash:${TRANSPORT_MAP}"
@@ -391,22 +567,22 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
fi fi
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
for f in "$DMARC_RECIPIENTS_MAP" "$DMARC_TRANSPORT_MAP" "$DMARC_RELAY_DOMAINS_MAP"; do for f in "$DMARC_RECIPIENTS_MAP" "$DMARC_TRANSPORT_MAP" "$DMARC_RELAY_DOMAINS_MAP"; do
[ -e "$f" ] || : > "$f" [ -e "$f" ] || [ -L "$f" ] || : > "$f"
done done
ln -sf /usr/local/bin/panel /usr/local/bin/dmarc-ingest ln -sf /usr/local/bin/panel /usr/local/bin/dmarc-ingest
# flags=, user= and argv= are arguments of pipe(8), not -o parameters: # flags=, user= and argv= are arguments of pipe(8), not -o parameters:
# postconf -P refuses them ("unmatched service_name/type") and leaves a # postconf -P refuses them ("unmatched service_name/type") and leaves a
# bare "pipe", which dies with "fatal: missing user= command-line # bare "pipe", which dies with "fatal: missing user= command-line
# attribute" on every report. They go into the service line itself. # attribute" on every report. They go into the service line itself.
# (Seen in 1.7.0 through 1.9.5, date not recorded. Postfix 3.7.11: # (Seen in 1.7.0 through 1.9.5, date not recorded, on Postfix 3.7.11.
# src/postconf/postconf_master.c, pcf_edit_master_param(), -P writes # Postfix 3.10.13: src/postconf/postconf_master.c:1029,
# "-o name=value" and nothing else; postconf_edit.c warns "unmatched # pcf_edit_master_param(), -P writes "-o name=value" and nothing else;
# service_name/type" for a service master.cf does not have; # postconf_edit.c:617 warns "unmatched service_name/type" for a service
# src/pipe/pipe.c, get_service_attr(): "missing user= command-line # master.cf does not have; src/pipe/pipe.c:867, get_service_attr(),
# attribute".) # :1018: "missing user= command-line attribute".)
# ${recipient} is expanded by pipe(8), not by the shell: the command # ${recipient} is expanded by pipe(8), not by the shell: the command
# learns which report address the message came to from its argument # learns which report address the message came to from its argument
# (man/man8/pipe.8, ${recipient}: as many arguments as recipients). # (man/man8/pipe.8:289, ${recipient}: as many arguments as recipients).
# One at a time (the 1 before "pipe"): reports are in no hurry, every run # One at a time (the 1 before "pipe"): reports are in no hurry, every run
# writes to the database the panel writes to, and a hundred of them in # writes to the database the panel writes to, and a hundred of them in
# parallel - Postfix's default - is what a flood of reports would start. # parallel - Postfix's default - is what a flood of reports would start.
@@ -417,9 +593,9 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
# command reads from the container's environment is named there. # command reads from the container's environment is named there.
# export_environment is a list split at whitespace in which Postfix # export_environment is a list split at whitespace in which Postfix
# expands "$": a path with either would reach the command as another # expands "$": a path with either would reach the command as another
# path, and it would quietly open a database of its own. (Postfix 3.7.11, # path, and it would quietly open a database of its own. (Postfix 3.10.13,
# man/man8/pipe.8, export_environment; src/global/mail_params.h, # man/man8/pipe.8:417, export_environment; src/global/mail_params.h:2650,
# DEF_EXPORT_ENVIRON "TZ MAIL_CONFIG LANG"; src/global/clean_env.c. The # DEF_EXPORT_ENVIRON "TZ MAIL_CONFIG LANG"; src/util/clean_env.c. The
# parameter is main.cf's, so every Postfix process exports these too.) # parameter is main.cf's, so every Postfix process exports these too.)
case "${SELFPOST_DATA_DIR:-}${SELFPOST_DB_PATH:-}" in case "${SELFPOST_DATA_DIR:-}${SELFPOST_DB_PATH:-}" in
*[[:space:]]* | *'$'*) *[[:space:]]* | *'$'*)
@@ -439,8 +615,8 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
TRANSPORT_SETTING="texthash:${DMARC_TRANSPORT_MAP}" TRANSPORT_SETTING="texthash:${DMARC_TRANSPORT_MAP}"
fi fi
# Comma, not a space, between check_recipient_access and its table: see # Comma, not a space, between check_recipient_access and its table: see
# master_cf_set_smtp_inet_o (master.5, NOTE 2: "or use commas instead of # master_cf_set_smtp_inet_o (master.5, "-o name=value (short form)": "use
# spaces"). # commas instead of spaces if the parameter supports that").
RECIPIENT_RESTRICTIONS="check_recipient_access,texthash:${DMARC_RECIPIENTS_MAP},${RECIPIENT_RESTRICTIONS}" RECIPIENT_RESTRICTIONS="check_recipient_access,texthash:${DMARC_RECIPIENTS_MAP},${RECIPIENT_RESTRICTIONS}"
else else
postconf -MX "dmarc-ingest/unix" 2>/dev/null || true postconf -MX "dmarc-ingest/unix" 2>/dev/null || true
@@ -458,16 +634,17 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
# relay domain too (the server's own name), so their table is a recipient # relay domain too (the server's own name), so their table is a recipient
# table as well; without it they are "User unknown in relay recipient # table as well; without it they are "User unknown in relay recipient
# table" and no report is ever accepted while both features are on. # table" and no report is ever accepted while both features are on.
# (Postfix 3.7.11, src/smtpd/smtpd_check.c, smtpd_check_rcpt(): after # (Postfix 3.10.13, src/smtpd/smtpd_check.c, smtpd_check_rcpt(),
# the lists, check_recipient_rcpt_maps() runs unless the status is # :5196-5200: after the lists, check_recipient_rcpt_maps() runs unless
# REJECT - an OK from check_recipient_access does not skip it.) # the status is REJECT - an OK from check_recipient_access does not
# skip it.)
RELAY_RECIPIENT_SETTING="texthash:${RELAY_RECIPIENTS_MAP}" RELAY_RECIPIENT_SETTING="texthash:${RELAY_RECIPIENTS_MAP}"
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
RELAY_RECIPIENT_SETTING="${RELAY_RECIPIENT_SETTING} texthash:${DMARC_RECIPIENTS_MAP}" RELAY_RECIPIENT_SETTING="${RELAY_RECIPIENT_SETTING} texthash:${DMARC_RECIPIENTS_MAP}"
fi fi
# The panel writes the same "[host]:port" into transport and # The panel writes the same "[host]:port" into transport and
# tls_policy: the policy lookup key is the verbatim next-hop, port and # tls_policy: the policy lookup key is the verbatim next-hop, port and
# brackets included (Postfix 3.7.11, README_FILES/TLS_README, "TLS # brackets included (Postfix 3.10.13, README_FILES/TLS_README:914, "TLS
# policy table" note: ":smtp", ":25" and no port are different keys). # policy table" note: ":smtp", ":25" and no port are different keys).
postconf -e \ postconf -e \
"relay_recipient_maps=${RELAY_RECIPIENT_SETTING}" \ "relay_recipient_maps=${RELAY_RECIPIENT_SETTING}" \
@@ -478,17 +655,17 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
"smtp_tls_policy_maps=" "smtp_tls_policy_maps="
fi fi
# Milters on port 25. main.cf's chain (OpenDKIM strict + journal-milter) is # Milters on port 25. main.cf's chain (sender check and signer strict +
# for the SUBMISSION path only: OpenDKIM must not gate someone else's # journal-milter) is for the SUBMISSION path only: the signer must not gate someone else's
# inbound mail (a signing outage would defer it), and the journal-milter # inbound mail (a signing outage would defer it), and the journal-milter
# would file inbound mail into the outbound send log under the *sender's* # would file inbound mail into the outbound send log under the *sender's*
# domain — polluting Deliveries and letting a forged From: consume that # domain — polluting Deliveries and letting a forged From: consume that
# domain's level-2 rate-limit budget. So smtpd_milters is always overridden # domain's level-2 rate-limit budget. So smtpd_milters is always overridden
# here: the optional antispam milter, then - with the inbound relay on - the # here: the optional antispam milter, then - with the inbound relay on - the
# panel's inbound journal milter, or nothing at all. (Postfix 3.7.11, # panel's inbound journal milter, or nothing at all. (Postfix 3.10.13,
# man/man5/master.5: "-o" overrides the main.cf parameter for this service; # man/man5/master.5: "-o" overrides the main.cf parameter for this service;
# src/smtpd/smtpd.c hands its own milter list to cleanup, milter_send(), # src/smtpd/smtpd.c:3475 hands its own milter list to cleanup, milter_send(),
# and src/cleanup/cleanup_milter.c, cleanup_milter_receive(), uses it.) # and src/cleanup/cleanup_milter.c:1997, cleanup_milter_receive(), uses it.)
INBOUND_MILTERS="" INBOUND_MILTERS=""
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ -n "${INBOUND_ANTISPAM_MILTER}" ]; then if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ -n "${INBOUND_ANTISPAM_MILTER}" ]; then
# A glob's "*" matches anything, so the shape alone would pass # A glob's "*" matches anything, so the shape alone would pass
@@ -521,11 +698,12 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
# never refuses, and must never hold up someone else's mail: accept when # never refuses, and must never hold up someone else's mail: accept when
# it is down. A comma, not a space, between the two - see # it is down. A comma, not a space, between the two - see
# master_cf_set_smtp_inet_o. # master_cf_set_smtp_inet_o.
# Postfix 3.7.11: src/milter/milter.c, milter_message(), sends the # Postfix 3.10.13: src/milter/milter.c:564, milter_message(), sends the
# message to each milter in list order; src/milter/milter8.c, # message to each milter in list order; src/milter/milter8.c:960,
# milter8_event(), applies a header a milter adds (SMFIR_ADDHEADER) to # milter8_event(), applies a header a milter adds (SMFIR_ADDHEADER,
# the queue file at once, and the next milter8_message() reads that # :1424) to the queue file at once, and the next milter8_message()
# file with rec_get(), which follows the added records. # (:2544) reads that file with rec_get() (:2612), which follows the
# added records.
case "${INBOUND_JOURNAL_SOCK}" in case "${INBOUND_JOURNAL_SOCK}" in
*[!A-Za-z0-9._/-]*) *[!A-Za-z0-9._/-]*)
echo "FATAL: INBOUND_JOURNAL_MILTER_SOCKET must be an absolute path, got: ${INBOUND_JOURNAL_SOCK}" >&2 echo "FATAL: INBOUND_JOURNAL_MILTER_SOCKET must be an absolute path, got: ${INBOUND_JOURNAL_SOCK}" >&2
@@ -557,12 +735,31 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
# syslog_name: this listener's lines read postfix/inbound/smtpd, the # syslog_name: this listener's lines read postfix/inbound/smtpd, the
# submission ports' postfix/smtpd. The log tailer files a refusal on port 25 # submission ports' postfix/smtpd. The log tailer files a refusal on port 25
# into the inbound journal and has to tell the two apart. (Postfix 3.7.11, # into the inbound journal and has to tell the two apart. (Postfix 3.10.13,
# src/global/mail_task.c, mail_task(): the log tag is syslog_name "/" # src/global/mail_task.c:56, mail_task(): the log tag is syslog_name "/"
# program name. Only smtpd carries it: cleanup and the rest are other # program name. Only smtpd carries it: cleanup and the rest are other
# services and log as postfix/.) # services and log as postfix/.)
postconf -M "smtp/inet=smtp inet n - n - - smtpd" postconf -M "smtp/inet=smtp inet n - n - - smtpd"
# smtpd_forbid_bare_newline: the 2023 SMTP-smuggling defence. normalize
# takes a bare <LF> as a line end but ends DATA only at the standard
# <CR><LF>.<CR><LF>: a "." line that a bare <LF> precedes or ends is
# dropped, not taken as the end of the message (Postfix 3.10.13,
# src/smtpd/smtpd.c:1693-1694, bare_lf_mask_table, normalize =
# BARE_LF_FLAG_WANT_STD_EOD; :3712-3716, the DATA loop; RELEASE_NOTES-3.9:
# 166-167, "the recommended setting "smtpd_forbid_bare_newline
# = normalize""). Since 3.9 normalize is also the default (src/smtpd/
# smtpd.c:830, "Postfix >= 3.9: normalize"; src/global/mail_params.h:4313,
# DEF_SMTPD_FORBID_BARE_LF "normalize"); the line stays so that port 25
# does not depend on a default (it came in 2.6.1, while the image's
# Postfix 3.7.11 defaulted to "no").
# Its exclusions default to $mynetworks (mail_params.h:4316), loopback
# only here (above), so no client is spared. The submission ports take
# the default, normalize as well since this Postfix: an application whose
# SMTP library ends DATA with a bare <LF>.<LF> is no longer read to its
# end there (docs/security.md § Mail path: their smuggled transaction would still
# be the authenticated session's, so they were left to the default).
postconf -P \ postconf -P \
"smtp/inet/smtpd_forbid_bare_newline=normalize" \
"smtp/inet/syslog_name=postfix/inbound" \ "smtp/inet/syslog_name=postfix/inbound" \
"smtp/inet/smtpd_sasl_auth_enable=no" \ "smtp/inet/smtpd_sasl_auth_enable=no" \
"smtp/inet/smtpd_tls_auth_only=no" \ "smtp/inet/smtpd_tls_auth_only=no" \
@@ -577,12 +774,12 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
# smtpd's -o limit is not the whole of it: cleanup writes the queue file # smtpd's -o limit is not the whole of it: cleanup writes the queue file
# under its own message_size_limit, main.cf's, which is never set here and # under its own message_size_limit, main.cf's, which is never set here and
# so is 10240000 (Postfix 3.7.11, src/cleanup/cleanup_init.c, # so is 10240000 (Postfix 3.10.13, src/cleanup/cleanup_init.c:444,
# cleanup_post_jail(), set_file_limit(); src/global/mail_params.h, # cleanup_post_jail(), :455, set_file_limit(); src/global/mail_params.h:2013,
# DEF_MESSAGE_LIMIT), and a larger message is refused 552 after DATA # DEF_MESSAGE_LIMIT), and a larger message is refused 552 after DATA
# (assumptions C-P31). cleanup gets port 25's limit when it is larger; # (assumptions C-P31). cleanup gets port 25's limit when it is larger;
# smtpd on 465 and 587 still holds its own, main.cf's (src/smtpd/smtpd.c, # smtpd on 465 and 587 still holds its own, main.cf's (src/smtpd/smtpd.c:3721,
# the DATA and BDAT size checks). # :3964, :4136, :4250, the DATA and BDAT size checks).
if [ "${PORT25_SIZE}" -gt "$(postconf -h message_size_limit)" ]; then if [ "${PORT25_SIZE}" -gt "$(postconf -h message_size_limit)" ]; then
postconf -P "cleanup/unix/message_size_limit=${PORT25_SIZE}" postconf -P "cleanup/unix/message_size_limit=${PORT25_SIZE}"
fi fi
@@ -603,23 +800,44 @@ fi
# Inside a container the chroot buys little (the container is the isolation # Inside a container the chroot buys little (the container is the isolation
# boundary) and breaks DNS/TLS trust-store access, so turn it off uniformly. Our # boundary) and breaks DNS/TLS trust-store access, so turn it off uniformly. Our
# own smtps/submission services are already n; this covers the delivery agents # own smtps/submission services are already n; this covers the delivery agents
# and the rest. (postfix 3.7.11-0+deb12u1: debian/patches/40_chroot_by_default.diff # and the rest. (postfix 3.10.13-0+deb13u1: debian/patches/40_chroot_by_default.diff
# sets chroot y; debian/configure-instance.sh copies etc/resolv.conf and the # sets chroot y. chroot(2) also needs CAP_SYS_CHROOT, which
# like into the chroot, and nothing runs it in this container. chroot(2) also # deploy/docker-compose.yml drops with cap_drop: ALL - capabilities(7).)
# needs CAP_SYS_CHROOT, which deploy/docker-compose.yml drops with cap_drop: #
# ALL - capabilities(7).) # Debian's chroot setup now runs on every "postfix check" and "postfix
# start-fg": conf/post-install:587 (debian/patches/
# debian-run-configure-instance-from-create-missing.patch) calls
# /usr/lib/postfix/configure-instance.sh at the end of create-missing, and
# that copies etc/localtime, etc/resolv.conf, etc/hosts and the like into
# $queue_directory/etc - here /data/postfix/queue/etc - whether a service is
# chrooted or not (debian/configure-instance.in:80-91, before its
# need_chroot test at :93). With nothing chrooted those copies are read by
# nobody, so the image turns the sync off with SYNC_CHROOT= in
# /etc/default/postfix (build/Dockerfile; configure-instance.in:22-24, :44,
# "[ -n "$SYNC_CHROOT" ] || exit 0"; debian/README.Debian:136-138).
postconf -F "*/*/chroot=n" postconf -F "*/*/chroot=n"
# --- Cyrus SASL app config for smtpd ----------------------------------------- # --- Cyrus SASL app config for smtpd -----------------------------------------
# Tells the Cyrus library (invoked by smtpd via smtpd_sasl_path=smtpd) to verify # Tells the Cyrus library (invoked by smtpd via smtpd_sasl_path=smtpd) to verify
# passwords straight from the panel-maintained sasldb2. PLAIN/LOGIN # passwords straight from the panel-maintained sasldb2. PLAIN/LOGIN
# only — both are safe because TLS is mandatory before auth on every port. # only — both are safe because TLS is mandatory before auth on every port.
# The file is found by a Debian patch, not by upstream Postfix: postfix # The directory is said here, cyrus_sasl_config_path, and not left to the
# 3.7.11-0+deb12u1, debian/patches/07_sasl_config.diff, xsasl_getconfpath() in # package's main.cf. Postfix 3.10.13, src/xsasl/xsasl_cyrus_server.c:227-232,
# src/xsasl/xsasl_cyrus_log.c gives Cyrus "$config_directory/sasl:/usr/lib/sasl", # xsasl_cyrus_server_init(): a non-empty cyrus_sasl_config_path goes to Cyrus
# and smtpd_sasl_path=smtpd names smtpd.conf there. The keys: Cyrus SASL # with sasl_set_path(SASL_PATH_TYPE_CONFIG, ...), and :246,
# sasl_server_init(callbacks, path_info) with smtpd_sasl_path=smtpd
# (src/smtpd/smtpd_sasl_glue.c:188-189) names smtpd.conf there; the default
# is empty (src/global/mail_params.h:1690, DEF_CYRUS_CONF_PATH ""), and then
# Cyrus looks in its own path, /etc/sasl2:/etc/sasl:/usr/lib/<triplet>/sasl2:
# /usr/lib/sasl2 (cyrus-sasl2 2.1.28+dfsg1, debian/rules:96, --with-configdir;
# lib/common.c:210, sasl_set_path()), which this file is not in. Bookworm's
# package found it with a patch (07_sasl_config.diff, gone in 3.10.13); trixie's
# writes cyrus_sasl_config_path = /etc/postfix/sasl into a new main.cf
# (postfix 3.10.13-0+deb13u1, debian/main.cf.in:71) and into an upgraded one
# only when it is empty (debian/postfix.postinst:88-97). The keys: Cyrus SASL
# 2.1.28, doc/legacy/options.html (pwcheck_method, auxprop_plugin, # 2.1.28, doc/legacy/options.html (pwcheck_method, auxprop_plugin,
# sasldb_path, mech_list). # sasldb_path, mech_list).
postconf -e "cyrus_sasl_config_path=/etc/postfix/sasl"
mkdir -p /etc/postfix/sasl mkdir -p /etc/postfix/sasl
cat > /etc/postfix/sasl/smtpd.conf <<EOF cat > /etc/postfix/sasl/smtpd.conf <<EOF
pwcheck_method: auxprop pwcheck_method: auxprop
@@ -630,8 +848,10 @@ EOF
# Validate the generated configuration; fail loudly if postconf produced # Validate the generated configuration; fail loudly if postconf produced
# anything Postfix rejects, before the wrapper tries to start it. (Postfix # anything Postfix rejects, before the wrapper tries to start it. (Postfix
# 3.7.11, conf/postfix-script, "check": "check-fatal || exit 1"; check-fatal # 3.10.13, conf/postfix-script:292, "check": "check-fatal || exit 1"; check-fatal
# also runs "post-install create-missing", which builds the queue tree.) # also runs "post-install create-missing" (:314), which builds the queue tree
# and, in Debian's package, runs configure-instance.sh - see the chroot note
# above.)
set +e set +e
check_out=$(postfix check 2>&1) check_out=$(postfix check 2>&1)
ec=$? ec=$?
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
# The reload the panel asks supervisord for (program postfix-reload,
# build/supervisord.conf; internal/supervisor RunOnce reads its exit status).
#
# Exit 75 says no Postfix master runs - it is stopped, or still in its start
# behind the milter wait (build/postfix-wrapper.sh) - and the master that
# starts next reads the files as they are now, so the panel takes that as
# done. Anything else is "postfix reload" itself: 0, or its failure (Postfix
# 3.10.13, conf/postfix-script:266-279, "reload": exit 1 when not running or
# when postsuper fails; src/postfix/postfix.c, a main.cf it cannot read). "master
# -t" exits 0, silently, while no master holds the lock (src/master/master.c:495-499,
# test_lock). A master that stops between the two is a failure the panel
# reports - the rarer mistake (review of stage 2c, F5). Since Postfix 3.8 the
# reload also writes "refreshing the Postfix mail system" to stderr, which
# goes to the container's log (postfix-wrapper.sh, the note on 3.8's stderr
# logging); the panel reads only the exit status.
d=$(postconf -h daemon_directory) || exit 1
if "$d/master" -t 2>/dev/null; then
echo "postfix-reload: Postfix is not running; its start reads the files" >&2
exit 75
fi
exec /usr/sbin/postfix reload
+39 -14
View File
@@ -2,8 +2,9 @@
# Postfix start wrapper: solves the cold-start race where Postfix would try to # Postfix start wrapper: solves the cold-start race where Postfix would try to
# reach the milter sockets before they are listening. # reach the milter sockets before they are listening.
# #
# It blocks until the milter sockets — OpenDKIM, the panel's sender check and # It blocks until the milter sockets — the panel's sender check, DKIM signer
# journal-milter and, with the inbound relay on, its inbound journal milter — # (two) and journal-milter and, with the inbound relay on, its
# inbound journal milter —
# are present, then runs `postfix start-fg` and stays its parent. If they are # are present, then runs `postfix start-fg` and stays its parent. If they are
# not ready within the timeout it exits non-zero WITHOUT starting Postfix. # not ready within the timeout it exits non-zero WITHOUT starting Postfix.
# supervisord counts that exit as one that came too quickly - the program's # supervisord counts that exit as one that came too quickly - the program's
@@ -16,16 +17,17 @@
# #
# This handles cold start only. Once Postfix runs, a milter that does not # This handles cold start only. Once Postfix runs, a milter that does not
# answer costs what its default_action says (build/postfix-config.sh): mail is # answer costs what its default_action says (build/postfix-config.sh): mail is
# deferred for OpenDKIM and the sender check, and passes the journal-milter. # deferred for the signer and the sender check, and passes the journal-milter.
set -eu set -eu
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}"
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}" JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
SENDER_SOCK="${SENDER_CHECK_SOCKET:-/run/selfpost/sender.sock}" SENDER_SOCK="${SENDER_CHECK_SOCKET:-/run/selfpost/sender.sock}"
SIGN_SOCK="${DKIM_SIGN_SOCKET:-/run/selfpost/sign.sock}"
SIGN_LOCAL_SOCK="${DKIM_SIGN_LOCAL_SOCKET:-/run/selfpost/sign-local.sock}"
TIMEOUT="${MILTER_WAIT_TIMEOUT:-30}" TIMEOUT="${MILTER_WAIT_TIMEOUT:-30}"
INTERVAL=1 INTERVAL=1
SOCKS="$OPENDKIM_SOCK $JOURNAL_SOCK $SENDER_SOCK" SOCKS="$JOURNAL_SOCK $SENDER_SOCK $SIGN_SOCK $SIGN_LOCAL_SOCK"
if [ "${INBOUND_RELAY_ENABLE:-false}" = "true" ]; then if [ "${INBOUND_RELAY_ENABLE:-false}" = "true" ]; then
SOCKS="$SOCKS ${INBOUND_JOURNAL_MILTER_SOCKET:-/run/selfpost/inbound-journal.sock}" SOCKS="$SOCKS ${INBOUND_JOURNAL_MILTER_SOCKET:-/run/selfpost/inbound-journal.sock}"
fi fi
@@ -44,28 +46,48 @@ for sock in $SOCKS; do
done done
echo "postfix-wrapper: milter sockets ready, starting postfix" echo "postfix-wrapper: milter sockets ready, starting postfix"
# Postfix 3.7.11: postfix(1) execs conf/postfix-script (src/postfix/postfix.c), # Postfix 3.10.13: postfix(1) execs conf/postfix-script (src/postfix/postfix.c:669),
# whose "start-fg" execs master only as PID 1 and otherwise runs "master -s" as # whose "start-fg" (:200-210) execs master only as PID 1 and otherwise runs
# its child; master then makes itself a session leader (src/master/master.c, # "master -s" as its child; master then makes itself a session leader
# setsid()). A signal supervisord sends this program never reaches master # (src/master/master.c:441, setsid()). A signal supervisord sends this
# program never reaches master
# (supervisor 4.2.5, supervisor/process.py, Subprocess.kill(): to the pid # (supervisor 4.2.5, supervisor/process.py, Subprocess.kill(): to the pid
# alone without stopasgroup, and master is not in its group anyway): before, # alone without stopasgroup, and master is not in its group anyway): before,
# a stop left master running - a restart of the program then found Postfix # a stop left master running - a restart of the program then found Postfix
# "already running", and a stop of the container cut Postfix off with # "already running", and a stop of the container cut Postfix off with
# SIGKILL (assumptions C-P42). So this shell stays, and on TERM or INT runs # SIGKILL (assumptions C-P42). So this shell stays, and on TERM or INT runs
# "postfix stop" (conf/postfix-script, "stop": TERM to master, then waits up # "postfix stop" (conf/postfix-script:237-253, "stop": TERM to master, then
# to five seconds and kills its group; src/master/master_sig.c, # waits up to five seconds and kills its group; src/master/master_sig.c:205,
# master_sigdeath(): "terminating on signal 15"). # master_sigdeath(): "terminating on signal 15"). Debian's redirect of
# "postfix start" to systemd (debian/patches/
# debian-re-run-startup-through-systemd.patch, conf/postfix-script:170-189)
# leaves start-fg alone and needs /run/systemd/system, which this container
# has not.
#
# Since Postfix 3.8 postfix(1) and postlog(1) - postfix-script's $INFO, $WARN
# and $FATAL - write their messages to stderr even when it is not a terminal
# (src/postfix/postfix.c:546, msg_vstream_init() no longer under isatty();
# RELEASE_NOTES-3.8:114-115, [Incompat 20230308]), besides maillog_file. So
# "starting the Postfix mail system", "stopping ..." and check-warn's
# warnings now reach the container's log through this program's stderr
# (build/supervisord.conf) as well as /data/log/mail.log; nothing reads them
# there but a person.
# #
# "postfix start-fg" spends its first seconds in its checks before master # "postfix start-fg" spends its first seconds in its checks before master
# runs; a stop asked then would find nothing to stop, so it is asked again # runs; a stop asked then would find nothing to stop, so it is asked again
# until the child is gone. "master -t" exits 0, silently, while no master # until the child is gone. "master -t" exits 0, silently, while no master
# holds the lock (src/master/master.c, test_lock). # holds the lock (src/master/master.c:495-499, test_lock).
daemon_directory=$(postconf -h daemon_directory) daemon_directory=$(postconf -h daemon_directory)
child= child=
stop_asked=
stop_postfix() { stop_postfix() {
if [ -z "$child" ]; then
# Between "&" and "child=$!": stopped right after the pid is known.
stop_asked=1
return
fi
trap '' TERM INT trap '' TERM INT
while [ -n "$child" ] && kill -0 "$child" 2>/dev/null; do while kill -0 "$child" 2>/dev/null; do
if "$daemon_directory/master" -t 2>/dev/null; then if "$daemon_directory/master" -t 2>/dev/null; then
sleep 1 sleep 1
else else
@@ -77,6 +99,9 @@ stop_postfix() {
trap stop_postfix TERM INT trap stop_postfix TERM INT
postfix start-fg & postfix start-fg &
child=$! child=$!
if [ -n "$stop_asked" ]; then
stop_postfix
fi
# A trapped signal makes wait return at once, the trap is taken right after # A trapped signal makes wait return at once, the trap is taken right after
# (POSIX, Shell Command Language, 2.11 Signals and Error Handling) and exits. # (POSIX, Shell Command Language, 2.11 Signals and Error Handling) and exits.
# Otherwise wait returns when Postfix ended on its own: its status is this # Otherwise wait returns when Postfix ended on its own: its status is this
+31 -37
View File
@@ -1,21 +1,23 @@
; SelfPost process supervision. ; SelfPost process supervision.
; ;
; Start ORDER is enforced by priority=: OpenDKIM, then the panel (which opens ; Start ORDER is enforced by priority=: the panel (which opens the sender
; the sender check's and the journal-milter's sockets), then the Postfix ; check's, the DKIM signer's and the journal-milter's sockets), then the
; wrapper — which additionally blocks until the milter sockets are ready ; Postfix wrapper — which additionally blocks until the milter sockets are ready
; before starting Postfix. (supervisor 4.2.5, docs/configuration.rst, ; before starting Postfix. (supervisor 4.2.5, docs/configuration.rst,
; [program:x] "priority": "the start and shutdown ordering"; supervisor/ ; [program:x] "priority": "the start and shutdown ordering"; supervisor/
; supervisord.py, runforever(), transitions every group in that order in one ; supervisord.py, runforever(), transitions every group in that order in one
; pass, with no wait for a program to be ready.) ; pass, with no wait for a program to be ready.)
; ;
; If a managed process of the mail path exhausts its restart retries (FATAL), ; If a managed process of the mail path exhausts its restart retries (FATAL),
; the crashexit event listener brings the whole container down so Docker's ; or exits unexpectedly five times in ten minutes, the crashexit event listener
; restart policy starts it again (the same container: docs.docker.com, "Start ; brings the whole container down so Docker's restart policy starts it again
; containers automatically", restart policies), rather than lingering with a ; (the same container: docs.docker.com, "Start containers automatically",
; dead component. A program reaches FATAL only through BACKOFF, by exits that ; restart policies), rather than lingering with a dead component. A program
; come before its startsecs (supervisor 4.2.5, supervisor/process.py, ; reaches FATAL only through BACKOFF, by exits that come before its startsecs
; Subprocess.finish() and transition()): an exit after it is EXITED and simply ; (supervisor 4.2.5, supervisor/process.py, Subprocess.finish() and
; restarted, for ever. logrotate's FATAL is left on Server > Health ; transition()): an exit after it is EXITED and simply restarted, for ever -
; which the count of exits covers (audit 2026-10-12, L32). logrotate's,
; postfix-reload's and cert-reload's failures are left on Server > Health
; (build/crashexit.py). ; (build/crashexit.py).
[supervisord] [supervisord]
@@ -26,17 +28,19 @@ logfile_maxbytes=0
pidfile=/run/supervisord.pid pidfile=/run/supervisord.pid
loglevel=info loglevel=info
; The control socket is reachable by the shared `selfpost` group so the panel ; The control socket is reachable by the `supervisor` group, whose one member
; (running as the unprivileged `panel` user) can ask supervisord to send ; is the panel (the unprivileged `panel` user): it asks supervisord to run the
; OpenDKIM a reload signal after it rewrites the DKIM tables. It is still ; Postfix reload, and for the processes' state. Whoever reaches the socket can
; unreachable by world; only root and the two SelfPost services are in the ; stop, start or restart any program, so it is not the shared `selfpost`
; group. No HTTP auth is configured because access is gated by socket ; group's: Postfix is in that one for the files under /data, and a fault in
; permissions, not the network. (supervisor 4.2.5, docs/configuration.rst, ; it must not reach every other program (audit 2026-10-12, stage 5). Not
; [unix_http_server]: chmod and chown set the socket's mode and owner; without ; reachable by world. No HTTP auth is configured because access is gated by
; username and password no authentication is asked.) ; socket permissions, not the network. (supervisor 4.2.5,
; docs/configuration.rst, [unix_http_server]: chmod and chown set the socket's
; mode and owner; without username and password no authentication is asked.)
[unix_http_server] [unix_http_server]
file=/run/supervisor.sock file=/run/supervisor.sock
chown=root:selfpost chown=root:supervisor
chmod=0770 chmod=0770
[rpcinterface:supervisor] [rpcinterface:supervisor]
@@ -45,17 +49,6 @@ supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl] [supervisorctl]
serverurl=unix:///run/supervisor.sock serverurl=unix:///run/supervisor.sock
[program:opendkim]
command=/usr/sbin/opendkim -f -x /etc/opendkim.conf
priority=100
autostart=true
autorestart=true
startretries=3
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; user=panel also gives the panel its supplementary groups, selfpost and ; user=panel also gives the panel its supplementary groups, selfpost and
; postdrop (build/Dockerfile): supervisor 4.2.5, supervisor/options.py, ; postdrop (build/Dockerfile): supervisor 4.2.5, supervisor/options.py,
; drop_privileges(), os.setgroups() from the user's memberships in ; drop_privileges(), os.setgroups() from the user's memberships in
@@ -96,19 +89,20 @@ stderr_logfile_maxbytes=0
; One-shot reload helper. The panel runs unprivileged and cannot run `postfix ; One-shot reload helper. The panel runs unprivileged and cannot run `postfix
; reload` itself, and it cannot signal the Postfix master directly: `postfix ; reload` itself, and it cannot signal the Postfix master directly: `postfix
; start-fg` forks a separate master process, so a signal to the supervised ; start-fg` forks a separate master process, so a signal to the supervised
; foreground process never reaches it (unlike OpenDKIM, which runs in the ; foreground process never reaches it. Instead the panel asks
; foreground as the supervised process itself). Instead the panel asks
; supervisord — over the group-accessible control socket — to run this program, ; supervisord — over the group-accessible control socket — to run this program,
; which executes the canonical `postfix reload` as root. autostart is off (it ; which runs the canonical `postfix reload` as root - or exits 75 when no
; only runs on demand) and a fast, clean exit is expected, so it never trips the ; master runs (build/postfix-reload.sh). autostart is off (it only runs on
; crashexit listener. (start-fg: see build/postfix-wrapper.sh. supervisor ; demand). A program that cannot even be spawned twice in a row ends FATAL
; (startretries=1); crashexit leaves that to the page that asked for the
; reload, which reports it (build/crashexit.py; review of stage 2c, F6). (start-fg: see build/postfix-wrapper.sh. supervisor
; 4.2.5, supervisor/process.py, Subprocess.finish(): with startsecs=0 no exit ; 4.2.5, supervisor/process.py, Subprocess.finish(): with startsecs=0 no exit
; counts as "too quickly", so even a failed run ends EXITED - never BACKOFF, ; counts as "too quickly", so even a failed run ends EXITED - never BACKOFF,
; never FATAL - with its exit status kept, which the panel reads ; never FATAL - with its exit status kept, which the panel reads
; (internal/supervisor, RunOnce). Its resting state is STOPPED until the first ; (internal/supervisor, RunOnce). Its resting state is STOPPED until the first
; run, EXITED after it; assumptions C-V10.) ; run, EXITED after it; assumptions C-V10.)
[program:postfix-reload] [program:postfix-reload]
command=/usr/sbin/postfix reload command=/usr/local/bin/postfix-reload.sh
autostart=false autostart=false
autorestart=false autorestart=false
startsecs=0 startsecs=0
@@ -160,6 +154,6 @@ stderr_logfile_maxbytes=0
; STOPPED (Subprocess.finish(), self.killing), which this listener never sees. ; STOPPED (Subprocess.finish(), self.killing), which this listener never sees.
[eventlistener:crashexit] [eventlistener:crashexit]
command=/usr/local/bin/crashexit.py command=/usr/local/bin/crashexit.py
events=PROCESS_STATE_FATAL events=PROCESS_STATE_FATAL,PROCESS_STATE_EXITED
stderr_logfile=/dev/stderr stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0 stderr_logfile_maxbytes=0
+11 -9
View File
@@ -3,7 +3,6 @@ package main
import ( import (
"errors" "errors"
"fmt" "fmt"
"io"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
@@ -14,8 +13,6 @@ import (
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
) )
const maxIngestStdin = 10 << 20 // 10 MiB
func runDMARCIngestMode() error { func runDMARCIngestMode() error {
cfg := loadConfig() cfg := loadConfig()
if !cfg.dmarcEnabled { if !cfg.dmarcEnabled {
@@ -28,14 +25,14 @@ func runDMARCIngestMode() error {
defer st.Close() defer st.Close()
recipient := ingestRecipient(os.Args[1:]) recipient := ingestRecipient(os.Args[1:])
if err := dmarc.IngestMessage(st, io.LimitReader(os.Stdin, maxIngestStdin), recipient, time.Now().UTC()); err != nil { if err := dmarc.IngestMessage(st, os.Stdin, recipient, time.Now().UTC()); err != nil {
if !errors.Is(err, dmarc.ErrRejected) { if !errors.Is(err, dmarc.ErrRejected) {
return err return err
} }
// Not a report this relay keeps. It is dropped: deferring would have // Not a report this relay keeps. It is dropped: deferring would have
// Postfix try it again for days, and a bounce would go to an envelope // Postfix try it again for days, and a bounce would go to an envelope
// sender anyone can write - the report addresses are public. Postfix // sender anyone can write - the report addresses are public. Postfix
// 3.7.11: exit 0 is PIPE_STAT_OK, logged "status=sent (delivered via // 3.10.13: exit 0 is PIPE_STAT_OK, logged "status=sent (delivered via
// ... service ...)" (src/global/pipe_command.c, pipe_command(); // ... service ...)" (src/global/pipe_command.c, pipe_command();
// src/pipe/pipe.c, eval_command_status()); a 4.x.x status defers until // src/pipe/pipe.c, eval_command_status()); a 4.x.x status defers until
// maximal_queue_lifetime and a 5.x.x one bounces, either way a notice // maximal_queue_lifetime and a 5.x.x one bounces, either way a notice
@@ -52,13 +49,18 @@ func runDMARCIngestMode() error {
// what a delivery command wrote - about a hundred bytes - into its own line // what a delivery command wrote - about a hundred bytes - into its own line
// for the message ("status=sent (delivered via dmarc-ingest service (...))"), // for the message ("status=sent (delivered via dmarc-ingest service (...))"),
// so the reason goes out first and bare: no prefix, no date, no recipient, // so the reason goes out first and bare: no prefix, no date, no recipient,
// all of which that line already has. Postfix 3.7.11, src/pipe/pipe.c, // all of which that line already has. Postfix 3.10.13, src/pipe/pipe.c,
// eval_command_status(): " (%.100s)" of the output, which // eval_command_status(): " (%.100s)" of the output, which
// src/global/pipe_command.c takes from stdout and stderr together. postlog is // src/global/pipe_command.c takes from stdout and stderr together. postlog is
// no use here: pipe(8) starts the command without the group that may write to // no use here: pipe(8) starts the command without the group that may write to
// Postfix's log socket (src/util/set_ugid.c, set_ugid(): setgroups() to the // Postfix's log socket (src/util/set_ugid.c, set_ugid(): setgroups() to the
// user's one group). Whether Debian's postlog is set-gid, which would bring // user's one group). Nor does postlog bring the group with it: upstream
// the group with it, is not recorded - assumptions A-43. // installs it set-gid, Debian's package does not - measured in the image,
// 2026-10-15: /usr/sbin/postlog is -rwxr-xr-x root:root (postfix
// 3.7.11-0+deb12u1), and dpkg-statoverride lists only postdrop and
// postqueue (assumptions A-43). postfix 3.10.13-0+deb13u1 is the same by its
// source: debian/postfix.postinst adds statoverrides for /usr/sbin/postdrop
// and /usr/sbin/postqueue alone, and nothing in debian/ makes postlog set-gid.
func logRejected(reason error) { func logRejected(reason error) {
fmt.Fprintln(os.Stderr, "dropped: "+logSafe(strings.TrimPrefix(reason.Error(), dmarc.ErrRejected.Error()+": "))) fmt.Fprintln(os.Stderr, "dropped: "+logSafe(strings.TrimPrefix(reason.Error(), dmarc.ErrRejected.Error()+": ")))
} }
@@ -83,7 +85,7 @@ func logSafe(s string) string {
// ingestRecipient is the report address the message was delivered to. Postfix's // ingestRecipient is the report address the message was delivered to. Postfix's
// pipe(8) hands it over as an argument (${recipient} in the service's argv, // pipe(8) hands it over as an argument (${recipient} in the service's argv,
// build/postfix-config.sh) - it sets no RECIPIENT variable, that is local(8). // build/postfix-config.sh) - it sets no RECIPIENT variable, that is local(8).
// Postfix 3.7.11, src/pipe/pipe.c, deliver_message(): pipe_command() gets only // Postfix 3.10.13, src/pipe/pipe.c, deliver_message(): pipe_command() gets only
// CA_PIPE_CMD_EXPORT(export_environment), no extra environment; // CA_PIPE_CMD_EXPORT(export_environment), no extra environment;
// src/local/command.c sets "RECIPIENT". // src/local/command.c sets "RECIPIENT".
// Without it the check that a report came to its own domain's address is // Without it the check that a report came to its own domain's address is
+124 -156
View File
@@ -1,176 +1,144 @@
package main package main
import ( import (
"io/fs"
"os"
"path/filepath"
"regexp"
"slices" "slices"
"strings"
"testing" "testing"
) )
// documentedPublic matches docs/guide.md "Environment variables" table. // The environment the image reads and the environment the documents describe
var documentedPublic = []string{ // are compared as they are, not as lists kept by hand beside them (audit
"SELFPOST_HOSTNAME", // 2026-10-12, stage 5 - the three lists this file held were compared with each
"SUBMISSION_ENABLE", // other and with nothing else). Read: what the panel's code asks of the
"RATE_LIMIT_MESSAGES_PER_IP", // environment (cmd/panel, cmd/selfpost-backup and internal, tests aside) and what the start-up
"RATE_LIMIT_WINDOW_SECONDS", // scripts take with a default (build/*.sh, "${VAR:-…}"). Documented: the
"SEND_LOG_RETENTION_DAYS", // operator's table in guide.md § Environment variables, with the paragraph
"PANEL_SESSION_IDLE_DAYS", // under it, and the internal list in architecture.md § Configuration.
"SELFPOST_DNS_RESOLVERS",
"TRUSTED_PROXY_CIDR", var (
"INBOUND_RELAY_ENABLE", goEnvRead = regexp.MustCompile(`(?:envDefault|envInt|envBool|envSeconds|envDuration|os\.Getenv|os\.LookupEnv)\("([A-Z][A-Z0-9_]+)"`)
"INBOUND_ANTISPAM_MILTER", // A name kept in a constant, as the backup CLI keeps its password's.
"INBOUND_ANTISPAM_MILTER_ACTION", goEnvConst = regexp.MustCompile(`\w+Env\s*=\s*"([A-Z][A-Z0-9_]+)"`)
"INBOUND_RATE_LIMIT_MESSAGES_PER_IP", scriptEnvRead = regexp.MustCompile(`\$\{([A-Z][A-Z0-9_]+):[-?=]`)
"INBOUND_MESSAGE_SIZE_LIMIT", docEnvName = regexp.MustCompile("`([A-Z][A-Z0-9_]+)`")
"DMARC_REPORTS_ENABLE", )
"DMARC_RATE_LIMIT_MESSAGES_PER_IP",
"DMARC_MESSAGE_SIZE_LIMIT", func readFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(b)
} }
// documentedInternal matches architecture.md § Configuration "Internal env vars". // envRead is every variable the code and the scripts read.
var documentedInternal = []string{ func envRead(t *testing.T) map[string]bool {
"SELFPOST_DATA_DIR", t.Helper()
"SELFPOST_DB_PATH", keys := map[string]bool{}
"SELFPOST_SETUP_TOKEN_FILE", for _, root := range []string{".", filepath.Join("..", "selfpost-backup"), filepath.Join("..", "..", "internal")} {
"PANEL_HTTP_ADDR", err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
"JOURNAL_MILTER_SOCKET", if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
"SENDER_CHECK_SOCKET", return err
"MAIL_LOG", }
"PANEL_COOKIE_SECURE", text := readFile(t, path)
"OPENDKIM_SOCKET", for _, m := range goEnvRead.FindAllStringSubmatch(text, -1) {
"OPENDKIM_DIR", keys[m[1]] = true
"DKIM_SELECTOR_DEFAULT", }
"SASL_DB_PATH", for _, m := range goEnvConst.FindAllStringSubmatch(text, -1) {
"SASL_REALM", keys[m[1]] = true
"POSTFIX_DIR", }
"POSTFIX_SENDER_LOGIN_MAPS", return nil
"POSTFIX_QUEUE_DIR", })
"POSTFIX_RELAY_DOMAINS", if err != nil {
"POSTFIX_TRANSPORT_MAPS", t.Fatal(err)
"POSTFIX_RELAY_RECIPIENTS",
"POSTFIX_TLS_POLICY_MAPS",
"POSTFIX_DMARC_RECIPIENTS",
"POSTFIX_DMARC_TRANSPORT",
"POSTFIX_DMARC_RELAY_DOMAINS",
"SELFPOST_DEPLOY_ROOT",
"MILTER_CONNECT_TIMEOUT",
"MILTER_COMMAND_TIMEOUT",
"MILTER_CONTENT_TIMEOUT",
"MILTER_WAIT_TIMEOUT",
"TLS_RELOAD_INTERVAL_SECONDS",
"LOGROTATE_INTERVAL_SECONDS",
}
// documentedComposeFixed are env keys documented outside the table (TLS paths fixed in compose).
var documentedComposeFixed = []string{
"TLS_CERT_FILE",
"TLS_KEY_FILE",
}
// loadConfigKeys is every environment variable read by loadConfig() in main.go.
// Update together with loadConfig when adding a new key.
var loadConfigKeys = []string{
"SELFPOST_DATA_DIR",
"PANEL_HTTP_ADDR",
"JOURNAL_MILTER_SOCKET",
"SENDER_CHECK_SOCKET",
"MAIL_LOG",
"SEND_LOG_RETENTION_DAYS",
"SELFPOST_DB_PATH",
"SELFPOST_SETUP_TOKEN_FILE",
"SELFPOST_HOSTNAME",
"PANEL_COOKIE_SECURE",
"SUBMISSION_ENABLE",
"TRUSTED_PROXY_CIDR",
"PANEL_SESSION_IDLE_DAYS",
"SELFPOST_DNS_RESOLVERS",
"TLS_CERT_FILE",
"TLS_RELOAD_INTERVAL_SECONDS",
"OPENDKIM_SOCKET",
"OPENDKIM_DIR",
"DKIM_SELECTOR_DEFAULT",
"SASL_DB_PATH",
"SASL_REALM",
"POSTFIX_DIR",
"SELFPOST_DEPLOY_ROOT",
"INBOUND_RELAY_ENABLE",
"INBOUND_ANTISPAM_MILTER",
"INBOUND_ANTISPAM_MILTER_ACTION",
"DMARC_REPORTS_ENABLE",
}
// buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh
// but not necessarily in loadConfig. Update when startup scripts gain a new knob.
var buildScriptKeys = []string{
"SELFPOST_HOSTNAME",
"TLS_CERT_FILE",
"TLS_KEY_FILE",
"RATE_LIMIT_MESSAGES_PER_IP",
"RATE_LIMIT_WINDOW_SECONDS",
"OPENDKIM_SOCKET",
"JOURNAL_MILTER_SOCKET",
"SENDER_CHECK_SOCKET",
"MAIL_LOG",
"POSTFIX_SENDER_LOGIN_MAPS",
"POSTFIX_QUEUE_DIR",
"SASL_DB_PATH",
"SUBMISSION_ENABLE",
"INBOUND_RELAY_ENABLE",
"INBOUND_ANTISPAM_MILTER",
"INBOUND_ANTISPAM_MILTER_ACTION",
"INBOUND_RATE_LIMIT_MESSAGES_PER_IP",
"INBOUND_MESSAGE_SIZE_LIMIT",
"DMARC_REPORTS_ENABLE",
"DMARC_RATE_LIMIT_MESSAGES_PER_IP",
"DMARC_MESSAGE_SIZE_LIMIT",
"POSTFIX_RELAY_DOMAINS",
"POSTFIX_TRANSPORT_MAPS",
"POSTFIX_RELAY_RECIPIENTS",
"POSTFIX_TLS_POLICY_MAPS",
"POSTFIX_DMARC_RECIPIENTS",
"POSTFIX_DMARC_TRANSPORT",
"POSTFIX_DMARC_RELAY_DOMAINS",
"MILTER_CONNECT_TIMEOUT",
"MILTER_COMMAND_TIMEOUT",
"MILTER_CONTENT_TIMEOUT",
"MILTER_WAIT_TIMEOUT",
"TLS_RELOAD_INTERVAL_SECONDS",
"LOGROTATE_INTERVAL_SECONDS",
}
func documentedKeys() []string {
keys := append([]string{}, documentedPublic...)
keys = append(keys, documentedInternal...)
keys = append(keys, documentedComposeFixed...)
slices.Sort(keys)
return slices.Compact(keys)
}
func TestLoadConfigKeysDocumented(t *testing.T) {
doc := documentedKeys()
for _, key := range loadConfigKeys {
if !slices.Contains(doc, key) {
t.Errorf("loadConfig reads %s but it is not listed in guide.md public, internal, or compose-fixed env docs", key)
} }
} }
scripts, err := filepath.Glob(filepath.Join("..", "..", "build", "*.sh"))
if err != nil || len(scripts) == 0 {
t.Fatalf("no build scripts: %v", err)
}
for _, path := range scripts {
for _, m := range scriptEnvRead.FindAllStringSubmatch(readFile(t, path), -1) {
keys[m[1]] = true
}
}
return keys
}
// section is the text of doc from the first line that starts with start up
// to the next that starts with any of ends.
func section(t *testing.T, doc, start string, ends ...string) string {
t.Helper()
i := strings.Index(doc, "\n"+start)
if i < 0 {
t.Fatalf("no %q", start)
}
rest := doc[i+1+len(start):]
end := len(rest)
for _, e := range ends {
if j := strings.Index(rest, "\n"+e); j >= 0 && j < end {
end = j
}
}
return rest[:end]
}
// envDocumented is every variable the documents name, and where.
func envDocumented(t *testing.T) map[string]string {
t.Helper()
keys := map[string]string{}
guide := section(t, readFile(t, filepath.Join("..", "..", "docs", "guide.md")), "### Environment variables", "### ")
for _, m := range docEnvName.FindAllStringSubmatch(guide, -1) {
keys[m[1]] = "guide.md § Environment variables"
}
arch := section(t, readFile(t, filepath.Join("..", "..", "docs", "architecture.md")), "**Internal env vars.**", "### ", "## ", "**")
for _, m := range docEnvName.FindAllStringSubmatch(arch, -1) {
if _, ok := keys[m[1]]; !ok {
keys[m[1]] = "architecture.md § Configuration"
}
}
return keys
}
func TestEnvironmentReadIsDocumented(t *testing.T) {
doc := envDocumented(t)
var missing []string
for key := range envRead(t) {
if _, ok := doc[key]; !ok {
missing = append(missing, key)
}
}
slices.Sort(missing)
for _, key := range missing {
t.Errorf("%s is read but documented nowhere: guide.md § Environment variables for an operator's setting, architecture.md § Configuration for an internal one", key)
}
} }
func TestBuildScriptKeysDocumented(t *testing.T) { func TestEnvironmentDocumentedIsRead(t *testing.T) {
doc := documentedKeys() read := envRead(t)
for _, key := range buildScriptKeys { var stale []string
if !slices.Contains(doc, key) { for key, where := range envDocumented(t) {
t.Errorf("build scripts read %s but it is not listed in guide.md env documentation", key) if !read[key] {
stale = append(stale, key+" ("+where+")")
} }
} }
slices.Sort(stale)
for _, key := range stale {
t.Errorf("%s is documented but nothing reads it", key)
}
} }
func TestDocumentedKeysAreRead(t *testing.T) { // The parsers find what they are for: a key of each kind is among the read.
read := append([]string{}, loadConfigKeys...) func TestEnvironmentParsersFindKeys(t *testing.T) {
read = append(read, buildScriptKeys...) read := envRead(t)
slices.Sort(read) for _, key := range []string{"SELFPOST_HOSTNAME", "PANEL_HTTP_ADDR", "MILTER_WAIT_TIMEOUT", "POSTFIX_QUEUE_DIR"} {
read = slices.Compact(read) if !read[key] {
t.Errorf("%s not found among the keys read", key)
for _, key := range documentedKeys() {
if !slices.Contains(read, key) {
t.Errorf("guide.md documents %s but no code in loadConfig or build scripts reads it", key)
} }
} }
} }
+42 -15
View File
@@ -27,16 +27,14 @@ type mailStack struct {
Lists *inbound.Lists Lists *inbound.Lists
DMARC *dmarc.Service DMARC *dmarc.Service
pf *postfix.Postfix pf *postfix.Postfix
odk *domain.OpenDKIM
inboundEnabled bool inboundEnabled bool
dmarcEnabled bool dmarcEnabled bool
} }
func newMailStack(cfg config, st *store.Store) *mailStack { func newMailStack(cfg config, st *store.Store) *mailStack {
pf := postfix.New(cfg.postfixDir) pf := postfix.New(cfg.postfixDir)
odk := domain.NewOpenDKIM(cfg.opendkimDir)
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf) apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
domains := domain.NewService(st, odk, apps, cfg.dkimSelectorDef) domains := domain.NewService(st, domain.NewKeys(cfg.dkimDir), apps, cfg.dkimSelectorDef)
// The panel's notifications are sent as postmaster@ the server's own name, // The panel's notifications are sent as postmaster@ the server's own name,
// and signed as it. // and signed as it.
domains.SetServerName(cfg.hostname) domains.SetServerName(cfg.hostname)
@@ -45,15 +43,15 @@ func newMailStack(cfg config, st *store.Store) *mailStack {
return &mailStack{ return &mailStack{
Domains: domains, Apps: apps, Inbound: inb, DMARC: dmarcSvc, Domains: domains, Apps: apps, Inbound: inb, DMARC: dmarcSvc,
Lists: inbound.NewLists(st, cfg.antispamDir), Lists: inbound.NewLists(st, cfg.antispamDir),
pf: pf, odk: odk, inboundEnabled: cfg.inboundEnabled, dmarcEnabled: cfg.dmarcEnabled, pf: pf, inboundEnabled: cfg.inboundEnabled, dmarcEnabled: cfg.dmarcEnabled,
} }
} }
// Resync rebuilds OpenDKIM's tables and Postfix's sender map from SQLite and // Resync rebuilds the selector table and Postfix's maps from SQLite and
// reloads both daemons — the same work as the Status page's Reload button. // reloads Postfix — the same work as the Health page's Reload button.
func (m *mailStack) Resync() error { func (m *mailStack) Resync() error {
if err := m.Domains.Resync(); err != nil { if err := m.Domains.Resync(); err != nil {
return fmt.Errorf("opendkim resync: %w", err) return fmt.Errorf("dkim resync: %w", err)
} }
if err := m.Apps.Resync(); err != nil { if err := m.Apps.Resync(); err != nil {
return fmt.Errorf("postfix resync: %w", err) return fmt.Errorf("postfix resync: %w", err)
@@ -78,7 +76,6 @@ func (m *mailStack) Resync() error {
func (m *mailStack) skipReloadForTest() { func (m *mailStack) skipReloadForTest() {
m.pf.SetReloadHook(func() error { return nil }) m.pf.SetReloadHook(func() error { return nil })
m.odk.SetReloadHook(func() error { return nil })
} }
// resyncAfterRestore runs one mail-path Resync on the first boot after a // resyncAfterRestore runs one mail-path Resync on the first boot after a
@@ -99,7 +96,7 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
ms := newMailStack(cfg, st) ms := newMailStack(cfg, st)
// postfix-config.sh has already run (entrypoint). postconf -h is the // postfix-config.sh has already run (entrypoint). postconf -h is the
// effective config, including a manual override; the panel keeps this // effective config, including a manual override; the panel keeps this
// snapshot for the process lifetime (architecture.md). Postfix 3.7.11, // snapshot for the process lifetime (architecture.md). Postfix 3.10.13,
// postconf(1): without -d or -n it shows main.cf's value or the default, // postconf(1): without -d or -n it shows main.cf's value or the default,
// and -h drops the "name = " label; values are not $-expanded without -x. // and -h drops the "name = " label; values are not $-expanded without -x.
retryPolicy := postfix.LoadRetryPolicy() retryPolicy := postfix.LoadRetryPolicy()
@@ -115,9 +112,13 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
TrustedProxyCIDRs: cfg.trustedProxies, TrustedProxyCIDRs: cfg.trustedProxies,
TLSCertFile: cfg.tlsCertFile, TLSCertFile: cfg.tlsCertFile,
TLSServedCertFile: cfg.tlsServedCertFile, TLSServedCertFile: cfg.tlsServedCertFile,
SMTPBanner: cfg.smtpBanner,
TimeZone: cfg.timeZone,
TLSReloadInterval: cfg.tlsReloadInterval, TLSReloadInterval: cfg.tlsReloadInterval,
OpenDKIMSocket: cfg.opendkimSocket, SignSocket: cfg.signSocket,
SignLocalSocket: cfg.signLocalSocket,
JournalSocket: cfg.journalSocket, JournalSocket: cfg.journalSocket,
InboundJournalSocket: cfg.inboundJournalSocket,
SenderSocket: cfg.senderSocket, SenderSocket: cfg.senderSocket,
SessionIdleDays: cfg.sessionIdleDays, SessionIdleDays: cfg.sessionIdleDays,
DNSResolvers: cfg.dnsResolvers, DNSResolvers: cfg.dnsResolvers,
@@ -134,6 +135,26 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
}, cfg.setupTokenPath) }, cfg.setupTokenPath)
} }
// newHTTPServer bounds what a client may hold the panel's server for (audit
// 2026-10-12, L14): the request with its body - a form, or a domain export
// of 1 MiB at most - in 30 s; the answer in 60 s, the slowest page being one
// that asks DNS; an idle kept-alive connection 2 min. The full backup moves
// its own write deadline to two hours: it streams for as long as the archive
// takes (handlers.HandleBackup). net/http, Go 1.26: ReadTimeout covers the
// header and the body, WriteTimeout runs from the end of the header's read
// to the end of the response's write, IdleTimeout is the wait for the next
// request on a kept-alive connection.
func newHTTPServer(addr string, h http.Handler) *http.Server {
return &http.Server{
Addr: addr,
Handler: h,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 2 * time.Minute,
}
}
// serveHTTP runs the control-panel HTTP server until ctx is cancelled, using // serveHTTP runs the control-panel HTTP server until ctx is cancelled, using
// the database handle shared by all roles: setup, login and the authenticated // the database handle shared by all roles: setup, login and the authenticated
// panel surface (security.md). // panel surface (security.md).
@@ -146,14 +167,12 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
return err return err
} }
srv := &http.Server{ srv := newHTTPServer(cfg.httpAddr, srvApp.Handler())
Addr: cfg.httpAddr,
Handler: srvApp.Handler(),
ReadHeaderTimeout: 10 * time.Second,
}
// Shut the server down cleanly when the process is asked to stop. // Shut the server down cleanly when the process is asked to stop.
shutDown := make(chan struct{})
go func() { go func() {
defer close(shutDown)
<-ctx.Done() <-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
@@ -164,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) { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err 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 return nil
} }
+25
View File
@@ -0,0 +1,25 @@
package main
import (
"testing"
"time"
)
// The panel's server bounds every phase a client could hold it in (audit
// 2026-10-12, L14); before, only the header's read was.
func TestHTTPServerHasItsTimeouts(t *testing.T) {
s := newHTTPServer(":0", nil)
for name, d := range map[string]time.Duration{
"ReadHeaderTimeout": s.ReadHeaderTimeout,
"ReadTimeout": s.ReadTimeout,
"WriteTimeout": s.WriteTimeout,
"IdleTimeout": s.IdleTimeout,
} {
if d <= 0 {
t.Errorf("%s is %v", name, d)
}
}
if s.ReadTimeout < s.ReadHeaderTimeout || s.WriteTimeout < 30*time.Second {
t.Errorf("read %v (header %v), write %v: too short for a form or a DNS page", s.ReadTimeout, s.ReadHeaderTimeout, s.WriteTimeout)
}
}
+85 -40
View File
@@ -36,7 +36,7 @@ func main() {
log.SetPrefix("dmarc-ingest: ") log.SetPrefix("dmarc-ingest: ")
if err := runDMARCIngestMode(); err != nil { if err := runDMARCIngestMode(); err != nil {
log.Printf("ingest failed: %v", err) log.Printf("ingest failed: %v", err)
// EX_TEMPFAIL — ask Postfix to defer. Postfix 3.7.11, // EX_TEMPFAIL — ask Postfix to defer. Postfix 3.10.13,
// src/global/pipe_command.c, pipe_command(): a sysexits code maps // src/global/pipe_command.c, pipe_command(): a sysexits code maps
// to its DSN (src/global/sys_exits.c: 75 is "4.3.0") and a 4 defers, // to its DSN (src/global/sys_exits.c: 75 is "4.3.0") and a 4 defers,
// unless the output starts with a "D.S.N text" of its own - the // unless the output starts with a "D.S.N text" of its own - the
@@ -68,9 +68,12 @@ type config struct {
httpAddr string httpAddr string
journalSocket string journalSocket string
// The sender check's socket; the default mirrors build/postfix-config.sh. // The sender check's socket; the default mirrors build/postfix-config.sh.
senderSocket string senderSocket string
mailLog string // The DKIM signer's sockets: applications' mail and the panel's own
retentionDays int // (cmd/panel/signer.go).
signSocket, signLocalSocket string
mailLog string
retentionDays int
dataDir string dataDir string
dbPath string dbPath string
@@ -85,19 +88,23 @@ type config struct {
rateLimitMessagesPerIP int rateLimitMessagesPerIP int
rateLimitWindowSeconds int rateLimitWindowSeconds int
// Read-only inputs to the panel's status page: the certificate Postfix // Read-only input to the panel's status page: the certificate Postfix
// serves and the two milter sockets it connects to. The defaults mirror // serves. The default mirrors build/postfix-config.sh, so the status
// build/postfix-config.sh, so the status page checks exactly what Postfix // page checks exactly what Postfix was configured with.
// was configured with. tlsCertFile string
tlsCertFile string
opendkimSocket string
// tlsServedCertFile is the certificate Postfix serves (its copy of // tlsServedCertFile is the certificate Postfix serves (its copy of
// tlsCertFile), read from postconf at the start; tlsReloadInterval is // tlsCertFile), read from postconf at the start; tlsReloadInterval is
// how often build/postfix-cert-reload.sh looks for a renewal. // how often build/postfix-cert-reload.sh looks for a renewal.
tlsServedCertFile string tlsServedCertFile string
tlsReloadInterval time.Duration tlsReloadInterval time.Duration
// smtpBanner is Postfix's smtpd_banner, expanded, read from postconf at
// the start: Health shows the greeting it makes.
smtpBanner string
// timeZone is TZ as the container was started with ("" for none): the
// zone of every time and day the panel shows, which Settings names.
timeZone string
opendkimDir string dkimDir string
dkimSelectorDef string dkimSelectorDef string
saslDBPath string saslDBPath string
@@ -128,6 +135,10 @@ func loadConfig() config {
httpAddr: envDefault("PANEL_HTTP_ADDR", ":8080"), httpAddr: envDefault("PANEL_HTTP_ADDR", ":8080"),
journalSocket: envDefault("JOURNAL_MILTER_SOCKET", "/run/selfpost/journal.sock"), journalSocket: envDefault("JOURNAL_MILTER_SOCKET", "/run/selfpost/journal.sock"),
senderSocket: envDefault("SENDER_CHECK_SOCKET", "/run/selfpost/sender.sock"), senderSocket: envDefault("SENDER_CHECK_SOCKET", "/run/selfpost/sender.sock"),
// The DKIM signer's two sockets; the defaults must match
// build/postfix-config.sh and build/postfix-wrapper.sh.
signSocket: envDefault("DKIM_SIGN_SOCKET", "/run/selfpost/sign.sock"),
signLocalSocket: envDefault("DKIM_SIGN_LOCAL_SOCKET", "/run/selfpost/sign-local.sock"),
// Postfix's delivery log, under /data so the lines that resolve a // Postfix's delivery log, under /data so the lines that resolve a
// "queued" send-log row outlive the container. The default must match // "queued" send-log row outlive the container. The default must match
// maillog_file in build/postfix-config.sh. // maillog_file in build/postfix-config.sh.
@@ -142,6 +153,7 @@ func loadConfig() config {
manifestPath: filepath.Join(dataDir, backup.ManifestName), manifestPath: filepath.Join(dataDir, backup.ManifestName),
setupTokenPath: envDefault("SELFPOST_SETUP_TOKEN_FILE", filepath.Join(dataDir, "setup-token")), setupTokenPath: envDefault("SELFPOST_SETUP_TOKEN_FILE", filepath.Join(dataDir, "setup-token")),
hostname: os.Getenv("SELFPOST_HOSTNAME"), hostname: os.Getenv("SELFPOST_HOSTNAME"),
timeZone: os.Getenv("TZ"),
// Secure cookies by default (security.md); PANEL_COOKIE_SECURE=false is a // Secure cookies by default (security.md); PANEL_COOKIE_SECURE=false is a
// development-only escape hatch for testing over plain HTTP. // development-only escape hatch for testing over plain HTTP.
cookieSecure: envDefault("PANEL_COOKIE_SECURE", "true") != "false", cookieSecure: envDefault("PANEL_COOKIE_SECURE", "true") != "false",
@@ -167,11 +179,10 @@ func loadConfig() config {
tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"), tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
tlsReloadInterval: time.Duration(envInt("TLS_RELOAD_INTERVAL_SECONDS", 86400)) * time.Second, tlsReloadInterval: time.Duration(envInt("TLS_RELOAD_INTERVAL_SECONDS", 86400)) * time.Second,
opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
// Per-domain DKIM state (architecture.md § OpenDKIM). The directory layout // Per-domain DKIM state (architecture.md § DKIM signing): the keys and
// matches what entrypoint.sh prepares (setgid, shared `selfpost` group). // the selector table, the layout entrypoint.sh prepares.
opendkimDir: envDefault("OPENDKIM_DIR", filepath.Join(dataDir, "opendkim")), dkimDir: envDefault("DKIM_DIR", filepath.Join(dataDir, "dkim")),
dkimSelectorDef: envDefault("DKIM_SELECTOR_DEFAULT", "selfpost"), dkimSelectorDef: envDefault("DKIM_SELECTOR_DEFAULT", "selfpost"),
// Application SASL accounts and the Postfix sender map (architecture.md § // Application SASL accounts and the Postfix sender map (architecture.md §
@@ -213,7 +224,7 @@ func antispamAction(raw string) string {
// saslRealm chooses the realm new SASL accounts live under. It mirrors the // saslRealm chooses the realm new SASL accounts live under. It mirrors the
// hostname Postfix's SASL layer uses so a client authenticating with a bare // hostname Postfix's SASL layer uses so a client authenticating with a bare
// login resolves to the right account. Postfix 3.7.11, // login resolves to the right account. Postfix 3.10.13,
// src/xsasl/xsasl_cyrus_server.c, xsasl_cyrus_server_create(): serverFQDN is // src/xsasl/xsasl_cyrus_server.c, xsasl_cyrus_server_create(): serverFQDN is
// $myhostname (= SELFPOST_HOSTNAME, build/postfix-config.sh) and user_realm // $myhostname (= SELFPOST_HOSTNAME, build/postfix-config.sh) and user_realm
// NULL while smtpd_sasl_local_domain is empty; Cyrus SASL 2.1.28, // NULL while smtpd_sasl_local_domain is empty; Cyrus SASL 2.1.28,
@@ -265,10 +276,17 @@ func parseTrustedProxies(raw string) []*net.IPNet {
continue continue
} }
if !strings.Contains(cidr, "/") { if !strings.Contains(cidr, "/") {
if ip := net.ParseIP(cidr); ip != nil && ip.To4() != nil { // The prefix length goes with how the address is written: an
cidr += "/32" // IPv4-mapped address written as IPv6 (::ffff:10.0.0.1) is a
} else { // 128-bit address, and "/32" on it is ::/32 - every address
// whose first 32 bits are zero (::1 among them) trusted, and the
// host meant not at all (audit 2026-10-12, L4). Go 1.26
// net.IPNet.Contains compares an IPv4 client with the mapped
// /128 as with 10.0.0.1/32 (net/ip.go, networkNumberAndMask).
if strings.Contains(cidr, ":") {
cidr += "/128" cidr += "/128"
} else {
cidr += "/32"
} }
} }
_, n, err := net.ParseCIDR(cidr) _, n, err := net.ParseCIDR(cidr)
@@ -281,8 +299,46 @@ func parseTrustedProxies(raw string) []*net.IPNet {
return nets return nets
} }
// run starts the panel's three roles and blocks until a shutdown signal or the // openData opens the database the panel runs on. When a backup was unpacked
// first fatal error from any role. A signal triggers a clean stop of all // into /data, the restore guard comes first (architecture.md § Persistence):
// the manifest's version must match this binary and the archive must have
// been unpacked whole before the database is touched, so neither schema skew
// nor half an archive can corrupt the restored state. That first start then
// runs resync, and only after it the manifest is consumed: a start that fails
// on the way leaves it, and the next start is a restore again (audit
// 2026-10-12, M1). Without a manifest it is an ordinary start.
func openData(cfg config, resync func(*store.Store) error) (st *store.Store, restored bool, err error) {
restored, err = backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
if err != nil {
return nil, false, err
}
st, err = store.Open(cfg.dbPath)
if err != nil {
return nil, false, err
}
if restored {
log.Printf("restore manifest accepted; regenerating mail-path maps from SQLite")
// 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
}
if err := backup.ConsumeRestore(cfg.manifestPath); err != nil {
_ = st.Close()
return nil, false, err
}
}
return st, restored, nil
}
// 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 // roles; a role error cancels the others and is returned so the process exits
// non-zero (letting supervisord/Docker see the failure — architecture.md § // non-zero (letting supervisord/Docker see the failure — architecture.md §
// Image and processes). // Image and processes).
@@ -291,37 +347,25 @@ func run() error {
// postfix-config.sh has written main.cf before supervisord started the // postfix-config.sh has written main.cf before supervisord started the
// panel (build/entrypoint.sh), and nothing rewrites it after. // panel (build/entrypoint.sh), and nothing rewrites it after.
cfg.tlsServedCertFile = health.ServedCertPath() cfg.tlsServedCertFile = health.ServedCertPath()
cfg.smtpBanner = health.SMTPBanner()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop() defer stop()
log.Printf("starting selfpost panel %s", buildinfo.Version) log.Printf("starting selfpost panel %s", buildinfo.Version)
// Restore version guard (architecture.md § Persistence): if a backup was
// extracted into /data, its manifest version must match this binary before we
// touch the database, so schema/format skew between versions cannot corrupt
// the restored state. A match consumes the manifest; its absence is the
// normal (non-restore) case.
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
if err != nil {
return err
}
// One database handle shared by every role. The store serialises writes // One database handle shared by every role. The store serialises writes
// (MaxOpenConns(1)), so the HTTP panel, the journal-milter and the tailer // (MaxOpenConns(1)), so the HTTP panel, the journal-milter and the tailer
// can all use it without stepping on each other under WAL. // can all use it without stepping on each other under WAL.
st, err := store.Open(cfg.dbPath) st, restored, err := openData(cfg, func(st *store.Store) error {
return resyncAfterRestore(cfg, st, false)
})
if err != nil { if err != nil {
return err return err
} }
defer st.Close() defer st.Close()
if restored { if !restored && cfg.dmarcEnabled {
log.Printf("restore manifest accepted; regenerating mail-path maps from SQLite")
if err := resyncAfterRestore(cfg, st, false); err != nil {
return err
}
} else if cfg.dmarcEnabled {
ms := newMailStack(cfg, st) ms := newMailStack(cfg, st)
ms.pf.SetReloadHook(func() error { return nil }) ms.pf.SetReloadHook(func() error { return nil })
if err := ms.DMARC.Resync(); err != nil { if err := ms.DMARC.Resync(); err != nil {
@@ -367,8 +411,8 @@ func run() error {
} }
var wg sync.WaitGroup var wg sync.WaitGroup
// Room for every role to report a failure without blocking: seven at most. // Room for every role to report a failure without blocking: eight at most.
errc := make(chan error, 7) errc := make(chan error, 8)
roles := []struct { roles := []struct {
name string name string
@@ -377,6 +421,7 @@ func run() error {
{"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg, st) }}, {"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg, st) }},
{"journal-milter", func(ctx context.Context) error { return serveJournal(ctx, cfg, st) }}, {"journal-milter", func(ctx context.Context) error { return serveJournal(ctx, cfg, st) }},
{"sender-check", func(ctx context.Context) error { return serveSenderCheck(ctx, cfg, st) }}, {"sender-check", func(ctx context.Context) error { return serveSenderCheck(ctx, cfg, st) }},
{"dkim-signer", func(ctx context.Context) error { return serveSigner(ctx, cfg, st) }},
{"log-tailer", func(ctx context.Context) error { {"log-tailer", func(ctx context.Context) error {
return logtail.Run(ctx, cfg.mailLog, st, func() int { return logtail.Run(ctx, cfg.mailLog, st, func() int {
days, err := st.GetSendLogRetentionDays(cfg.retentionDays) days, err := st.GetSendLogRetentionDays(cfg.retentionDays)
-15
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"log"
"time" "time"
"github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/dnscheck"
@@ -21,20 +20,6 @@ func runNotifier(ctx context.Context, cfg config, st *store.Store) error {
ms := newMailStack(cfg, st) ms := newMailStack(cfg, st)
dns := dnscheck.New(cfg.dnsResolvers) dns := dnscheck.New(cfg.dnsResolvers)
// OpenDKIM's tables are rebuilt when a domain is added or deleted. On the
// first start of a version that signs the server's own name they do not
// have its line yet. A failure here costs the signature, not the message:
// OpenDKIM 2.11.0~beta2, opendkim/opendkim.c, mlfi_eoh(): with no
// SigningTable match and Mode s (build/opendkim.conf) the message is
// accepted unsigned. A line whose key the KeyTable lacks or that does not
// load is another matter - dkimf_apply_signtable() < 0 there answers
// SMFIS_TEMPFAIL (assumptions A-47).
if rebuilt, err := ms.Domains.EnsureServerSigned(); err != nil {
log.Printf("notifier: sign as %s: %v", cfg.hostname, err)
} else if rebuilt {
log.Printf("notifier: OpenDKIM now signs mail of %s", cfg.hostname)
}
n := notify.New(st, notify.Checks{ n := notify.New(st, notify.Checks{
Domain: func(d store.Domain) (dnscheck.Domain, error) { Domain: func(d store.Domain) (dnscheck.Domain, error) {
rec, err := ms.Domains.DKIMRecord(d) rec, err := ms.Domains.DKIMRecord(d)
+36
View File
@@ -0,0 +1,36 @@
package main
import (
"net"
"testing"
)
// A bare address is one host however it is written; an IPv4-mapped one in
// IPv6 notation used to become ::/32 (audit 2026-10-12, L4).
func TestParseTrustedProxiesTakesABareAddressAsOneHost(t *testing.T) {
for _, c := range []struct {
entry string
trusts []string
refuses []string
}{
{"10.0.0.1", []string{"10.0.0.1"}, []string{"10.0.0.2"}},
{"::ffff:10.0.0.1", []string{"10.0.0.1", "::ffff:10.0.0.1"}, []string{"10.0.0.2", "::1", "0:0:1::1"}},
{"2001:db8::1", []string{"2001:db8::1"}, []string{"2001:db8::2"}},
{"10.0.0.0/8", []string{"10.1.2.3"}, []string{"11.0.0.1"}},
} {
nets := parseTrustedProxies(c.entry)
if len(nets) != 1 {
t.Fatalf("%q parsed as %v", c.entry, nets)
}
for _, ip := range c.trusts {
if !nets[0].Contains(net.ParseIP(ip)) {
t.Errorf("%q (%v) does not trust %s", c.entry, nets[0], ip)
}
}
for _, ip := range c.refuses {
if nets[0].Contains(net.ParseIP(ip)) {
t.Errorf("%q (%v) trusts %s", c.entry, nets[0], ip)
}
}
}
}
+184
View File
@@ -0,0 +1,184 @@
package main
import (
"archive/tar"
"bytes"
"compress/gzip"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/mixeme/selfpost/internal/backup"
"github.com/mixeme/selfpost/internal/buildinfo"
"github.com/mixeme/selfpost/internal/store"
)
// A restore that goes wrong half way must leave the operator where they
// started: the archive is refused before the database is opened, and a start
// that fails after the guard is a restore again the next time (audit
// 2026-10-12, M1 and stage 3).
// A start that fails after the guard - here its Resync - leaves the manifest,
// so the next start is a restore again and runs the Resync the failed one did
// not finish. The failed start had the database open, so its files are no
// longer the archive's; the next start does not compare them again.
func TestARestoreStartThatFailsIsARestoreAgain(t *testing.T) {
target := t.TempDir()
extract(t, archiveOf(t, seedPanelProject(t)), target)
cfg := panelConfig(t, target)
broken := errors.New("postfix reload failed")
if _, _, err := openData(cfg, func(*store.Store) error { return broken }); !errors.Is(err, broken) {
t.Fatalf("a start whose Resync failed = %v, want that failure", err)
}
if _, err := os.Stat(cfg.manifestPath); err != nil {
t.Fatalf("the failed start consumed the manifest: %v", err)
}
mapPath := filepath.Join(cfg.dataDir, "postfix", "sender_login_maps")
if err := os.WriteFile(mapPath, []byte("stale map\n"), 0o640); err != nil {
t.Fatalf("write stale map: %v", err)
}
st, restored, err := openData(cfg, testResync(cfg))
if err != nil {
t.Fatalf("the start after a failed one: %v", err)
}
st.Close()
if !restored {
t.Fatal("the start after a failed one did not take the data directory for a restore")
}
if got, _ := os.ReadFile(mapPath); string(got) != "@"+restoreDomain+" shop\n" {
t.Errorf("sender map = %q: the second start did not run the Resync", got)
}
for _, name := range []string{backup.ManifestName, backup.ContentsName, "contents.checked.json"} {
if _, err := os.Stat(filepath.Join(cfg.dataDir, name)); !os.IsNotExist(err) {
t.Errorf("%s is still there after the restore start succeeded (stat err = %v)", name, err)
}
}
st, restored, err = openData(cfg, func(*store.Store) error {
t.Error("an ordinary start ran the restore Resync")
return nil
})
if err != nil {
t.Fatalf("the ordinary start after the restore: %v", err)
}
st.Close()
if restored {
t.Error("the start after a finished restore took it for a restore again")
}
}
// An archive unpacked in part - a download cut short, a tar stopped by a
// damaged block - is refused before the database is opened, with a message
// that says so, and nothing in the directory is changed: the database the
// archive left is as it left it, and the manifest is there for the next try.
func TestAnArchiveUnpackedInPartIsRefused(t *testing.T) {
archive := archiveOf(t, seedPanelProject(t))
dbSize := entrySize(t, archive, backup.DataArchivePrefix+"selfpost.db")
for _, c := range []struct {
name string
cut string
at int64
}{
{"in the database", backup.DataArchivePrefix + "selfpost.db", dbSize / 2},
{"before the deploy files", backup.ComposeFileName, 0},
{"in its last entry", backup.DataArchivePrefix + backup.ContentsName, 10},
} {
t.Run(c.name, func(t *testing.T) {
target := t.TempDir()
extractCut(t, archive, target, c.cut, c.at)
cfg := panelConfig(t, target)
before, err := os.ReadFile(cfg.dbPath)
if err != nil {
t.Fatalf("the cut archive left no database: %v", err)
}
_, _, err = openData(cfg, func(*store.Store) error {
t.Error("a refused restore ran its Resync")
return nil
})
if err == nil || !strings.Contains(err.Error(), "not unpacked whole") {
t.Fatalf("the start on an archive cut %s = %v, want it refused as unpacked in part", c.name, err)
}
if after, _ := os.ReadFile(cfg.dbPath); !bytes.Equal(before, after) {
t.Error("the refused start changed the database")
}
for _, side := range []string{"-wal", "-shm", "-journal"} {
if _, err := os.Stat(cfg.dbPath + side); !os.IsNotExist(err) {
t.Errorf("selfpost.db%s exists: the refused start opened the database", side)
}
}
if _, err := os.Stat(cfg.manifestPath); err != nil {
t.Errorf("the refused start removed the manifest: %v", err)
}
})
}
}
// A file the archive holds and the data directory holds otherwise - damaged
// on the way, or unpacked from another archive - is refused by its name.
func TestADamagedRestoredFileIsRefusedByName(t *testing.T) {
target := t.TempDir()
extract(t, archiveOf(t, seedPanelProject(t)), target)
cfg := panelConfig(t, target)
key := filepath.Join(cfg.dataDir, "dkim", "keys", restoreDomain, "selfpost.private")
f, err := os.OpenFile(key, os.O_WRONLY|os.O_APPEND, 0)
if err != nil {
t.Fatalf("open key: %v", err)
}
f.WriteString("x")
f.Close()
_, _, err = openData(cfg, func(*store.Store) error {
t.Error("a refused restore ran its Resync")
return nil
})
want := "data/dkim/keys/" + restoreDomain + "/selfpost.private"
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("the start on a damaged key = %v, want a refusal naming %s", err, want)
}
}
// archiveOf takes a full backup of a seeded project for this binary's version.
func archiveOf(t *testing.T, deployRoot string) []byte {
t.Helper()
dataDir := filepath.Join(deployRoot, "data")
var archive bytes.Buffer
if err := backup.Create(&archive, backup.Params{
DataDir: dataDir,
DBPath: filepath.Join(dataDir, "selfpost.db"),
Version: buildinfo.Version,
DeployRoot: deployRoot,
}); err != nil {
t.Fatalf("create backup: %v", err)
}
return archive.Bytes()
}
// entrySize is the size of the archive's entry name.
func entrySize(t *testing.T, archive []byte, name string) int64 {
t.Helper()
gz, err := gzip.NewReader(bytes.NewReader(archive))
if err != nil {
t.Fatalf("gzip: %v", err)
}
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
t.Fatalf("the archive has no %s", name)
}
if err != nil {
t.Fatalf("read the archive: %v", err)
}
if hdr.Name == name {
return hdr.Size
}
}
}
+60 -36
View File
@@ -27,7 +27,8 @@ import (
// below stubs that story out — the archive is downloaded from a running panel // below stubs that story out — the archive is downloaded from a running panel
// through /backup, unpacked the way `tar -xzf` unpacks it, and a second panel // through /backup, unpacked the way `tar -xzf` unpacks it, and a second panel
// is started on the result through the same startup sequence run() uses: // is started on the result through the same startup sequence run() uses:
// CheckRestore, store.Open, newPanel, Start. // openData (CheckRestore, store.Open, Resync, ConsumeRestore), newPanel,
// Start.
const ( const (
restorePassword = "correct-horse-battery" restorePassword = "correct-horse-battery"
@@ -84,9 +85,9 @@ func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
// The archive carries the daemon files, and the first boot after restore // The archive carries the daemon files, and the first boot after restore
// re-derives the maps from SQLite so they stay aligned with the database. // re-derives the maps from SQLite so they stay aligned with the database.
for path, want := range map[string]string{ for path, want := range map[string]string{
filepath.Join("opendkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY", filepath.Join("dkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
filepath.Join("sasl", "sasldb2"): "SASLDB", filepath.Join("sasl", "sasldb2"): "SASLDB",
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n", filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
} { } {
got, err := os.ReadFile(filepath.Join(r.dataDir, path)) got, err := os.ReadFile(filepath.Join(r.dataDir, path))
if err != nil { if err != nil {
@@ -171,22 +172,17 @@ func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) {
t.Fatalf("write manifest: %v", err) t.Fatalf("write manifest: %v", err)
} }
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version) // A record of no files: this test is about the maps, and the files it
if err != nil { // seeded were never in an archive.
t.Fatalf("CheckRestore: %v", err) writeContents(t, dataDir, backup.Contents{Format: backup.FormatContents})
}
if !restored {
t.Fatal("CheckRestore did not report a restore")
}
st, err := store.Open(cfg.dbPath) st, restored, err := openData(cfg, testResync(cfg))
if err != nil { if err != nil {
t.Fatalf("open store: %v", err) t.Fatalf("open the restored data: %v", err)
} }
defer st.Close() defer st.Close()
if !restored {
if err := resyncAfterRestore(cfg, st, true); err != nil { t.Fatal("the start did not take the data directory for a restore")
t.Fatalf("resync after restore: %v", err)
} }
got, err := os.ReadFile(mapPath) got, err := os.ReadFile(mapPath)
@@ -272,10 +268,10 @@ func seedPanelProject(t *testing.T) string {
} }
for path, content := range map[string]string{ for path, content := range map[string]string{
filepath.Join("opendkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY", filepath.Join("dkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
filepath.Join("sasl", "sasldb2"): "SASLDB", filepath.Join("sasl", "sasldb2"): "SASLDB",
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n", filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
filepath.Join("log", "mail.log"): "postfix/smtp[1]: 4A1B2C3D: status=sent", filepath.Join("log", "mail.log"): "postfix/smtp[1]: 4A1B2C3D: status=sent",
} { } {
full := filepath.Join(dataDir, path) full := filepath.Join(dataDir, path)
if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil { if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil {
@@ -326,28 +322,17 @@ func bootPanel(t *testing.T, deployRoot string) http.Handler {
t.Helper() t.Helper()
cfg := panelConfig(t, deployRoot) cfg := panelConfig(t, deployRoot)
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version) st, restored, err := openData(cfg, testResync(cfg))
if err != nil { if err != nil {
t.Fatalf("the panel refused to start on %s: %v", deployRoot, err) t.Fatalf("the panel refused to start on %s: %v", deployRoot, err)
} }
t.Cleanup(func() { _ = st.Close() })
if restored { if restored {
if _, err := os.Stat(cfg.manifestPath); err == nil { if _, err := os.Stat(cfg.manifestPath); err == nil {
t.Errorf("the restore manifest was not consumed, so the next start is gated by it too") t.Errorf("the restore manifest was not consumed, so the next start is gated by it too")
} }
} }
st, err := store.Open(cfg.dbPath)
if err != nil {
t.Fatalf("open the restored database: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
if restored {
if err := resyncAfterRestore(cfg, st, true); err != nil {
t.Fatalf("resync after restore: %v", err)
}
}
panel, err := newPanel(cfg, st) panel, err := newPanel(cfg, st)
if err != nil { if err != nil {
t.Fatalf("build the panel: %v", err) t.Fatalf("build the panel: %v", err)
@@ -375,10 +360,11 @@ func signIn(t *testing.T, h http.Handler) *http.Cookie {
// downloadBackup takes a backup through the panel's own /backup route, the way // downloadBackup takes a backup through the panel's own /backup route, the way
// the operator does. An empty password downloads the plain archive; otherwise // the operator does. An empty password downloads the plain archive; otherwise
// the response is a .spbk envelope, which is decrypted here. // the response is a .spbk envelope, which is decrypted here. The account
// password goes with it, as the form asks for it (backup-reauth).
func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password string) []byte { func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password string) []byte {
t.Helper() t.Helper()
form := url.Values{} form := url.Values{"account_password": {restorePassword}}
if password != "" { if password != "" {
form.Set("encrypt", "1") form.Set("encrypt", "1")
form.Set("password", password) form.Set("password", password)
@@ -417,6 +403,15 @@ func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password
// extract unpacks a backup archive into dir, as `tar -xzf` does onto the project // extract unpacks a backup archive into dir, as `tar -xzf` does onto the project
// directory before the image is started. // directory before the image is started.
func extract(t *testing.T, archive []byte, dir string) { func extract(t *testing.T, archive []byte, dir string) {
t.Helper()
extractCut(t, archive, dir, "", 0)
}
// extractCut unpacks the archive as extract does, except that the stream ends
// cutAt bytes into the entry named cut: that entry is written as far as the
// stream went and nothing after it is, which is what tar leaves of a download
// cut short or a damaged block. An empty cut unpacks the whole archive.
func extractCut(t *testing.T, archive []byte, dir, cut string, cutAt int64) {
t.Helper() t.Helper()
gz, err := gzip.NewReader(bytes.NewReader(archive)) gz, err := gzip.NewReader(bytes.NewReader(archive))
if err != nil { if err != nil {
@@ -426,6 +421,9 @@ func extract(t *testing.T, archive []byte, dir string) {
for { for {
hdr, err := tr.Next() hdr, err := tr.Next()
if err == io.EOF { if err == io.EOF {
if cut != "" {
t.Fatalf("the archive has no entry %s to cut", cut)
}
break break
} }
if err != nil { if err != nil {
@@ -436,6 +434,10 @@ func extract(t *testing.T, archive []byte, dir string) {
t.Fatalf("the archive escapes the directory it is extracted into: %q", hdr.Name) t.Fatalf("the archive escapes the directory it is extracted into: %q", hdr.Name)
} }
path := filepath.Join(dir, name) path := filepath.Join(dir, name)
var body io.Reader = tr
if hdr.Name == cut {
body = io.LimitReader(tr, cutAt)
}
switch hdr.Typeflag { switch hdr.Typeflag {
case tar.TypeDir: case tar.TypeDir:
@@ -450,7 +452,7 @@ func extract(t *testing.T, archive []byte, dir string) {
if err != nil { if err != nil {
t.Fatalf("create %s: %v", path, err) t.Fatalf("create %s: %v", path, err)
} }
if _, err := io.Copy(f, tr); err != nil { if _, err := io.Copy(f, body); err != nil {
f.Close() f.Close()
t.Fatalf("write %s: %v", path, err) t.Fatalf("write %s: %v", path, err)
} }
@@ -458,6 +460,28 @@ func extract(t *testing.T, archive []byte, dir string) {
t.Fatalf("close %s: %v", path, err) t.Fatalf("close %s: %v", path, err)
} }
} }
if hdr.Name == cut {
return
}
}
}
// testResync is the Resync a restore start runs, without reloading daemons
// the test does not have.
func testResync(cfg config) func(*store.Store) error {
return func(st *store.Store) error { return resyncAfterRestore(cfg, st, true) }
}
// writeContents writes the record of an archive's files into dataDir, as the
// archive's last entry would have.
func writeContents(t *testing.T, dataDir string, c backup.Contents) {
t.Helper()
body, err := json.Marshal(c)
if err != nil {
t.Fatalf("marshal contents: %v", err)
}
if err := os.WriteFile(filepath.Join(dataDir, backup.ContentsName), body, 0o600); err != nil {
t.Fatalf("write contents: %v", err)
} }
} }
+11 -12
View File
@@ -6,7 +6,6 @@ import (
"os" "os"
"time" "time"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/health" "github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/milter" "github.com/mixeme/selfpost/internal/milter"
"github.com/mixeme/selfpost/internal/postfix" "github.com/mixeme/selfpost/internal/postfix"
@@ -20,12 +19,12 @@ const aliveEvery = 2 * time.Second
// serveSenderCheck runs the sender check (internal/milter/sender.go) on a // serveSenderCheck runs the sender check (internal/milter/sender.go) on a
// socket of its own. Postfix attaches that socket strictly // socket of its own. Postfix attaches that socket strictly
// (default_action=tempfail in build/postfix-config.sh; Postfix 3.7.11, // (default_action=tempfail in build/postfix-config.sh; Postfix 3.10.13,
// src/milter/milter8.c, milter8_comm_error(): "451 4.7.1 Service unavailable - // src/milter/milter8.c, milter8_comm_error(): "451 4.7.1 Service unavailable -
// try again later"), so while this role is not serving, mail is deferred: // try again later"), so while this role is not serving, mail is deferred:
// what that costs is counted here and // what that costs is counted here and
// by the log tailer, and shown on Server > Health // by the log tailer, and shown on Server > Health
// (docs/plans/from-header-binding.md § Statistics). // (docs/architecture.md § Sender check).
func serveSenderCheck(ctx context.Context, cfg config, st *store.Store) error { func serveSenderCheck(ctx context.Context, cfg config, st *store.Store) error {
ln, err := listenMilter(cfg.senderSocket) ln, err := listenMilter(cfg.senderSocket)
if err != nil { if err != nil {
@@ -64,11 +63,9 @@ func serveSenderCheck(ctx context.Context, cfg config, st *store.Store) error {
} }
}() }()
// The two tables the mail path itself goes by: whom Postfix lets send as // The table the mail path itself goes by: whom Postfix lets send as what.
// what, and what OpenDKIM signs.
bind := milter.NewBindings(postfix.New(cfg.postfixDir).SenderLoginMapsPath()) bind := milter.NewBindings(postfix.New(cfg.postfixDir).SenderLoginMapsPath())
signers := milter.NewSigners(domain.NewOpenDKIM(cfg.opendkimDir).SigningTablePath()) return milter.ServeSender(ctx, ln, bind, st)
return milter.ServeSender(ctx, ln, bind, signers, st)
} }
// postfixState is what supervisord said about Postfix: since when it runs, if // postfixState is what supervisord said about Postfix: since when it runs, if
@@ -107,19 +104,21 @@ func senderCheckDowntime(markAt, now time.Time, ask func() postfixState) (down t
return 0, false return 0, false
} }
// dayPart is the part of a stretch of downtime that fell on one UTC day. // dayPart is the part of a stretch of downtime that fell on one day of the
// container's zone.
type dayPart struct { type dayPart struct {
day time.Time day time.Time
down time.Duration down time.Duration
} }
// byDay cuts the time from 'from' to 'to' at the UTC midnights between them, // byDay cuts the time from 'from' to 'to' at the midnights of the container's
// so that a night's outage is not all booked on the morning it ended. // zone between them, so that a night's outage is not all booked on the morning
// it ended.
func byDay(from, to time.Time) []dayPart { func byDay(from, to time.Time) []dayPart {
from, to = from.UTC(), to.UTC() from, to = from.Local(), to.Local()
var parts []dayPart var parts []dayPart
for from.Before(to) { for from.Before(to) {
next := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, 1) next := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, time.Local).AddDate(0, 0, 1)
if next.After(to) { if next.After(to) {
next = to next = to
} }
+12
View File
@@ -62,3 +62,15 @@ func TestByDay(t *testing.T) {
t.Errorf("no time: %+v", none) t.Errorf("no time: %+v", none)
} }
} }
// In a container with TZ=Europe/Moscow a day is Moscow's: an outage from
// 20:50 to 21:10 UTC crosses its midnight (21:00 UTC).
func TestByDayInTheContainersZone(t *testing.T) {
inZone(t, "Europe/Moscow")
from := time.Date(2026, 10, 12, 20, 50, 0, 0, time.UTC)
got := byDay(from, from.Add(20*time.Minute))
if len(got) != 2 || got[0].down != 10*time.Minute || got[1].down != 10*time.Minute ||
got[1].day.Format("2006-01-02 15:04") != "2026-10-13 00:00" {
t.Errorf("parts = %+v, want ten minutes on the 12th and ten from Moscow's midnight", got)
}
}
+82
View File
@@ -0,0 +1,82 @@
package main
import (
"context"
"log"
"path/filepath"
"strings"
"time"
"github.com/mixeme/selfpost/internal/milter"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
)
// rewriteRetry is how often the signer tries again to write the selector
// table when the write at its start failed.
const rewriteRetry = 10 * time.Second
// serveSigner runs the DKIM signer (internal/milter/sign.go) on its two
// sockets: signSocket for what applications send on 465/587, signed with the
// key of the domain the login belongs to - the sender_login_maps Postfix and
// the sender check read (Bindings.DomainOf); signLocalSocket for what the
// panel itself submits with sendmail(1), signed with the key of the server's
// name. Postfix attaches both strictly (default_action=tempfail,
// build/postfix-config.sh), so while this role is not serving, mail waits.
//
// The keys and selectors come from the key tree and the selector table under
// dkimDir (internal/domain, Keys). Before it serves, the table is rewritten
// from the registry (WriteSelectors): Postfix waits for these sockets
// (build/postfix-wrapper.sh), so no message meets a table from before. A
// rewrite that fails is tried again every rewriteRetry until it succeeds;
// meanwhile what the signer finds no key for it defers, and the panel's
// pages - Reload configuration among them - stay up.
func serveSigner(ctx context.Context, cfg config, st *store.Store) error {
domains := newMailStack(cfg, st).Domains
if err := domains.WriteSelectors(); err != nil {
log.Printf("dkim-signer: the selector table: %v - trying again every %s", err, rewriteRetry)
go func() {
tick := time.NewTicker(rewriteRetry)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return
case <-tick.C:
if err := domains.WriteSelectors(); err != nil {
log.Printf("dkim-signer: the selector table: %v", err)
continue
}
log.Printf("dkim-signer: the selector table is written")
return
}
}
}()
}
keys := milter.NewSignKeys(filepath.Join(cfg.dkimDir, "selectors"), filepath.Join(cfg.dkimDir, "keys"))
bind := milter.NewBindings(postfix.New(cfg.postfixDir).SenderLoginMapsPath())
server := strings.ToLower(cfg.hostname)
apps, err := listenMilter(cfg.signSocket)
if err != nil {
return err
}
local, err := listenMilter(cfg.signLocalSocket)
if err != nil {
apps.Close()
return err
}
log.Printf("dkim-signer listening on %s (applications) and %s (the panel's own mail, as %s)", cfg.signSocket, cfg.signLocalSocket, server)
errc := make(chan error, 2)
go func() { errc <- milter.ServeSign(ctx, apps, keys, bind.DomainOf) }()
go func() {
errc <- milter.ServeSign(ctx, local, keys, func(string) string { return server })
}()
// The first to stop decides: an error stops the panel (run()), and on a
// cancelled ctx both return nil.
if err := <-errc; err != nil {
return err
}
return <-errc
}
+28
View File
@@ -0,0 +1,28 @@
package main
import (
"os"
"testing"
"time"
)
// TestMain runs the tests in UTC, whatever the zone of the machine they run
// on: the panel shows times and counts days in time.Local, the container's
// zone (TZ), and the fixtures are written in UTC. The tests of another zone
// set it themselves (inZone).
func TestMain(m *testing.M) {
time.Local = time.UTC
os.Exit(m.Run())
}
// inZone runs the rest of the test in the zone name, as a container started
// with TZ=name; the test must not be parallel.
func inZone(t *testing.T, name string) {
t.Helper()
loc, err := time.LoadLocation(name)
if err != nil {
t.Skipf("no zone %s here: %v", name, err)
}
time.Local = loc
t.Cleanup(func() { time.Local = time.UTC })
}
+83 -13
View File
@@ -21,16 +21,21 @@
package main package main
import ( import (
"bufio"
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"unicode/utf8"
"github.com/mixeme/selfpost/internal/atomicfile"
"github.com/mixeme/selfpost/internal/backup" "github.com/mixeme/selfpost/internal/backup"
"github.com/mixeme/selfpost/internal/buildinfo" "github.com/mixeme/selfpost/internal/buildinfo"
"github.com/mixeme/selfpost/internal/secretfile" "github.com/mixeme/selfpost/internal/secretfile"
"github.com/mixeme/selfpost/internal/web/validate"
) )
// passwordEnv names the environment variable holding the encryption password. // passwordEnv names the environment variable holding the encryption password.
@@ -70,13 +75,20 @@ func run(outPath, password string) error {
dataDir := envDefault("SELFPOST_DATA_DIR", "/data") dataDir := envDefault("SELFPOST_DATA_DIR", "/data")
dbPath := envDefault("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db")) dbPath := envDefault("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db"))
w, closeOut, err := openOutput(outPath) // The floor the panel holds the same files to: an encrypted backup is
// attacked offline, at leisure (security.md; audit 2026-10-12, M5). An
// empty password is no encryption, by design.
if password != "" && utf8.RuneCountInString(password) < validate.MinSecretFilePasswordLen {
return fmt.Errorf("the encryption password must be at least %d characters", validate.MinSecretFilePasswordLen)
}
w, err := openOutput(outPath)
if err != nil { if err != nil {
return err return err
} }
defer closeOut() defer w.abort()
sink := w var sink io.Writer = w
var env *secretfile.Writer var env *secretfile.Writer
if password != "" { if password != "" {
env, err = secretfile.NewWriter(w, secretfile.TypeFullBackup, password) env, err = secretfile.NewWriter(w, secretfile.TypeFullBackup, password)
@@ -102,6 +114,9 @@ func run(outPath, password string) error {
return err return err
} }
} }
if err := w.commit(); err != nil {
return err
}
if outPath != "" { if outPath != "" {
kind := "plain" kind := "plain"
if password != "" { if password != "" {
@@ -133,17 +148,28 @@ func runDecrypt(inPath, outPath, password string) error {
if err != nil { if err != nil {
return err return err
} }
// The first chunk is opened before anything is written: a wrong password
// is told before the output is touched, and the envelope's type is
// authenticated only once a chunk opens (secretfile.Reader.Type; audit
// 2026-10-12, L1).
plain := bufio.NewReaderSize(env, 64<<10)
if _, err := plain.Peek(1); err != nil && !errors.Is(err, io.EOF) {
return err
}
if env.Type() != secretfile.TypeFullBackup { if env.Type() != secretfile.TypeFullBackup {
return fmt.Errorf("that file is a %s, not a full backup", env.Type()) return fmt.Errorf("that file is a %s, not a full backup", env.Type())
} }
w, closeOut, err := openOutput(outPath) w, err := openOutput(outPath)
if err != nil { if err != nil {
return err return err
} }
defer closeOut() defer w.abort()
if _, err := io.Copy(w, env); err != nil { if _, err := io.Copy(w, plain); err != nil {
return err
}
if err := w.commit(); err != nil {
return err return err
} }
if outPath != "" { if outPath != "" {
@@ -152,17 +178,61 @@ func runDecrypt(inPath, outPath, password string) error {
return nil return nil
} }
// openOutput returns stdout, or a freshly created owner-only file: both plain // output is where the CLI writes: stdout, or a file that takes its name only
// and encrypted backups are secret enough to keep off other users' eyes. // once it is whole. A backup or a decryption that fails half way - a wrong
func openOutput(outPath string) (io.Writer, func(), error) { // password, an envelope cut short - leaves whatever had the name before, and
// no half-written file under it (audit 2026-10-12, L1 and stage 3). The file
// is the owner's alone (os.CreateTemp makes it 0600): plain and encrypted
// backups are secret enough to keep off other users' eyes.
type output struct {
io.Writer
tmp *os.File // nil for stdout
path string
}
func openOutput(outPath string) (*output, error) {
if outPath == "" { if outPath == "" {
return os.Stdout, func() {}, nil return &output{Writer: os.Stdout}, nil
} }
f, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) f, err := os.CreateTemp(filepath.Dir(outPath), "."+filepath.Base(outPath)+".tmp-*")
if err != nil { if err != nil {
return nil, nil, err return nil, err
} }
return f, func() { _ = f.Close() }, nil return &output{Writer: f, tmp: f, path: outPath}, nil
}
// commit puts the whole file under its name.
func (o *output) commit() error {
if o.tmp == nil {
return nil
}
f := o.tmp
o.tmp = nil
if err := f.Sync(); err != nil {
f.Close()
os.Remove(f.Name())
return err
}
if err := f.Close(); err != nil {
os.Remove(f.Name())
return err
}
if err := os.Rename(f.Name(), o.path); err != nil {
os.Remove(f.Name())
return err
}
_ = atomicfile.SyncDir(filepath.Dir(o.path))
return nil
}
// abort removes a file that was not committed; after commit it does nothing.
func (o *output) abort() {
if o.tmp == nil {
return
}
o.tmp.Close()
os.Remove(o.tmp.Name())
o.tmp = nil
} }
// readPassword takes the password from the given file (first line) or, when no // readPassword takes the password from the given file (first line) or, when no
+73
View File
@@ -162,3 +162,76 @@ func TestReadPassword(t *testing.T) {
t.Error("-decrypt without a password was accepted") t.Error("-decrypt without a password was accepted")
} }
} }
// An encrypted backup is held to the panel's password floor (audit
// 2026-10-12, M5); a refused one writes nothing.
func TestEncryptionPasswordFloor(t *testing.T) {
seedProject(t)
out := filepath.Join(t.TempDir(), "backup.spbk")
err := run(out, "eleven-char")
if err == nil || !strings.Contains(err.Error(), "at least 12 characters") {
t.Fatalf("an 11-character password = %v, want refused", err)
}
if _, statErr := os.Stat(out); !os.IsNotExist(statErr) {
t.Errorf("the refused backup wrote %s", out)
}
}
// A wrong SELFPOST_DB_PATH makes no backup and no database (audit
// 2026-10-12, M3).
func TestBackupOfAMissingDatabaseIsRefused(t *testing.T) {
_, dataDir := seedProject(t)
missing := filepath.Join(dataDir, "typo.db")
t.Setenv("SELFPOST_DB_PATH", missing)
out := filepath.Join(t.TempDir(), "backup.tar.gz")
if err := run(out, ""); err == nil {
t.Fatal("a backup of a database that is not there was made")
}
for _, p := range []string{missing, out} {
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Errorf("the refused backup left %s (stat err = %v)", p, err)
}
}
}
// -decrypt -o writes its file whole or not at all: a wrong password or an
// envelope cut short leaves the file that had the name before, and nothing
// else in its directory (audit 2026-10-12, L1 and stage 3).
func TestDecryptLeavesTheOutputAloneOnFailure(t *testing.T) {
seedProject(t)
dir := t.TempDir()
encrypted := filepath.Join(dir, "backup.spbk")
const password = "a long enough password"
if err := run(encrypted, password); err != nil {
t.Fatalf("create encrypted backup: %v", err)
}
sealed, err := os.ReadFile(encrypted)
if err != nil {
t.Fatal(err)
}
cut := filepath.Join(dir, "cut.spbk")
writeFile(t, cut, string(sealed[:len(sealed)-100]))
outDir := t.TempDir()
out := filepath.Join(outDir, "backup.tar.gz")
const before = "the archive that was here"
for _, c := range []struct{ name, in, password, want string }{
{"wrong password", encrypted, "the wrong password", "wrong password"},
{"cut short", cut, password, "corrupted"},
} {
t.Run(c.name, func(t *testing.T) {
writeFile(t, out, before)
err := runDecrypt(c.in, out, c.password)
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Fatalf("runDecrypt = %v, want %q", err, c.want)
}
if got, _ := os.ReadFile(out); string(got) != before {
t.Errorf("the failed decryption changed %s to %d bytes", out, len(got))
}
entries, _ := os.ReadDir(outDir)
if len(entries) != 1 {
t.Errorf("the failed decryption left files beside its output: %v", entries)
}
})
}
}
+6
View File
@@ -60,6 +60,12 @@ PANEL_SESSION_IDLE_DAYS=7
# your own recursor, e.g. SELFPOST_DNS_RESOLVERS=10.0.0.2:53 # your own recursor, e.g. SELFPOST_DNS_RESOLVERS=10.0.0.2:53
# SELFPOST_DNS_RESOLVERS= # SELFPOST_DNS_RESOLVERS=
# Time zone of the container, an IANA name: every time the panel shows, the
# days it counts (today on Health, the days of the statistics, the log
# filters) and the stamps Postfix writes in mail.log. Leave unset for UTC;
# changing it is a restart. Server > Settings shows the zone in use.
# TZ=Europe/Moscow
# Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to # Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to
# supply X-Forwarded-For for login/setup rate-limiting. Leave unset unless # supply X-Forwarded-For for login/setup rate-limiting. Leave unset unless
# you know the exact address of your reverse proxy — trusting the wrong # you know the exact address of your reverse proxy — trusting the wrong
+23 -9
View File
@@ -9,10 +9,11 @@
# Requires INBOUND_RELAY_ENABLE=true in .env. The milter sees the real client # 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. # 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" # The rspamd image is pinned to one build, 4.2.2-0 - "4.2", "4" and "latest"
# move with each rebuild (Docker Hub rspamd/rspamd tags 3.11.1-0 ... -3) - # are rebuilt every night - and it is the build the comments in
# and it is the build the comments in antispam/rspamd/ were read against; # antispam/rspamd/ were read against (rspamd 4.2.2, rspamd-docker v4.2.2+0;
# bump it deliberately, and read them again. Default milter action is fail-open (accept) so a down sidecar # 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 # does not block backup-MX. Set INBOUND_ANTISPAM_MILTER_ACTION=tempfail to
# defer instead. # defer instead.
@@ -26,12 +27,23 @@ services:
- antispam - antispam
antispam: antispam:
# The milter: rspamd 3.11.1, conf/worker-proxy.inc, "milter = yes" for the # The milter: rspamd 4.2.2, conf/worker-proxy.inc, "milter = yes" for the
# rspamd_proxy worker, which the image binds to *:11332 (rspamd-docker # rspamd_proxy worker, bound to *:11332 by antispam/rspamd/worker-proxy.inc
# v3.11.1+3, patches/rspamd-docker-config.diff), and runs as uid 11333 # while every other worker stays on loopback (RSPAMD_LOCAL_ADDR below);
# (its Dockerfile, USER 11333:11333). # it runs as uid 11333 (rspamd-docker v4.2.2+0, Dockerfile, USER
image: rspamd/rspamd:3.11.1-3 # 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 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. # No host ports: only SelfPost talks to the milter on the compose network.
volumes: volumes:
# The verdict headers SelfPost's inbound journal reads. # 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 # The allow and deny lists kept in the panel: the rules, and the files
# SelfPost writes them to. # SelfPost writes them to.
- ./antispam/rspamd/multimap.conf:/etc/rspamd/local.d/multimap.conf:ro - ./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 - ./data/antispam:/etc/rspamd/selfpost:ro
+7 -5
View File
@@ -8,7 +8,7 @@
# rspamd removes headers of these names that a sender wrote himself before it # 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 # 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 # 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 # 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 # "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 # 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 # 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 # 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 # 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 # Without this file the journal still lists accepted mail, with no verdict
# next to it. # next to it.
@@ -34,7 +36,7 @@
# signature travels on with the mail and is of no use to whoever sees it: it # signature travels on with the mail and is of no use to whoever sees it: it
# fits that one queue id. # 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 # 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 # remove, metadata; a remove value of 0 removes every header of that name
# (docs.rspamd.com/modules/milter_headers, "Custom routines"). # (docs.rspamd.com/modules/milter_headers, "Custom routines").
@@ -43,8 +45,8 @@
# text. task:get_queue_id() is the Queue-Id the proxy took from the milter # text. task:get_queue_id() is the Queue-Id the proxy took from the milter
# macro "{i}" or "i" (src/libserver/milter.c, rspamd_milter_macro_http(); read # macro "{i}" or "i" (src/libserver/milter.c, rspamd_milter_macro_http(); read
# back in src/libserver/protocol.c), which Postfix sends at # back in src/libserver/protocol.c), which Postfix sends at
# end-of-message (Postfix 3.7.11, src/global/mail_params.h, DEF_MILT_EOD_MACROS # end-of-message (Postfix 3.10.13, src/global/mail_params.h:3555,
# "i"). # DEF_MILT_EOD_MACROS "i").
use = ["x-spamd-result", "x-rspamd-action", "selfpost-signature"]; use = ["x-spamd-result", "x-rspamd-action", "selfpost-signature"];
skip_local = false; skip_local = false;
skip_authenticated = false; skip_authenticated = false;
+7 -4
View File
@@ -3,7 +3,7 @@
# The allow and deny lists kept in the panel (Inbound > Filter lists). SelfPost # 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 # 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. # /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 # 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; # 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 # docs.rspamd.com/configuration/maps asks for an atomic rename, which
@@ -14,9 +14,12 @@
# allowed one is not accepted outright - rspamd would then let whichever of # 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 # the two rules ran first decide - but given a weight no spam score
# outweighs, which a refusal by the deny list still overrides. (rspamd # 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; # "normal" symbol, one with an action with the "passthrough" augmentation;
# no "prefilter" setting is read.) # no "prefilter" setting is read. Measured with this file in a throwaway
# rspamd/rspamd:3.11.1-3, 2026-10-15: every mix of an allow and a deny entry
# matching one message - address, domain, IP - came out reject, and a
# changed map file was in use about ten seconds later.)
# #
# A sender rule looks at the envelope sender only, extract_from = "smtp": # A sender rule looks at the envelope sender only, extract_from = "smtp":
# by default multimap.lua takes the header From: when the envelope sender is # by default multimap.lua takes the header From: when the envelope sender is
@@ -26,7 +29,7 @@
# bounce whose From: domain is on the deny list was refused by default and # 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. # 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, # 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 # case-insensitively - rspamd_icase_hash, rspamd_lc_cmp; "email:domain" gives
# the address's domain part, src/plugins/lua/multimap.lua, apply_addr_filter().) # 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";
+8 -6
View File
@@ -1,17 +1,19 @@
# Example host Apache vhost for the SelfPost panel (default scenario). Install # Example host Apache vhost for the SelfPost panel (default scenario). Install
# on the host running Apache — NOT inside the SelfPost container. Requires # 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 # Certificates: obtain with the Apache certbot plugin against THIS vhost
# (`certbot --apache -d mail.example.com`). Certbot edits this file in place to # (`certbot --apache -d mail.example.com`). Certbot edits this file in place to
# add the SSLCertificateFile/SSLCertificateKeyFile directives and a :80 -> :443 # add the SSLCertificateFile/SSLCertificateKeyFile directives and a :80 -> :443
# redirect vhost, and renews in the background via its own timer. The resulting # redirect vhost, and renews in the background via its own timer. The resulting
# PEM files land at # PEM files land at
# /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host — # /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host,
# bind-mount that directory read-only into the SelfPost container (see # as links into ../../archive/. Link certs/fullchain.pem and certs/privkey.pem
# ../docker-compose.yml, the `./certs:/etc/postfix/tls:ro` volume) so Postfix # to them and mount /etc/letsencrypt read-only at the same path (see
# can present the very same certificate on 465/587. One certificate, two # ../docker-compose.yml and docs/guide.md, "Reverse proxy") so Postfix can
# consumers — no separate cert just for the panel. # present the very same certificate on 465/587. Mounting live/mail.example.com
# alone gives the container links into a directory it does not have. One
# certificate, two consumers — no separate cert just for the panel.
<VirtualHost *:443> <VirtualHost *:443>
ServerName mail.example.com ServerName mail.example.com
+7 -2
View File
@@ -16,7 +16,8 @@
# the CA URL's host and path, "acme-v02.api.letsencrypt.org-directory" for Let's # 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 # Encrypt). caddy/modules/caddytls/automation.go, DefaultIssuers(): ZeroSSL is a
# second issuer only when an email is configured, and the Caddyfile below sets # 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 # Merge with the base file — run this from the deploy/ directory (Compose
# resolves every relative path in both files against the directory of the # 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/caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>:/etc/postfix/tls:ro
caddy: 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 restart: unless-stopped
# A ceiling on processes and threads (security audit 2026-10-17, I3).
pids_limit: 256
depends_on: depends_on:
- selfpost - selfpost
ports: ports:
+39 -16
View File
@@ -5,7 +5,7 @@
# on the HOST (the target audience for this project typically runs Apache there # on the HOST (the target audience for this project typically runs Apache there
# already) and reverse-proxies HTTPS for the panel; it is not containerised # already) and reverse-proxies HTTPS for the panel; it is not containerised
# here. See ../apache/selfpost-vhost.conf for a ready vhost fragment, and # here. See ../apache/selfpost-vhost.conf for a ready vhost fragment, and
# docs/guide.md "Reverse proxy" for the certbot steps that produce the PEM files # docs/guide.md "Reverse proxy (mandatory)" for the certbot steps that produce the PEM files
# this compose file mounts. # this compose file mounts.
# #
# Usage: # Usage:
@@ -23,7 +23,7 @@
services: services:
selfpost: selfpost:
image: ghcr.io/mixeme/selfpost:2.5.1 image: ghcr.io/mixeme/selfpost:2.16.0
restart: unless-stopped restart: unless-stopped
environment: environment:
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}" SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
@@ -56,6 +56,9 @@ services:
# report what the internet sees rather than what this host's own stub # report what the internet sees rather than what this host's own stub
# resolver synthesises. Empty uses public defaults. # resolver synthesises. Empty uses public defaults.
SELFPOST_DNS_RESOLVERS: "${SELFPOST_DNS_RESOLVERS:-}" SELFPOST_DNS_RESOLVERS: "${SELFPOST_DNS_RESOLVERS:-}"
# The zone of every time the panel shows and of mail.log's stamps, e.g.
# Europe/Moscow. Empty is UTC.
TZ: "${TZ:-}"
# Reverse-proxy CIDRs allowed to supply X-Forwarded-For for login/setup # Reverse-proxy CIDRs allowed to supply X-Forwarded-For for login/setup
# rate-limiting. See docs/guide.md "Environment variables" — wrong value lets a # rate-limiting. See docs/guide.md "Environment variables" — wrong value lets a
# client spoof its rate-limit key. # client spoof its rate-limit key.
@@ -72,9 +75,15 @@ services:
# certs/ are archived alongside /data (architecture.md § Persistence). # certs/ are archived alongside /data (architecture.md § Persistence).
- .:/selfpost-deploy:ro - .:/selfpost-deploy:ro
# Read-only: SelfPost only ever reads certificates, never manages them. # Read-only: SelfPost only ever reads certificates, never manages them.
# Point this at wherever your reverse-proxy/certbot writes PEM files, e.g. # ./certs holds fullchain.pem and privkey.pem: the files themselves, or
# /etc/letsencrypt on the host. # links to where your reverse proxy's certbot keeps them.
- ./certs:/etc/postfix/tls:ro - ./certs:/etc/postfix/tls:ro
# With certbot on the host (docs/guide.md, "Reverse proxy (mandatory)", Apache):
# certs/ holds links into /etc/letsencrypt/live/, and certbot's live/
# files are themselves relative links into archive/ - so the whole
# directory is mounted, at the same path, for the links to lead
# somewhere inside the container. Uncomment it then.
# - /etc/letsencrypt:/etc/letsencrypt:ro
ports: ports:
# 465 (smtps, primary) and optionally 587 (submission/STARTTLS) are # 465 (smtps, primary) and optionally 587 (submission/STARTTLS) are
# published directly — mail traffic bypasses Apache entirely, it only ever # published directly — mail traffic bypasses Apache entirely, it only ever
@@ -84,20 +93,22 @@ services:
# reachable from the internet without TLS. # reachable from the internet without TLS.
- "465:465" - "465:465"
# 587 is mapped even when SUBMISSION_ENABLE=false; Postfix listens only # 587 is mapped even when SUBMISSION_ENABLE=false; Postfix listens only
# when the variable is true — see docs/guide.md "Published ports". # when the variable is true — see docs/guide.md "Ports".
- "587:587" - "587:587"
# 25 is mapped even when INBOUND_RELAY_ENABLE=false; Postfix listens only # 25 is mapped even when INBOUND_RELAY_ENABLE=false; Postfix listens only
# when the variable is true. # when the variable is true.
- "25:25" - "25:25"
- "127.0.0.1:8080:8080" - "127.0.0.1:8080:8080"
# Hardening. SelfPost's entrypoint still needs to run as # Hardening. SelfPost's entrypoint runs as root to fix /data ownership
# root very briefly to fix /data ownership and normalise permissions # and normalise permissions under the shared `selfpost` group (see
# under the shared `selfpost` group (see build/entrypoint.sh) before # build/entrypoint.sh) before supervisord drops the panel to an
# supervisord drops the panel to an unprivileged user — so this cannot be # 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 # `user: panel` or a fully read-only rootfs without breaking that startup
# self-healing. What IS applied: no privilege escalation past what the # self-healing. What IS applied: no privilege escalation past what the
# image already grants, and every Linux capability dropped except the # image already grants, and every Linux capability dropped except the
# small set the root startup phase and Postfix/OpenDKIM genuinely need: # small set the root startup phase and Postfix genuinely need:
# - NET_BIND_SERVICE — bind 465/587 and inbound 25 below 1024; # - NET_BIND_SERVICE — bind 465/587 and inbound 25 below 1024;
# - CHOWN — entrypoint re-owns /data (bind mount) to `panel`; # - CHOWN — entrypoint re-owns /data (bind mount) to `panel`;
# - FOWNER — entrypoint then chmods those now panel-owned /data # - FOWNER — entrypoint then chmods those now panel-owned /data
@@ -107,12 +118,18 @@ services:
# - SETUID/SETGID — supervisord drops the panel to the unprivileged # - SETUID/SETGID — supervisord drops the panel to the unprivileged
# `panel` user; Postfix switches to its own users; # `panel` user; Postfix switches to its own users;
# - DAC_OVERRIDE — cross-user file access within the `selfpost` group. # - DAC_OVERRIDE — cross-user file access within the `selfpost` group.
# - KILL — supervisord (root) sends SIGUSR1 to opendkim (a # - KILL — root signals processes of other uids: supervisord
# different uid) on domain add/remove to reload its # stops and restarts the panel (supervisor 4.2.5,
# KeyTable/SigningTable; without it os.kill() fails # supervisor/process.py:467, options.kill()), and
# with EPERM even though the caller is root, because # Postfix's master ends its children, which run as
# the kernel's signal permission check for # `postfix` (Postfix 3.10.13, src/master/master_sig.c:196,
# cross-uid kill() still consults CAP_KILL. # master_sigdeath(); master_spawn.c:365,
# master_delete_children()). Without it kill() fails
# with EPERM although the caller is root (kill(2),
# capabilities(7); measured on the test server
# 2026-10-16: root with SETUID/SETGID and no KILL
# cannot kill a process of uid 65534). Until 2.8.0
# it also carried OpenDKIM's USR1 reload.
# FOWNER/FSETID are required by build/entrypoint.sh's permission # FOWNER/FSETID are required by build/entrypoint.sh's permission
# self-healing; without them chmod fails with EPERM and the container # self-healing; without them chmod fails with EPERM and the container
# crash-loops on start. # crash-loops on start.
@@ -136,3 +153,9 @@ services:
- SETGID - SETGID
- DAC_OVERRIDE - DAC_OVERRIDE
- KILL - 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
+27 -10
View File
@@ -14,10 +14,15 @@
# #
# docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d # docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d
# #
# First-run certificate issuance (webroot method, before nginx has a cert to # First-run certificate issuance, before nginx has a certificate to serve -
# serve — run once): # run once, with port 80 free (certbot answers the challenge itself;
# --webroot would need nginx already serving /.well-known). --entrypoint is
# needed: the service's own entrypoint is the renewal loop below, an
# `sh -c` that ignores any arguments given to `run`
# (docs.docker.com/reference/compose-file/services/#entrypoint; assumptions
# D-guide:301):
# docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml \ # docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml \
# run --rm certbot certonly --webroot -w /var/www/certbot \ # run --rm -p 80:80 --entrypoint certbot certbot certonly --standalone \
# -d mail.example.com --email you@example.com --agree-tos --no-eff-email # -d mail.example.com --email you@example.com --agree-tos --no-eff-email
services: services:
@@ -31,14 +36,19 @@ services:
volumes: !override volumes: !override
- ./data:/data - ./data:/data
- .:/selfpost-deploy:ro - .:/selfpost-deploy:ro
# Same host directory certbot below writes into — plain bind mount, no # certs/ holds two links, fullchain.pem and privkey.pem, to
# named volume, so the PEM files are as directly inspectable as in the # /etc/letsencrypt/live/mail.example.com/ - a path inside the
# Apache scenario. # container: certbot's directory below is mounted there whole, because
- ./nginx/certbot-etc/live/mail.example.com:/etc/postfix/tls:ro # its live/ files are relative links into archive/ (mounting live/ alone
# left them pointing at nothing). docs/guide.md, "Reverse proxy", nginx.
- ./certs:/etc/postfix/tls:ro
- ./nginx/certbot-etc:/etc/letsencrypt:ro
nginx: nginx:
image: nginx:1.27 image: nginx:1.27
restart: unless-stopped restart: unless-stopped
# A ceiling on processes and threads (security audit 2026-10-17, I3).
pids_limit: 256
depends_on: depends_on:
- selfpost - selfpost
ports: ports:
@@ -50,7 +60,10 @@ services:
- ./nginx/certbot-www:/var/www/certbot:ro - ./nginx/certbot-www:/var/www/certbot:ro
certbot: 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: volumes:
- ./nginx/certbot-etc:/etc/letsencrypt - ./nginx/certbot-etc:/etc/letsencrypt
- ./nginx/certbot-www:/var/www/certbot - ./nginx/certbot-www:/var/www/certbot
@@ -58,5 +71,9 @@ services:
# until a certificate is within its renewal window. # until a certificate is within its renewal window.
# (eff-certbot.readthedocs.io, User Guide, "Automated Renewals": the # (eff-certbot.readthedocs.io, User Guide, "Automated Renewals": the
# sample cron runs "certbot renew" at 0 and 12 h; renew acts only on a # sample cron runs "certbot renew" at 0 and 12 h; renew acts only on a
# certificate near expiry.) # certificate near expiry.) The first certificate was issued --standalone
entrypoint: sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done' # (see the top of this file), and certbot renews with the plugin it was
# issued with "unless you specify other plugins or options" (same page,
# "Renewing certificates") - by then nginx holds port 80 and serves the
# webroot, so renewal names it; a renewal that succeeds with it saves it.
entrypoint: sh -c 'trap exit TERM; while :; do certbot renew --webroot -w /var/www/certbot; sleep 12h & wait $${!}; done'
@@ -40,6 +40,8 @@ services:
traefik: traefik:
image: traefik:v3.1 image: traefik:v3.1
restart: unless-stopped restart: unless-stopped
# A ceiling on processes and threads (security audit 2026-10-17, I3).
pids_limit: 256
depends_on: depends_on:
- selfpost - selfpost
command: command:
Regular → Executable
+52 -12
View File
@@ -1,11 +1,11 @@
#!/bin/sh #!/bin/bash
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json — # Extracts a PEM cert/key pair for one domain out of Traefik's acme.json —
# Traefik keeps its certificates there, so a PEM extraction step is required. # Traefik keeps its certificates there, so a PEM extraction step is required.
# Run this on the host, after Traefik has issued or renewed the certificate, and # Run this on the host, after Traefik has issued or renewed the certificate, and
# again on a schedule (cron/systemd timer) since acme.json is not itself watched # again on a schedule (cron/systemd timer) since acme.json is not itself watched
# by SelfPost/Postfix. # by SelfPost/Postfix.
# #
# Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir> # Requires bash and jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
# #
# The layout is not documented (doc.traefik.io/traefik/v3.1/https/acme/ says # The layout is not documented (doc.traefik.io/traefik/v3.1/https/acme/ says
# only "JSON file"); read in Traefik v3.1.0: pkg/provider/acme/local_store.go # only "JSON file"); read in Traefik v3.1.0: pkg/provider/acme/local_store.go
@@ -13,23 +13,63 @@
# Certificates}, provider.go Certificate{domain, certificate, key} with the PEM # Certificates}, provider.go Certificate{domain, certificate, key} with the PEM
# as []byte - base64 in JSON (Go encoding/json). "le" is the resolver # as []byte - base64 in JSON (Go encoding/json). "le" is the resolver
# docker-compose.traefik.yml names. # docker-compose.traefik.yml names.
set -eu #
# Nothing is replaced unless both new files are PEM: a domain acme.json does
# not hold, an empty field or a broken file stops the script with an error and
# leaves the files that were there (audit 2026-10-12, M21 - under sh without
# pipefail, jq finding nothing still exited 0 and two empty files replaced the
# certificate; Postfix's TLS broke at its next restart). The new files are
# written beside the old ones, the owner's alone until they are whole, then
# renamed into place.
set -euo pipefail
ACME_JSON="${1:?path to acme.json}" ACME_JSON="${1:?path to acme.json}"
DOMAIN="${2:?domain name, e.g. mail.example.com}" DOMAIN="${2:?domain name, e.g. mail.example.com}"
OUT_DIR="${3:?output directory, e.g. ./extracted-certs}" OUT_DIR="${3:?output directory, e.g. ./extracted-certs}"
mkdir -p "$OUT_DIR" mkdir -p "$OUT_DIR"
umask 077
jq -r --arg domain "$DOMAIN" ' die() {
.le.Certificates[] echo "extract-cert: $*" >&2
| select(.domain.main == $domain) exit 1
| .certificate' "$ACME_JSON" | base64 -d > "$OUT_DIR/fullchain.pem" }
jq -r --arg domain "$DOMAIN" ' # field prints the base64 of one field of the domain's first certificate, or
.le.Certificates[] # nothing.
| select(.domain.main == $domain) field() {
| .key' "$ACME_JSON" | base64 -d > "$OUT_DIR/privkey.pem" jq -r --arg domain "$DOMAIN" --arg field "$1" '
first(.le.Certificates[]? | select(.domain.main == $domain) | .[$field]) // empty
' "$ACME_JSON"
}
chmod 0640 "$OUT_DIR/fullchain.pem" "$OUT_DIR/privkey.pem" cert_b64=$(field certificate)
key_b64=$(field key)
[ -n "$cert_b64" ] || die "$ACME_JSON holds no certificate for $DOMAIN"
[ -n "$key_b64" ] || die "$ACME_JSON holds no key for $DOMAIN"
tmp_cert="" tmp_key=""
trap 'rm -f "$tmp_cert" "$tmp_key"' EXIT
tmp_cert=$(mktemp "$OUT_DIR/.fullchain.pem.XXXXXX")
tmp_key=$(mktemp "$OUT_DIR/.privkey.pem.XXXXXX")
printf '%s' "$cert_b64" | base64 -d >"$tmp_cert" || die "the certificate of $DOMAIN is not base64"
printf '%s' "$key_b64" | base64 -d >"$tmp_key" || die "the key of $DOMAIN is not base64"
grep -q -- '-----BEGIN CERTIFICATE-----' "$tmp_cert" || die "the certificate of $DOMAIN is not PEM"
grep -q -- '-----BEGIN .*PRIVATE KEY-----' "$tmp_key" || die "the key of $DOMAIN is not PEM"
chmod 0640 "$tmp_cert" "$tmp_key"
# The group the files had stays theirs: the guide gives the key to group 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
if [ -e "$OUT_DIR/$f.pem" ]; then
tmp=$tmp_cert
[ "$f" = privkey ] && tmp=$tmp_key
chgrp --reference="$OUT_DIR/$f.pem" "$tmp" || die "cannot give the new $f.pem the group of the old one"
fi
done
mv -f "$tmp_cert" "$OUT_DIR/fullchain.pem"
mv -f "$tmp_key" "$OUT_DIR/privkey.pem"
trap - EXIT
echo "extracted $DOMAIN to $OUT_DIR/{fullchain,privkey}.pem" echo "extracted $DOMAIN to $OUT_DIR/{fullchain,privkey}.pem"
+440 -159
View File
@@ -26,14 +26,17 @@ Managed programs ([build/supervisord.conf](../build/supervisord.conf)):
| Program | User | Priority | Role | | Program | User | Priority | Role |
|---|---|---|---| |---|---|---|---|
| `opendkim` | root → `opendkim` (`UserID` in opendkim.conf) | 100 | DKIM signing milter | | `panel` | panel | 200 | HTTP UI + sender check + DKIM signer + journal-milter + log-tailer + rate-limit recalc + notifier; with the inbound relay on also the inbound journal milter |
| `panel` | panel | 200 | HTTP UI + sender check + journal-milter + log-tailer + rate-limit recalc; with the inbound relay on also the inbound journal milter | | `postfix` | root (wrapper) | 300 | MTA — started only after the milter sockets exist (the sender check's, the DKIM signer's two, the journal milter's and, with the inbound relay on, the inbound journal milter's) |
| `postfix` | root (wrapper) | 300 | MTA — started only after the milter sockets exist (OpenDKIM's, the sender check's, the journal milter's and, with the inbound relay on, the inbound journal milter's) |
| `postfix-reload` | root | — | On-demand `postfix reload` (autostart off) | | `postfix-reload` | root | — | On-demand `postfix reload` (autostart off) |
| `cert-reload` | root | 400 | Daily: re-copies a renewed TLS certificate for Postfix and reloads it | | `cert-reload` | root | 400 | Daily: re-copies a renewed TLS certificate for Postfix and reloads it |
| `logrotate` | root | 400 | Periodic `mail.log` rotation | | `logrotate` | root | 400 | Periodic `mail.log` rotation |
Start order: OpenDKIM → panel (opens the sender check's and the journal-milter's sockets) → Postfix wrapper Until `2.8.0` two more programs ran before the panel: `opendkim`, the DKIM
signing milter, and `syslog-relay`, the container's `/dev/log` for what
OpenDKIM logged; the panel signs now (§ DKIM signing).
Start order: panel (opens the sender check's, the DKIM signer's two and the journal-milter's sockets) → Postfix wrapper
polls unix sockets (timeout `MILTER_WAIT_TIMEOUT`, default 30s) then runs polls unix sockets (timeout `MILTER_WAIT_TIMEOUT`, default 30s) then runs
`postfix start-fg` and stays its parent: on TERM it runs `postfix stop`, so a `postfix start-fg` and stays its parent: on TERM it runs `postfix stop`, so a
stop of the program or of the container stops Postfix as Postfix means it to stop of the program or of the container stops Postfix as Postfix means it to
@@ -45,14 +48,24 @@ that will not start, is an exit supervisord counts as too quick: once
each start it is STARTING. each start it is STARTING.
`crashexit` event listener exits the container when a program of the mail path `crashexit` event listener exits the container when a program of the mail path
is FATAL, so Docker `restart: unless-stopped` starts it again. `logrotate`'s is FATAL - it crashed before its `startsecs` four times running (the start
FATAL (a config logrotate would ignore) stays on Server › Health instead. and `startretries=3`) - or has
exited unexpectedly five times in ten minutes, however long each run lasted
(an exit after `startsecs` is EXITED and restarted, never FATAL; `2.6.0`), so
Docker `restart: unless-stopped` starts it again - the same container, not a
new one: the entrypoint clears what the last run left in `/run` (the milter
sockets, the sender check's alive mark). Besides a stop, supervisord itself
dying, or the entrypoint refusing a setting, nothing else exits the
container. `logrotate`'s FATAL (a config logrotate would ignore),
`postfix-reload`'s (reported to the page that asked) and `cert-reload`'s
stay on Server › Health instead.
The panel runs `postfix-reload` through supervisord's XML-RPC interface and The panel runs `postfix-reload` through supervisord's XML-RPC interface and
waits for its exit status (`internal/supervisor`, `RunOnce`); the program waits for its exit status (`internal/supervisor`, `RunOnce`); the program
rests EXITED after a run. rests EXITED after a run.
**Liveness:** `GET /healthz` (unauthenticated) returns 200 when opendkim, **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
panel, and postfix are RUNNING; Docker `HEALTHCHECK` uses the same probe (its 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). 90-second start period covers postfix's STARTING).
--- ---
@@ -63,7 +76,7 @@ panel, and postfix are RUNNING; Docker `HEALTHCHECK` uses the same probe (its
Client ──TLS+SASL──► Postfix (465 smtps, optional 587 submission) Client ──TLS+SASL──► Postfix (465 smtps, optional 587 submission)
│ │
├─► sender check (From: against the login, tempfail on failure) ├─► sender check (From: against the login, tempfail on failure)
├─► OpenDKIM milter (sign, tempfail on failure) ├─► DKIM signer (the login's domain, tempfail on failure)
├─► journal-milter (send log + L2 rate limits, fail-open) ├─► journal-milter (send log + L2 rate limits, fail-open)
└─► outbound MX delivery (port 25 client) └─► outbound MX delivery (port 25 client)
@@ -86,11 +99,14 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
- **587/submission** — only when `SUBMISSION_ENABLE=true`; STARTTLS with - **587/submission** — only when `SUBMISSION_ENABLE=true`; STARTTLS with
`smtpd_tls_security_level=encrypt`. `smtpd_tls_security_level=encrypt`.
- **25/smtp inet** — when `INBOUND_RELAY_ENABLE=true` and/or - **25/smtp inet** — when `INBOUND_RELAY_ENABLE=true` and/or
`DMARC_REPORTS_ENABLE=true`. No SASL, no OpenDKIM, no journal-milter — `DMARC_REPORTS_ENABLE=true`. No SASL, no sender check, no signing, no journal-milter —
`smtpd_milters` is always overridden on this service, because main.cf's `smtpd_milters` is always overridden on this service, because main.cf's
submission chain would otherwise let a signing outage defer relayed mail and 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 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 then the panel's **inbound journal milter** (unix socket
`INBOUND_JOURNAL_MILTER_SOCKET`, default `INBOUND_JOURNAL_MILTER_SOCKET`, default
`/run/selfpost/inbound-journal.sock`, `default_action=accept`); with DMARC `/run/selfpost/inbound-journal.sock`, `default_action=accept`); with DMARC
@@ -100,29 +116,41 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
verdict headers (`X-Rspamd-Action`, `X-Spamd-Result`) off it. It writes one verdict headers (`X-Rspamd-Action`, `X-Spamd-Result`) off it. It writes one
`inbound_spam_log` row per recipient at an inbound domain - a DMARC report `inbound_spam_log` row per recipient at an inbound domain - a DMARC report
address is not one - and never refuses anything address is not one - and never refuses anything
([internal/milter/inbound.go](../internal/milter/inbound.go)). The headers ([internal/milter/inbound.go](../internal/milter/inbound.go)). The row
keeps the message's queue id (`{i}` at end-of-message) and starts
`queued` - `held` when the filter had Postfix hold it - and the recipient
as Postfix logs it (`recipientAsLogged`); the log tailer puts the
upstream's answer on it (§ Log tailer, *The upstream's answer*). Older
rows of a queue id Postfix uses again are detached first. The headers
are read only while a filter is attached, and only next to the filter's are read only while a filter is attached, and only next to the filter's
signature: a third header, `X-SelfPost-Filter`, the HMAC-SHA256 of the signature: a third header, `X-SelfPost-Filter`, the HMAC-SHA256 of the
message's queue id under the key in `/data/antispam/filter.key` message's queue id under the key in `/data/antispam/filter.key`
(`inbound.FilterKey` makes it on first start; rspamd reads it through the (`inbound.FilterKey` makes it on first start; rspamd reads it through the
mounted directory and signs in a custom routine of `milter_headers.conf`). mounted directory and signs in a custom routine of `milter_headers.conf`).
rspamd removes a sender's own copies of the three headers and writes and
signs its own for every message it lets through: `skip_local` and
`skip_authenticated` are off in that file, so this holds whatever the
sender's address (rspamd 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 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. headers carry no signature and the row is that of mail nobody judged.
Refused mail never reaches this milter. Refused mail never reaches this milter.
Refusals come from `mail.log`: this listener logs as `postfix/inbound` Refusals come from `mail.log`: this listener logs as `postfix/inbound`
(`syslog_name`), and the log tailer turns a `reject:` line of (`syslog_name`), and the log tailer turns a `reject:` line of
`postfix/inbound/smtpd` (Postfix's own policy) and a `milter-reject` / `postfix/inbound/smtpd` (Postfix's own policy) and a `milter-reject` /
`milter-hold` / `milter-discard` line of `cleanup` (the filter's verdict, `milter-discard` line of `cleanup` (the filter's verdict, counted only for
counted only for a queue id that listener opened) into a row, dated by the a queue id that listener opened) into a row, dated by the line, when the
line, when the recipient is at an inbound domain 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 ([internal/logtail/inbound.go](../internal/logtail/inbound.go)). A line
names one recipient, so a message refused at end-of-message is one row names one recipient (the last), so a message refused at end-of-message is
whatever its recipient count. The tailer saves its place every five seconds, one row whatever its recipient count. The tailer saves its place every five seconds,
so after an unclean stop it reads some lines again; the lines it reads on so after an unclean stop it reads some lines again; the lines it reads on
start are written only if the same row is not there already. The start are written only if the same row is not there already. The
journal is pruned every six hours: rows journal is pruned every six hours: rows
older than the `inbound_spam_log_retention_days` setting, then down to 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 **Filter lists** (`inbound_spam_list`): allow and deny entries for the
external filter - sender address, sender domain, client address or network - external filter - sender address, sender domain, client address or network -
validated and normalised by `internal/inbound` (`lists.go`) and written as validated and normalised by `internal/inbound` (`lists.go`) and written as
@@ -131,21 +159,33 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
them. The sidecar mounts the directory and rspamd's multimap rules them. The sidecar mounts the directory and rspamd's multimap rules
([deploy/antispam/rspamd/multimap.conf](../deploy/antispam/rspamd/multimap.conf)) ([deploy/antispam/rspamd/multimap.conf](../deploy/antispam/rspamd/multimap.conf))
read them: a deny entry's rule carries the action `reject`, which rspamd read them: a deny entry's rule carries the action `reject`, which rspamd
sets as a pre-result, an allow entry is a symbol weighing -100. Allow is sets as a pre-result (a passthrough result: the remaining filters are not
run and the action stands whatever the score), an allow entry is a symbol
weighing -100. Allow is
deliberately not an `accept` action: with two pre-results rspamd keeps the deliberately not an `accept` action: with two pre-results rspamd keeps the
one set first, and deny then won or lost by rule order (seen on rspamd one set first, and deny then won or lost by rule order (seen on rspamd
3.11, date not recorded). A refusal by the deny list overrides any score, 3.11, date not recorded). A refusal by the deny list overrides any score,
so deny wins whichever entries match. A sender entry is matched against so deny wins whichever entries match - measured in a throwaway
`rspamd/rspamd:3.11.1-3` with this file, 2026-10-15: the same address or
IP on both lists, an allowed address with a denied domain or IP, and an
allowed IP with a denied address were all `reject` (assumptions
D-arch:121). A sender entry is matched against
the envelope sender only (`extract_from = "smtp"`; by default rspamd takes 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). `src/plugins/lua/multimap.lua`, the `from` callback).
How often an entry decided a message is not stored: `Lists.Usage` reads it 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 off the journal on each view of Filter lists - the rows a list decided (the
deny rule's reply, the allow rule's symbol, which the journal milter keeps deny rule's reply, the allow rule's symbol, which the journal milter keeps
ahead of its six-symbol cap) matched against the entries as rspamd matches ahead of its six-symbol cap) matched against the entries as rspamd matches
them. When that stops being good enough is written in them. So a count reaches back as far as the journal does, a row counts for
[plans/inbound-antispam-panel.md](plans/inbound-antispam-panel.md) § Filter every fitting entry of the deciding list, and a count is of messages,
list usage. roughly. A counter kept with each entry (hits and last hit on
`inbound_spam_list`, a migration) is worth it when any one of these holds
(owner, 2026-10-12): the journal is cut by its 10 000-row cap rather than by
age (the tailer's prune keeps removing rows while the oldest row of
Inbound › Log is younger than the retention); the retention is set short,
towards its 7 days; counts are wanted that outlive the journal; or the page
is slow - past a few hundred entries, measure it.
**Inbound relay** (`INBOUND_RELAY_ENABLE=true`): accepts only **Inbound relay** (`INBOUND_RELAY_ENABLE=true`): accepts only
`relay_domains` + `relay_recipient_maps` (`reject_unauth_destination`, `relay_domains` + `relay_recipient_maps` (`reject_unauth_destination`,
`reject_unlisted_recipient`). Maps under `/data/postfix/` (`relay_domains`, `reject_unlisted_recipient`). Maps under `/data/postfix/` (`relay_domains`,
@@ -153,8 +193,11 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
[internal/postfix/inbound.go](../internal/postfix/inbound.go). Domains with [internal/postfix/inbound.go](../internal/postfix/inbound.go). Domains with
an empty upstream host are omitted from the maps. Optional an empty upstream host are omitted from the maps. Optional
`INBOUND_ANTISPAM_MILTER` only when inbound relay is enabled (not on `INBOUND_ANTISPAM_MILTER` only when inbound relay is enabled (not on
DMARC-only port 25); default `milter_default_action` is fail-open DMARC-only port 25); it is attached with its own `default_action`,
(`accept`). **DMARC ingest** (`DMARC_REPORTS_ENABLE=true`): allow-listed `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`; report addresses via `check_recipient_access` on `postfix/dmarc_recipients`;
messages pipe to `dmarc-ingest` (symlink to `panel`). When both flags are messages pipe to `dmarc-ingest` (symlink to `panel`). When both flags are
on, one listener serves both paths; per-IP rate uses on, one listener serves both paths; per-IP rate uses
@@ -166,36 +209,138 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
panel's sender check (§ Panel roles). panel's sender check (§ Panel roles).
- **Level-1 rate limit** — `smtpd_client_message_rate_limit` / - **Level-1 rate limit** — `smtpd_client_message_rate_limit` /
`anvil_rate_time_unit` from `RATE_LIMIT_*` env vars; independent of milter. `anvil_rate_time_unit` from `RATE_LIMIT_*` env vars; independent of milter.
- **Port 25 ends DATA only at `<CR><LF>.<CR><LF>`** —
`smtpd_forbid_bare_newline=normalize` on the `smtp/inet` listener (Postfix
3.10.13, `src/smtpd/smtpd.c`, `receive_data_message()`, the DATA loop at
3713: a `.` line next to a bare `<LF>` is dropped, not taken as the end); a
bare `<LF>` elsewhere is read as `<CR><LF>`. The submission ports keep
Postfix's default, which is the same `normalize` since Postfix 3.9
(`src/global/mail_params.h`, `DEF_SMTPD_FORBID_BARE_LF`); the image ran
Postfix 3.7.11 until `2.7.0`, whose default was `no`, hence the explicit
setting on port 25.
- **The submission ports hide the application in `Received:`** (`2.7.0`) —
`smtpd_hide_client_session=yes` on `smtps/inet` and `submission/inet`:
Postfix's header reads `Received: by <hostname> (Postfix) id <queue id>`,
with the recipient when there is one, and the date - no HELO name, client
name or address, TLS, login or protocol (Postfix 3.10.13,
`src/smtpd/smtpd.c:3495` and `:3627-3645`). Port 25 keeps the full
header, which RFC 5321 requires there (`proto/postconf.proto:19587-19589`).
The queue id in the header leads to the message's row in the panel's log
and to smtpd's `client=` line in mail.log.
- **Chroot disabled** for all services (DNS/TLS inside container). - **Chroot disabled** for all services (DNS/TLS inside container).
- **TLS certs** — read-only mount at `TLS_CERT_FILE` / `TLS_KEY_FILE`, copied - **TLS certs** — read-only mount at `TLS_CERT_FILE` / `TLS_KEY_FILE`, copied
at start into `/etc/postfix/tls-internal` (Postfix wants the key root-owned at start into `/etc/postfix/tls-internal`, owned by root, the key `0600`
and `0600`, which the mount cannot give), and Postfix reads the copy. (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 `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 the certificate and key belong together it renews the copy (each file
renamed into place) and reloads Postfix. renamed into place) and reloads Postfix.
### OpenDKIM ### DKIM signing
Per-domain keys under `/data/opendkim/keys`; `KeyTable` / `SigningTable` maintained The panel signs outgoing mail itself, in a role of its own, `dkim-signer`
by the panel. Socket `/run/opendkim/opendkim.sock`. ([cmd/panel/signer.go](../cmd/panel/signer.go),
[internal/milter/sign.go](../internal/milter/sign.go)), since `2.8.0`; until
then OpenDKIM did, in a program of its own (CHANGELOG `[2.8.0]`).
The server's own name (`SELFPOST_HOSTNAME`) has a key and a line in both - **Keys** — `/data/dkim/keys/<name>/<selector>.private`, PKCS#1 PEM, one
tables too, without being a row of the registry for each sending domain and one for the server's own name
(`domain.Service.SetServerName`): the panel's notifications are sent as (`internal/domain`, `Keys`; `DKIM_DIR`, default `/data/dkim`). A key is
`postmaster@` that name and signed with it. No application can use it as an made when a domain is added and an existing one is reused, never
envelope sender - the sender map is built from the registry - but the overwritten: that would invalidate the record already published
`From:` header is not held against the login, so an application can still (`EnsureKey`). A domain export carries the key and an import writes it,
have a message signed as the server ([security.md](security.md) § Accepted over whatever key the name had (`ImportKey`: the import is the domain
risks). A sending domain of the same name shares its key and selector with with exactly that key). A
the server; deleting that domain leaves the server signed under the default deleted domain's key directory is removed once the selector table no
selector, which is the same key only if that was the domain's. At its start longer names it; one a deletion had to leave, Reload configuration removes
the notifier makes sure the tables have the line and name the selector the (`Service.Resync`).
panel shows (`EnsureServerSigned`): tables are otherwise rebuilt only when a - **The selector table** — `/data/dkim/selectors`, a line `<name> <selector>`
domain is added or deleted. for every name the server signs, sorted, written whole from the database
and renamed into place (`Keys.Rebuild`). It is rewritten when a domain is
added, deleted or imported, by Server › Health › Reload configuration,
after a restore, and at every start of the signer before it serves
(`WriteSelectors`): Postfix waits for the signer's sockets, so no message
meets a table from before - one a rewrite cut short left, the first start
of `2.8.0` without one, or one naming the old selector after a changed
`DKIM_SELECTOR_DEFAULT`. A rewrite at start that fails is tried again every
10 seconds; meanwhile the signer defers what it finds no key for, and the
pages stay up. Each role builds a domain service of its own, so the
writers are serialised by a mutex (`tableMu`): a writer that read the
registry before another's domain was added cannot write after it.
- **The signer reads files, not the database.** The panel's one SQLite
connection is shared with the pages, and a long query would hold up mail.
It reads the table for every message (a few lines; a stamp of time and
size would miss a rewrite of the same size within one tick of the file
system's clock) and a key again when its file's time or size changes; a key
the table no longer names is forgotten. Nothing is reloaded.
- **The panel's alone.** All of `/data/dkim` is `panel:panel`, directories
`0700`, files `0600`, set by the entrypoint at every start: no group, no
other program reads it.
- **Two sockets, both strict** (`default_action=tempfail`, so while the role
does not answer mail waits, never goes unsigned):
`DKIM_SIGN_SOCKET` (`/run/selfpost/sign.sock`) for what applications send
on 465/587, signed with the key of the domain the SASL login belongs to -
`{auth_authen}`, then `Bindings.DomainOf`, the `sender_login_maps` file
Postfix and the sender check read; `DKIM_SIGN_LOCAL_SOCKET`
(`/run/selfpost/sign-local.sock`) for what the panel itself submits with
`sendmail(1)` (`non_smtpd_milters`), signed with the key of the server's
name. The socket a message comes through, not the absence of a login,
decides that the server's key may sign it.
- **The server's own name** (`SELFPOST_HOSTNAME`) has a key and a line in the
table without being a row of the registry (`domain.Service.SetServerName`):
the panel's notifications are sent as `postmaster@` that name and signed
with it. The name is kept in lower case for DKIM, as the signer finds keys
by the name in lower case; the SASL realm, which defaults to the hostname,
stays as written. No application can use it as an envelope sender - the
sender map is built from the registry - and since `2.4.0` none can have a
message signed as the server by writing it into `From:` either: the sender
check holds the header against the login (§ Sender check). A sending
domain of the same name shares its key and selector with the server;
deleting that domain leaves the server signed under the default selector,
which is the same key only if that was the domain's.
- **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`, `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=`
the message lacked would break the signature over the one added); then `From` once more, last (`signLines`). Each header is hashed as
Postfix will deliver it: the value with its leading space (the signer asks
for `SMFIP_HDR_LEADSPC`), the lines of a folded one joined with CRLF. The
signature is inserted at the top, above Postfix's own `Received:`, as
`DKIM-Signature: ...`. `disable_mime_output_conversion=yes`
([build/postfix-config.sh](../build/postfix-config.sh)): an 8-bit message
is delivered as it was signed, not converted to 7-bit for a server that
does not announce 8BITMIME.
- **Limits.** What cannot be signed - no line in the table, no key file, a
login with no domain, a read error - is deferred. A header section over
1 MiB is refused, `552 5.3.4`: the signer holds it whole until the body
starts. A body line over 998 bytes is logged (`dkim-signer: <domain> ...`)
and the message goes on: Postfix's SMTP client breaks such a line on
delivery (`smtp_line_length_limit`), and the signature will not verify. A
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 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
start and kept it for a rollback to a 2.7.x image; since `2.9.0` the
entrypoint removes it once `/data/dkim` holds a key, and a `/data` with
keys under the old tree only - one that never ran 2.8.x - stops the start
(the server's name would otherwise get a new key and its published record
would no longer match). Under 2.8.x it was the panel's alone, like the
new one, and left out of backups.
### Panel binary ([cmd/panel](../cmd/panel)) ### Panel binary ([cmd/panel](../cmd/panel))
One process, five roles (six with the inbound relay on): One process, seven roles (eight with the inbound relay on: its inbound journal
milter, § Postfix above, port 25):
1. **HTTP server** — `:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse 1. **HTTP server** — `:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse
proxy only. On start it runs `postconf -h -x` once for the deferred-mail retry proxy only. On start it runs `postconf -h -x` once for the deferred-mail retry
@@ -204,7 +349,7 @@ One process, five roles (six with the inbound relay on):
`delay_warning_time`), reads each as Postfix does - one number and one `delay_warning_time`), reads each as Postfix does - one number and one
unit, the parameter's own default unit when there is none (`5d` for the unit, the parameter's own default unit when there is none (`5d` for the
lifetimes, so a bare `3` is three days), `bounce_queue_lifetime` capped at lifetimes, so a bare `3` is three days), `bounce_queue_lifetime` capped at
`maximal_queue_lifetime` as qmgr caps it (Postfix 3.7.11, `maximal_queue_lifetime` as qmgr caps it (Postfix 3.10.13,
`src/global/conv_time.c`, `src/qmgr/qmgr.c`) - and caches the snapshot on `src/global/conv_time.c`, `src/qmgr/qmgr.c`) - and caches the snapshot on
the handlers config. The the handlers config. The
Outbound queue box and a delivery's `deferred` / `bounced` history print those Outbound queue box and a delivery's `deferred` / `bounced` history print those
@@ -213,7 +358,14 @@ One process, five roles (six with the inbound relay on):
(`300s` / `4000s` / `5d` / `0`) with a muted note on the box. (`300s` / `4000s` / `5d` / `0`) with a muted note on the box.
2. **sender check** — unix socket `SENDER_CHECK_SOCKET`, first in the chain 2. **sender check** — unix socket `SENDER_CHECK_SOCKET`, first in the chain
and **strict** (`default_action=tempfail`): § Sender check below. and **strict** (`default_action=tempfail`): § Sender check below.
3. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/ 3. **DKIM signer** (`2.8.0`, § DKIM signing)
— unix sockets `DKIM_SIGN_SOCKET` (applications' mail on 465/587, signed
with the key of the domain the SASL login belongs to) and
`DKIM_SIGN_LOCAL_SOCKET` (the panel's own mail, `non_smtpd_milters`,
signed with the key of the server's name); after the sender check and
**strict**. Keys and the selector table from `/data/dkim`, no database:
§ DKIM signing above.
4. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open** Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open**
(`default_action=accept`) so milter failure does not stop mail. Domain (`default_action=accept`) so milter failure does not stop mail. Domain
ceilings apply to every client IP; an application ceiling **overrides** the ceilings apply to every client IP; an application ceiling **overrides** the
@@ -225,33 +377,40 @@ One process, five roles (six with the inbound relay on):
this process has admitted but not yet written (`internal/milter/inflight.go`), this process has admitted but not yet written (`internal/milter/inflight.go`),
so concurrent sessions cannot each spend the same last slot; a reservation so concurrent sessions cannot each spend the same last slot; a reservation
is released at end-of-message, on ABORT, or after a 10-minute TTL. is released at end-of-message, on ABORT, or after a 10-minute TTL.
4. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by 5. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by
queue-id. Send-log `queued → sent` transitions depend on this goroutine alone queue-id. Send-log `queued → sent` transitions depend on this goroutine alone
(`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)). (`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)).
It also keeps the lines Postfix writes under a queue id, for the message's It also keeps the lines Postfix writes under a queue id, for the message's
page (§ Log tailer, *Stored lines*), and counts the mail Postfix deferred page (§ Log tailer, *Stored lines*), and counts the mail Postfix deferred
because the sender check did not answer. because the sender check did not answer.
5. **rate-limit recalc** — every six hours (and on demand from Domain settings and 6. **rate-limit recalc** — every six hours (and on demand from Domain settings and
the application form), recomputes level-2 **Auto** rate limits from send-log statistics. the application form), recomputes level-2 **Auto** rate limits from send-log statistics.
6. **notifier** — writes to the panel's users when something needs them 7. **notifier** — writes to the panel's users when something needs them
(§ Notifications below). (§ Notifications below).
Milter chain in Postfix: the sender check (tempfail), OpenDKIM (tempfail), Milter chain in Postfix ([build/postfix-config.sh](../build/postfix-config.sh),
then the journal (accept on failure). Mail submitted locally with `smtpd_milters`): the sender check (tempfail), the DKIM signer (tempfail),
`sendmail(1)` — the notifier's — passes OpenDKIM alone (`non_smtpd_milters`): then the journal (accept on failure) - the check first, since the signature
it is signed, it is the panel's own and so needs no check of whose it is, and is held to the `From:` domain and a message the check could not judge must
it is not an application's mail to be journalled or limited. Bounces Postfix not be signed. Until `2.8.0` OpenDKIM signed in the signer's place. Mail
submitted locally with `sendmail(1)` — the notifier's — passes the signer's
own socket alone (`non_smtpd_milters`, tempfail): it is signed, it is the panel's own and so needs no
check of whose it is, and it is not an application's mail to be journalled
or limited. Bounces Postfix
generates pass no milter. generates pass no milter.
### Sender check ### Sender check
[internal/milter/sender.go](../internal/milter/sender.go) holds a message's [internal/milter/sender.go](../internal/milter/sender.go) holds a message's
`From:` header against the application that sends it `From:` header against the application that sends it (since `2.4.0`;
([plans/from-header-binding.md](plans/from-header-binding.md)). Postfix holds `Sender:` too since `2.8.0`). Postfix holds
the envelope sender against the SASL login, and OpenDKIM chooses the signing the envelope sender against the SASL login, and the signer signs with the key
key by the header; until `2.4.0` nothing connected the two, and an of the login's domain (§ DKIM signing): the signature's `d=` is that domain,
application could have mail signed as another domain of the server or as the and a receiver holds it to the domain of `From:` (DMARC, RFC 7489 § 3.1.1).
server itself. Without the check an application could send under the server's signature as
another domain of the server or as the server itself. The check came in
`2.4.0`, when OpenDKIM still signed and chose its key by the header itself;
the hole it closed was the same.
- **The rule is the envelope's.** The check reads the file Postfix reads for - **The rule is the envelope's.** The check reads the file Postfix reads for
`smtpd_sender_login_maps` and makes the same lookup: the address itself is `smtpd_sender_login_maps` and makes the same lookup: the address itself is
@@ -265,24 +424,29 @@ server itself.
reason), then `@domain` - and the **first of these keys in the table reason), then `@domain` - and the **first of these keys in the table
decides**, its owners alone: an address listed for one application is not decides**, its owners alone: an address listed for one application is not
open to another that holds the whole domain. Logins are compared without open to another that holds the whole domain. Logins are compared without
regard to case, as Postfix compares them (Postfix 3.7.11, regard to case, as Postfix compares them (Postfix 3.10.13,
`src/smtpd/smtpd_check.c`, `reject_auth_sender_login_mismatch()`, over `src/smtpd/smtpd_check.c`, `reject_auth_sender_login_mismatch()`, over
`src/global/mail_addr_find.c`, `mail_addr_find_opt()`). A key written `src/global/mail_addr_find.c`, `mail_addr_find_opt()`). A key written
twice makes Postfix refuse the whole table, and the check defers then too. twice makes Postfix refuse the whole table, and the check defers then too.
- **How the header is read.** Exactly one `From:` field (none, or more than - **How the header is read.** Exactly one `From:` field (none, or more than
one, is refused), parsed as an address list that must hold exactly one one, is refused). The line, its folds joined, must be one address in RFC
address. Then a cruder second look at the raw value: OpenDKIM finds the 5322's grammar as Go's `net/mail` reads it - a mail program's reading, the
address with a parser of its own, and wherever the two could read a line address it shows; a display name may say anything (an encoded one in a
differently - a display name, a comment, a quoted string - the other charset Go does not convert is passed through unread). A line a lenient
address would have to be spelled out in it. So no `@name` may appear there program could show as another address - `ceo@bank.com <me@mine.com>`,
that is another name the server signs for; those names are read from `<b@evil.com, a@mine.com>` - is refused. That address, its local part
OpenDKIM's own `SigningTable`; the display name is looked at once more unquoted as Postfix holds the envelope's, is what the bindings are asked
as decoded, since a mail program shows `=?utf-8?q?director=40example.org?=` about, folded to lower case in ASCII only: a Unicode fold would make the
as an address though no `@` is written in it. And, needing no knowledge of Kelvin sign a `k` and let a look-alike pass. Since `2.8.0` a `Sender:`
that parser: line - the sending agent's, shown beside `From:` as "on behalf of" - is
outside quoted strings the line has exactly one `@`. A folded line is held to the same rule when the message has one (at most one; the owner's
unfolded first, and a display name in a charset Go does not convert is decision, 2026-10-16): the reply says `Sender:` instead of `From:` and the
passed through unread - only the address is judged. row's reason is `sender`. `From:` is judged first. The check asks Postfix for
header values with their leading space (`SMFIP_HDR_LEADSPC`), as the
signer does, so both read the same bytes. Until `2.8.0` the line was also
read with a port of OpenDKIM's own parser, so that the address judged was
the one OpenDKIM signed for, and an address over 256 bytes was refused;
both went with OpenDKIM.
- **The verdict** comes at the end of the headers: `550 5.7.1`, permanent, - **The verdict** comes at the end of the headers: `550 5.7.1`, permanent,
naming the line, as a reply code of the milter's own. What the client wrote naming the line, as a reply code of the milter's own. What the client wrote
is reduced to printable ASCII and cut before it is said back, logged or is reduced to printable ASCII and cut before it is said back, logged or
@@ -301,8 +465,9 @@ server itself.
how many more there were: a row per recipient would let one refused message how many more there were: a row per recipient would let one refused message
write a thousand. write a thousand.
- **What it costs is counted.** The role marks itself alive every two - **What it costs is counted.** The role marks itself alive every two
seconds (the socket's path with `.alive`, under `/run`, so a new container seconds (the socket's path with `.alive`, under `/run`; the entrypoint
starts without one). At start the time since the mark is the check's removes it at every container start, so a restart begins without one). At
start the time since the mark is the check's
downtime - counted from Postfix's own start if that was later, and not at downtime - counted from Postfix's own start if that was later, and not at
all when supervisord says Postfix is not running, since mail was then all when supervisord says Postfix is not running, since mail was then
stopped for another reason. Supervisord is asked for half a minute; when it stopped for another reason. Supervisord is asked for half a minute; when it
@@ -312,21 +477,23 @@ server itself.
writes a warning naming the socket and then, from the same smtpd process, writes a warning naming the socket and then, from the same smtpd process,
`milter-reject: … 451 4.7.1 Service unavailable` from the connection on `milter-reject: … 451 4.7.1 Service unavailable` from the connection on
(the client is told `454 4.3.0` at `AUTH` and leaves), so what is counted (the client is told `454 4.3.0` at `AUTH` and leaves), so what is counted
is a connection turned away, once - a client brings a message when it is a connection turned away, once - how many messages it held back is not
connects. The reject line does not name the milter and the journal milter's known: a client may bring one or several, or come back for the same one.
The reject line does not name the milter and the journal milter's
level-2 refusal reads the same, hence the warning. A panel that is running level-2 refusal reads the same, hence the warning. A panel that is running
and stops answering in the middle of a message (its socket accepts, nothing and stops answering in the middle of a message (its socket accepts, nothing
replies) is met by cleanup, not smtpd: past `DATA` it is cleanup that talks replies) is met by cleanup, not smtpd: past `DATA` it is cleanup that talks
to the milters, and after the content timeout it writes `warning: milter to the milters, and after the content timeout it writes `warning: milter
unix:<socket>: can't read SMFIC_HEADER reply packet header` and refuses the unix:<socket>: can't read SMFIC_HEADER reply packet header` and refuses the
message with `4.7.1` (seen on the e2e stand with the panel frozen). One message with `4.7.1` (seen on the e2e stand with the panel frozen;
`panel_hung_mid_message_is_counted` checks it at every run). One
cleanup process serves message after message, so each such warning is cleanup process serves message after message, so each such warning is
counted as a message. The two are counted apart (the owner, 2026-10-14): counted as a message. The two are counted apart (the owner, 2026-10-14):
connections turned away in `deferred`, messages deferred mid-message in connections turned away in `deferred`, messages deferred mid-message in
`messages` of `sender_check_days`, a row per UTC day kept a year, and `messages` of `sender_check_days`, a row per day of the container's zone (`TZ`) kept a year, and
Server › Health shows today, 30 days and 12 months in a column each. The Server › Health shows today, 30 days and 12 months in a column each. The
counts also see a panel that is running and not answering, which the mark counts also see a panel that is running and not answering, which the mark
does not. Downtime is cut at the UTC midnights it crossed. A log read again does not. Downtime is cut at the midnights of that zone it crossed. A log read again
after an unclean stop does not count again: the count and the place in the after an unclean stop does not count again: the count and the place in the
log it reached (the file's fingerprint and the offset past the line) are log it reached (the file's fingerprint and the offset past the line) are
saved in one transaction, and while the tailer catches up a warning at or saved in one transaction, and while the tailer catches up a warning at or
@@ -342,10 +509,15 @@ server itself.
[internal/notify](../internal/notify/notify.go) reports four things to the [internal/notify](../internal/notify/notify.go) reports four things to the
users of the panel: a sending domain's DNS record that stopped matching, a users of the panel: a sending domain's DNS record that stopped matching, a
level-2 rate limit that refused mail, the TLS certificate about to expire, a level-2 rate limit that refused mail, the TLS certificate about to expire, a
message more than three days in the queue. The rules of when a message goes message more than three days in the queue. The operator's view is guide
out are the owner's and are written in § Notifications. When a message goes out is the owner's rule (2026-10-12):
[plans/panel-notifications.md](plans/panel-notifications.md) § When a message
is sent; the operator's view is guide § Notifications. | Subject | Sent | "Cleared" message |
|---|---|---|
| DNS mismatch | when it has held for an hour (two checks, below); a resolver that did not answer is not a mismatch | the same way back |
| Certificate | 14 days before expiry, and 3 days before if it is still the same certificate | none |
| Level-2 limit refused mail | at the first refusal for a sending domain, then at most once a day for it | none |
| Queue older than three days | when such a message appears | one, when none is left |
- **Readings.** Once an hour, starting three minutes after the process does: - **Readings.** Once an hour, starting three minutes after the process does:
every sending domain's records through a DNS checker of the notifier's own, every sending domain's records through a DNS checker of the notifier's own,
@@ -370,11 +542,22 @@ is sent; the operator's view is guide § Notifications.
minutes after it - a count alone would let the last check before a restart minutes after it - a count alone would let the last check before a restart
and the first after it stand for "an hour apart"; back the same way. The and the first after it stand for "an hour apart"; back the same way. The
certificate's row remembers which threshold was said for which expiry. A certificate's row remembers which threshold was said for which expiry. A
deleted domain's row goes with it. deleted domain's `dns:` row goes at the next check; its `limit:` row stays
until it was last said more than 48 hours ago (`CheckLimits`).
- **Said means sent.** A problem that arises while no user can be told, or - **Said means sent.** A problem that arises while no user can be told, or
while Postfix does not take the message, stays unsaid and is told at a while Postfix does not take the message, stays unsaid and is told at a
later check. Its ending, with nobody left to tell, is simply over. If one later check. Its ending, with nobody left to tell, is simply over.
recipient's copy fails and another's goes out, the subject is said. - **Owed to each recipient** (since `2.5.4`, `notify_addressees`): the
subject's first message records who it was addressed to and who was
handed a copy; while the subject is open, every later check hands it to
those not told yet - a copy that failed, or that the ceiling held back -
who are still recipients. A user who comes to hear of a subject only while
it is open is not mailed about it, and its ending goes to those who were
told. A subject opened before `2.5.4` has no such record, and its ending
goes to every recipient, as it did. It is owed to an address: a user who
changes their e-mail while a subject is open is a new address, told
neither its follow-up nor its end - accepted, a subject lasts hours to
days.
- **The notifier's own mail is not a subject.** A notification waiting for a - **The notifier's own mail is not a subject.** A notification waiting for a
mailbox that does not answer, or its bounce coming back to mailbox that does not answer, or its bounce coming back to
`postmaster@<hostname>` - which receives no mail - is left out of the `postmaster@<hostname>` - which receives no mail - is left out of the
@@ -411,15 +594,17 @@ and `MAIL_LOG` in [cmd/panel/main.go](../cmd/panel/main.go). Backups exclude
Rotation uses rename + `postfix reload` Rotation uses rename + `postfix reload`
([build/logrotate-mail.conf](../build/logrotate-mail.conf)), not `copytruncate` — ([build/logrotate-mail.conf](../build/logrotate-mail.conf)), not `copytruncate` —
the latter can drop `status=sent` lines and leave send-log rows stuck at the latter can drop `status=sent` lines and leave send-log rows stuck at
`queued`. After rename, logrotate runs `create 0640 postfix selfpost` (postlogd `queued`. After rename, logrotate runs `create 0640 postfix selfpost`, so the
recreates the file lazily on first write as mode `0600`, which the unprivileged file is there, readable by the panel through group `selfpost`, before postlogd
panel user cannot read). `follow()` drains the old inode once more before writes its first line after the reload; a file postlogd makes itself (one
switching descriptors; the panel treats a missing log file as an empty tail, not removed while the container runs) is `0640` too, through
an error. That drain does not close the gap entirely: postlogd keeps writing to `maillog_file_permissions=0640` - Postfix's default is `0600`, which the
the renamed file until the reload reaches it, seconds later, and a line written unprivileged panel user could not read. `follow()` drains the
there after the drain is not read. Keeping the old file open until it has old inode once more before switching descriptors, and keeps it open and reads
stopped growing is the fix; it waits for the measurement below to say how it for a minute after: postlogd keeps writing to the renamed file until the
much it matters. reload makes it exit, seconds later (Postfix 3.10.13, src/postlogd/postlogd.c;
src/master/dgram_server.c, `dgram_server_abort()`). The panel treats a missing
log file as an empty tail, not an error.
**Delivery lines are read at the head of the line.** The pattern that turns a **Delivery lines are read at the head of the line.** The pattern that turns a
line into a row's status (`deliveryRe`) is matched after the timestamp, the line into a row's status (`deliveryRe`) is matched after the timestamp, the
@@ -428,10 +613,44 @@ inbound journal's. The tail of a line is the far end's words (a reply, a HELO
name), and a pattern that could start there let a stranger's HELO name file a name), and a pattern that could start there let a stranger's HELO name file a
delivery (audit 2026-10-12, M12). delivery (audit 2026-10-12, M12).
**The test message** (test-email, `2.12.0`) is the one row the panel writes
itself: Server › Health hands it to `sendmail(1)`, which tells nothing of the
queue id, and the journal-milter does not see local mail. The row is written
first, queued and with no queue id, and its Message-ID is awaited
(`logtail.AwaitMessageID`, in memory, ten minutes); cleanup's line
`<id>: message-id=<…>` (Postfix 3.10.13, `src/cleanup/cleanup_message.c:639`)
gives the row its queue id, and from then on its delivery lines update it as
any row's. A restart in the second between leaves it without one, and the
reconcile sweep closes it once the id is no longer awaited
(`CloseUnmatchedTestMessages`); an id awaited longer than that is not taken
any more. sendmail out of time keeps the row and the id - postdrop may have
queued the message all the same.
**The upstream's answer** on the inbound journal (inbound-upstream-result,
`2.10.0`). Mail accepted on port 25 is handed to its upstream by the same
`smtp` client as outbound mail, and its delivery lines look alike; what tells
them apart is the row. A delivery line no send-log row takes (`UpdateStatus`
changed nothing) is offered to the journal: the rows of its queue id and
recipient take the status, the host (`relay=`, `""` for `none` - a line
written without a session, Postfix 3.10.13, `src/smtp/smtp_trouble.c:273,
421`), the reply - the `(%s)` after the first `status=`, made printable and
cut to 255 bytes - and the line's time (`store.UpdateInboundUpstream`). Only
a `queued`, `held` or `deferred` row moves: `sent` and `bounced` are final, so
a line read twice after a restart changes nothing. The addresses are read by
Postfix's quoting (`loggedAddress`), so a quoted local part cannot end one
early. cleanup's `removed (canceled)` - a queue file never queued - detaches
the rows the milter had written for it. qmgr's expiry line, which names no
recipient, makes the rows of its queue id `bounced`, keeping the last reply
after "expired in the queue" (`ExpireInboundUpstream`). It is an update per
attempt, never a row: the journal's caps stand, and port-25 mail's lines are
still not stored (delivery-log-storage). A bounce Postfix sends to a stranger
is mail of its own queue id and matches no row.
**Read offset is persisted** (`logtail_state` table, in the baseline schema): the **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 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 512 bytes and the file's inode (not the device, which can change across a
files and a renamed file keeps its fingerprint - and on start resumes from it, 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 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 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 `mail.log.1` (left plain by `delaycompress`), and if that is the file the saved
@@ -440,7 +659,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 `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` 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 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. pre-existing log.
**A refusal's line, and a refusal the panel did not write.** A message refused **A refusal's line, and a refusal the panel did not write.** A message refused
@@ -450,7 +669,7 @@ the lines stored under it - only those of the minutes around the refusal, since
Postfix frees the id at once. A message the journal milter refused at Postfix frees the id at once. A message the journal milter refused at
`MAIL FROM` has none: the tailer reads smtpd's `NOQUEUE: milter-reject: MAIL `MAIL FROM` has none: the tailer reads smtpd's `NOQUEUE: milter-reject: MAIL
from <client>[<address>]: …; from=<…> … sasl_username=<login>` - the login is from <client>[<address>]: …; from=<…> … sasl_username=<login>` - the login is
Postfix's last field on an authenticated session (Postfix 3.7.11, Postfix's last field on an authenticated session (Postfix 3.10.13,
`src/smtpd/smtpd.c`, `smtpd_whatsup()`), and a line without one is no `src/smtpd/smtpd.c`, `smtpd_whatsup()`), and a line without one is no
application's - and puts it on the nearest refused row of that client application's - and puts it on the nearest refused row of that client
address, login and sender within two minutes address, login and sender within two minutes
@@ -469,7 +688,7 @@ a kill is covered as well as a clean stop.
**Expiry.** A message Postfix gives up on at the end of its queue lifetime has **Expiry.** A message Postfix gives up on at the end of its queue lifetime has
no line per recipient: qmgr writes one line for the message, `<id>: no line per recipient: qmgr writes one line for the message, `<id>:
from=<...>, status=expired, returned to sender` (`force-expired` after from=<...>, status=expired, returned to sender` (`force-expired` after
`postsuper -e`; Postfix 3.7.11, `src/qmgr/qmgr_active.c`, `postsuper -e`; Postfix 3.10.13, `src/qmgr/qmgr_active.c`,
`qmgr_active_done_25_generic()`), and the recipients' last lines say `qmgr_active_done_25_generic()`), and the recipients' last lines say
`deferred`. The tailer reads that line and closes the message's open rows - `deferred`. The tailer reads that line and closes the message's open rows -
each recipient's newest, `queued` or `deferred` - as `bounced`. each recipient's newest, `queued` or `deferred` - as `bounced`.
@@ -498,14 +717,18 @@ of the id's flags (`src/postqueue/showq_compat.c`, `showq_message()`).
**Which row a delivery line is.** The journal milter writes a row's **Which row a delivery line is.** The journal milter writes a row's
recipient as Postfix will log it: the address the client gave, its quoted recipient as Postfix will log it: the address the client gave, its quoted
local part unquoted and quoted again only where it needs to be (`"ab"@x` local part unquoted and quoted again only where it needs to be (`"ab"@x`
becomes `ab@x`, `"a b"@x` stays; Postfix 3.7.11, `tok822_parse.c`, becomes `ab@x`, `"a b"@x` stays; Postfix 3.10.13, `tok822_parse.c`,
`quote_822_local.c`), case kept, and a recipient given twice - in any case - `quote_822_local.c`), case kept, and a recipient given twice - in any case -
once, since cleanup keeps one (`cleanup_out_recipient.c`, `been_here()` once, since cleanup keeps one (`cleanup_out_recipient.c`, `been_here()`
folding case). A delivery line names it in `orig_to=` when Postfix rewrote folding case). A delivery line names it in `orig_to=` when Postfix rewrote
the address, in `to=` otherwise (`log_adhoc.c` writes `orig_to=` only then), the address, in `to=` otherwise (`log_adhoc.c` writes `orig_to=` only then),
and is matched without regard to case. It updates only the newest row of its and is matched without regard to case. It updates only the newest row of its
queue id and recipient: Postfix reuses a short queue id once the message has queue id and recipient: Postfix reuses a short queue id once the message has
left the queue, and an old row must not be rewritten. `UpdateStatus`'s transaction left the queue, and an old row must not be rewritten. Since `2.10.1` the
image makes long, non-repeating queue ids (`enable_long_queue_ids=yes`,
[build/postfix-config.sh](../build/postfix-config.sh)): the time in seconds,
then the microseconds, `z` and the inode; a queue file from before keeps its
short name, and every pattern of the panel takes both. `UpdateStatus`'s transaction
starts with a write, so a commit by the `dmarc-ingest` process in between starts with a write, so a commit by the `dmarc-ingest` process in between
cannot fail it with `database is locked`. cannot fail it with `database is locked`.
Now that the log survives the container, reaching this path should mean the Now that the log survives the container, reaching this path should mean the
@@ -513,7 +736,7 @@ lines are gone for good — rotated past fourteen files while the panel was down
or deleted — or that the tailer was not reading. or deleted — or that the tailer was not reading.
Whether the sweep earns its place is measured, not argued (audit 2026-10-12, Whether the sweep earns its place is measured, not argued (audit 2026-10-12,
H3). `delivery_log_days` (migration `0007`) keeps, per UTC day for a year: H3). `delivery_log_days` (migration `0007`) keeps, per day of the container's zone for a year:
`reconcile_closed`, the rows the sweep closed; `reconcile_missed`, those of `reconcile_closed`, the rows the sweep closed; `reconcile_missed`, those of
them whose final line - sent, bounced, or the message's expiry - was in them whose final line - sent, bounced, or the message's expiry - was in
`mail.log` or `mail.log.1` when the sweep closed them - the tailer did not `mail.log` or `mail.log.1` when the sweep closed them - the tailer did not
@@ -570,8 +793,12 @@ reads them:
send-log row carries is deleted (`PruneSendLogLines`), at start and after send-log row carries is deleted (`PruneSendLogLines`), at start and after
every six-hourly send-log sweep. That removes the lines of rows the sweep every six-hourly send-log sweep. That removes the lines of rows the sweep
deleted and the lines that never had a row. The send log is asked once per deleted and the lines that never had a row. The send log is asked once per
queue id; the statement still walks the table, about a second per million queue id; the statement still walks the table, with the connection taken
lines, with the connection taken meanwhile. meanwhile. Measured on the test server (2 cores, 2026-10-15) with 1.37
million lines, 120 days at 1 900 messages a day: 0.4 s when there is
nothing to delete, 1.9 s to delete 30 days' lines (342 000) after a gap -
under `busy_timeout`'s 5 s, and the longest a second process's write waited
meanwhile was 1.8 s (assumptions D-S7, D-arch:516).
- **Which lines are a row's.** Postfix reuses a queue id once the message has - **Which lines are a row's.** Postfix reuses a queue id once the message has
left the queue, and a row outlives that by months. `SendLogLines` gives a row left the queue, and a row outlives that by months. `SendLogLines` gives a row
the lines of its queue id dated from one hour before the row was written to the lines of its queue id dated from one hour before the row was written to
@@ -585,8 +812,9 @@ reads them:
catching up at start it writes a line only if the same line is not there. catching up at start it writes a line only if the same line is not there.
- **Not stored:** what the tailer never read — the tail of a file rotated away - **Not stored:** what the tailer never read — the tail of a file rotated away
while the panel was down. while the panel was down.
- **Size:** six to ten lines of about 200 bytes per message — more than the row - **Size:** about six lines of about 150 bytes per message — more than the row
itself. The table is in the database and therefore in a full backup, which itself (the live installation, 2026-10-15: 6.0 lines per queue id, 146 bytes
on average, over its first 18 lines; assumptions D-arch:532). The table is in the database and therefore in a full backup, which
`log/` is not ([security.md](security.md) § Backup and domain export). `log/` is not ([security.md](security.md) § Backup and domain export).
**Two one-shot reads** sit beside the follow loop and are unrelated to it, both **Two one-shot reads** sit beside the follow loop and are unrelated to it, both
@@ -611,7 +839,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: a bare `Server` and ask which pattern answers a path:
- **public** — `/healthz` (liveness), `/license` (the embedded `LICENSE`), - **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 - **authenticated** — everything else, behind `RequireAuth`: a request without
a live session is redirected to `/login`. a live session is redirected to `/login`.
@@ -633,8 +863,9 @@ only reads (the delete confirmation at `GET …/delete` renders a form).
| Outbound › Log, Queue | `GET /outbound/log` · `/outbound/log/fragment` · `/outbound/log/{id}` · `GET /outbound/queue` · `/outbound/queue/fragment` | | Outbound › Log, Queue | `GET /outbound/log` · `/outbound/log/fragment` · `/outbound/log/{id}` · `GET /outbound/queue` · `/outbound/queue/fragment` |
| Outbound › DMARC reports | `GET /outbound/dmarc` · `…/dmarc/domains/{id}` · `…/dmarc/reports/{id}` — registered only with `DMARC_REPORTS_ENABLE=true` | | 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 › Domains | `GET, POST /inbound/domains` · `/inbound/domains/{id}` · `POST …/dns-recheck`, `…/upstream`, `…/recipients` · `GET, POST …/delete` — registered only with `INBOUND_RELAY_ENABLE=true` |
| Server | `GET /server/health` (+ `/fragment`) · `POST /server/health/recheck`, `/server/health/reload` · `GET /server/log` (+ `/fragment`) · `GET, POST /server/backup` · `POST /server/backup/import` · `/server/users`, `…/new`, `…/{uid}`, `…/{uid}/delete` · `GET, POST /server/settings` · `GET /server/components` | | Inbound › Log, Queue, Filter lists | `GET /inbound/log` · `GET /inbound/queue` · `/inbound/queue/fragment` · `GET, POST /inbound/filter-lists` · `POST /inbound/filter-lists/{id}/delete` — registered only with `INBOUND_RELAY_ENABLE=true` |
| 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) | | User menu | `GET /help` · `POST /logout` (public mux) |
An application is addressed under its domain, and an application id that does An application is addressed under its domain, and an application id that does
@@ -676,7 +907,11 @@ is not.
([handlers/handlers_inbound.go](../internal/web/handlers/handlers_inbound.go)): ([handlers/handlers_inbound.go](../internal/web/handlers/handlers_inbound.go)):
`requireInbound` (the list), `requireInboundDomain` (one domain — page, `requireInbound` (the list), `requireInboundDomain` (one domain — page,
upstream, recipients, DNS re-check) and `requireInboundGlobal` (add, delete). upstream, recipients, DNS re-check) and `requireInboundGlobal` (add, delete).
All three answer 404 alike. All three answer 404 alike. The **Inbound log** (`/inbound/log`) is open to
anyone who reaches an inbound domain (`requireInbound`) and shows the rows
of their domains only; **Filter lists** (`/inbound/filter-lists`) are the
instance's and the global role's (`requireInboundGlobal`)
([handlers/handlers_inbound_log.go](../internal/web/handlers/handlers_inbound_log.go)).
- **The DMARC hub** is open to anyone with outbound reach for the reports of - **The DMARC hub** is open to anyone with outbound reach for the reports of
their own domains; the *Ingest* box (server-wide statistics) is rendered for their own domains; the *Ingest* box (server-wide statistics) is rendered for
the global role only. the global role only.
@@ -700,9 +935,15 @@ a refresh cannot differ. HTMX refreshes them every 5 s while the operator is
active on the page, every 30 s when the tab is visible but idle, and not at all active on the page, every 30 s when the tab is visible but idle, and not at all
when it is hidden — scheduled in `panel.js` via `data-poll`, not when it is hidden — scheduled in `panel.js` via `data-poll`, not
`hx-trigger="every …"`, because a trigger filter is evaluated with `new `hx-trigger="every …"`, because a trigger filter is evaluated with `new
Function`, which the CSP forbids. Polling does not extend the session idle Function`, which the CSP forbids. The next refresh is scheduled when a
timeout (only non-`HX-Request` `GET`s and mutating requests count as request of the box ends, however it ended: a refresh that failed - an error
activity). The Outbound queue's retry-policy box is outside the polled region: answer, the panel restarting, the network gone - is tried again after twice
the wait, doubling with each failure in a row up to a minute, and the wait
is back to normal after one that works. A request without a session answers
htmx with `HX-Redirect: /login` (status 401), and htmx loads the sign-in
page in place of the whole page; a browser gets the 303 as before. Polling
does not extend the session idle timeout (only non-`HX-Request` `GET`s and
mutating requests count as activity). The Outbound queue's retry-policy box is outside the polled region:
it is the start-up `postconf -h` snapshot (see [Panel it is the start-up `postconf -h` snapshot (see [Panel
binary](#panel-binary-cmdpanel)), not a live re-read. binary](#panel-binary-cmdpanel)), not a live re-read.
@@ -725,9 +966,8 @@ is one layout and one way to render.
package (`NewOutDomain`, `NewHelp`, …) that the handler fills. The shell is package (`NewOutDomain`, `NewHelp`, …) that the handler fills. The shell is
derived from `Meta` and the feature flags (`SetInboundEnabled`, derived from `Meta` and the feature flags (`SetInboundEnabled`,
`SetDMARCEnabled`) in one place, `Engine.shell`, so which entries the menu `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 has is decided once. No page's data is a map: the health sampler too
in the handlers is the health sampler's, which is turned into a typed returns a typed `health.Machine`.
`Health` before rendering.
- **The component kit.** [components.html](../internal/web/view/templates/components.html) - **The component kit.** [components.html](../internal/web/view/templates/components.html)
holds one `{{define}}` per partial (page head, postmark, box and its head and 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, foot, flash, health card, DNS record, facts, side menu, timeline, log pane,
@@ -748,8 +988,9 @@ is one layout and one way to render.
`panel.css` (the mockups' stylesheet, rule for rule), `htmx.min.js`, `panel.js`, `panel.css` (the mockups' stylesheet, rule for rule), `htmx.min.js`, `panel.js`,
the IBM Plex subset and the logo files. Served with a content-derived `ETag`, the IBM Plex subset and the logo files. Served with a content-derived `ETag`,
and `/static/` does not list its files. The kit page and `/static/` does not list its files. The kit page
`GET /server/components` renders every partial in every state from fixtures `GET /server/components` renders every partial in every state from the data
(global role only). of `KitPage` ([kit.go](../internal/web/view/kit.go), production code that the
guard tests render too; global role only).
**Guard tests** hold the design in place; they run in `go test ./...` and CI. **Guard tests** hold the design in place; they run in `go test ./...` and CI.
They read their rules from the accepted mockups They read their rules from the accepted mockups
@@ -766,8 +1007,8 @@ has the component skeleton of its mockup), `TestRedesignedPagesLoadOnlyTheKit`,
ratchet list `internal/web/view/legacy_pages.txt` is empty and can only shrink; ratchet list `internal/web/view/legacy_pages.txt` is empty and can only shrink;
the CI step the CI step
[design-first](../.github/scripts/design-first.sh) rejects a commit that [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 changes a guard together with the code it judges, and a template that is
byte-for-byte return of one from history. The design contract and the process 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. are in [plans/panel-redesign.md](plans/panel-redesign.md) § Enforcement.
### Security headers, CSP and scripts ### Security headers, CSP and scripts
@@ -776,7 +1017,7 @@ are in [plans/panel-redesign.md](plans/panel-redesign.md) § Enforcement.
on every response: `Content-Security-Policy: default-src 'self'; object-src on every response: `Content-Security-Policy: default-src 'self'; object-src
'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'`, 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'`,
`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`,
`Referrer-Policy: no-referrer`, and `Strict-Transport-Security` when the cookie `Referrer-Policy: same-origin`, and `Strict-Transport-Security` when the cookie
is `Secure`. Because the policy has no exemptions, no template carries an is `Secure`. Because the policy has no exemptions, no template carries an
inline `<script>`, an event-handler attribute or `style="…"` — the page's inline `<script>`, an event-handler attribute or `style="…"` — the page's
behaviour is `htmx.min.js` plus `panel.js` (copy and *fill* buttons, the behaviour is `htmx.min.js` plus `panel.js` (copy and *fill* buttons, the
@@ -786,8 +1027,14 @@ with scripts off. htmx is told not to inject its indicator stylesheet
(`htmx-config` meta), which the CSP would otherwise have to allow. (`htmx-config` meta), which the CSP would otherwise have to allow.
State-changing requests (every non-`GET`/`HEAD`/`OPTIONS`) pass `originAllowed` State-changing requests (every non-`GET`/`HEAD`/`OPTIONS`) pass `originAllowed`
first: `Sec-Fetch-Site` must say `same-origin`, or `Origin` must name the first: `Sec-Fetch-Site` must say `same-origin`, or, where the browser does not
panel's host; a request with neither header is let through (an accepted risk). send it (an older one, or a current one over plain HTTP at a name other than
`localhost`), `Origin` must name the panel's host - which a form of the panel
carries under its `Referrer-Policy: same-origin`, the page's own origin (under
`no-referrer` it would be `null` and refused); a request with neither header
is refused too (origin-strict, `2.10.4`; it was let through before, as an
accepted risk). A refusal logs the `Sec-Fetch-Site`,
`Origin` and `Host` it compared.
There are no CSRF tokens — see [security.md](security.md). There are no CSRF tokens — see [security.md](security.md).
### Errors shown to users ### Errors shown to users
@@ -796,7 +1043,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 next to the field, or in a notice above the first box). Anything else — a
database error, `saslpasswd2`, a Postfix reload — is logged database error, `saslpasswd2`, a Postfix reload — is logged
and the page gets a fixed sentence saying so; the error text never reaches the 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). (`Engine.Render` buffers).
### Sessions ### Sessions
@@ -835,19 +1083,19 @@ the cookie and the restored row's `expires_at` has not passed.
## Code layers ## Code layers
Multi-store writes that must land in more than one place (SQLite row, Multi-store writes that must land in more than one place (SQLite row,
`sasldb2` entry, Postfix map, OpenDKIM table) go through a service, which is `sasldb2` entry, Postfix map, DKIM key or selector table) go through a service, which is
also where the rollback of a partial failure lives. Handlers may call also where the rollback of a partial failure lives. Handlers may call
`store` directly for single-table reads and simple writes (sessions, users, `store` directly for single-table reads and simple writes (sessions, users,
send-log queries); the first-run setup-token file is read and written in send-log queries); the first-run setup-token file is read and written in
`web` itself. The adapters below the services are the only code that knows `web` itself. The adapters below the services are the only code that knows
about Postfix, OpenDKIM, DNS or the log file, which is what makes them about Postfix, the milter protocol, DNS or the log file, which is what makes them
substitutable in tests — `milter.Store`, `app.SenderMaps` and substitutable in tests — `milter.Store`, `app.SenderMaps` and
`logtail.StatusStore` are the seams the unit tests replace with fakes. `logtail.StatusStore` are the seams the unit tests replace with fakes.
```mermaid ```mermaid
flowchart TB flowchart TB
subgraph cmd ["cmd — composition root"] subgraph cmd ["cmd — composition root"]
panel["panel: HTTP + journal-milter + log-tailer"] panel["panel: HTTP + sender check + DKIM signer + journal-milter + log-tailer"]
backupcli["selfpost-backup CLI"] backupcli["selfpost-backup CLI"]
end end
subgraph web ["internal/web — HTTP surface"] subgraph web ["internal/web — HTTP surface"]
@@ -905,9 +1153,9 @@ flowchart TB
dmarcSvc --> postfix dmarcSvc --> postfix
``` ```
The four roles inside the `panel` process (HTTP server, journal-milter, The roles inside the `panel` process (HTTP server, sender check, DKIM signer,
log-tailer, rate-limit recalc) share one binary and one SQLite handle on journal-milter, log-tailer, rate-limit recalc, notifier) share one binary and
purpose — see one SQLite handle on purpose — see
[Panel binary](#panel-binary-cmdpanel) for why, and *Persistence* below for the [Panel binary](#panel-binary-cmdpanel) for why, and *Persistence* below for the
single-connection trade-off that follows from it. single-connection trade-off that follows from it.
@@ -919,7 +1167,7 @@ single-connection trade-off that follows from it.
|---|---| |---|---|
| `selfpost.db` | SQLite panel state (users, sessions, domains, apps, send log, L2 limits, log-tailer offset, inbound relay, DMARC reports) — see [Database](#database) and [schema-migrations.md](schema-migrations.md) | | `selfpost.db` | SQLite panel state (users, sessions, domains, apps, send log, L2 limits, log-tailer offset, inbound relay, DMARC reports) — see [Database](#database) and [schema-migrations.md](schema-migrations.md) |
| `setup-token` | First-run setup token file | | `setup-token` | First-run setup token file |
| `opendkim/` | DKIM keys + tables | | `dkim/` | DKIM keys (`keys/<name>/<selector>.private`) and the selector table (`selectors`) the signer reads; the panel's alone, `0700` / `0600` — § DKIM signing |
| `sasl/sasldb2` | Application SASL credentials | | `sasl/sasldb2` | Application SASL credentials |
| `postfix/sender_login_maps` | Login → From binding | | `postfix/sender_login_maps` | Login → From binding |
| `postfix/relay_domains` | Inbound domains accepted on port 25 | | `postfix/relay_domains` | Inbound domains accepted on port 25 |
@@ -929,9 +1177,11 @@ single-connection trade-off that follows from it.
| `postfix/dmarc_recipients` | Allow-listed DMARC aggregate report addresses | | `postfix/dmarc_recipients` | Allow-listed DMARC aggregate report addresses |
| `postfix/dmarc_transport` | Pipe transport for report ingest | | `postfix/dmarc_transport` | Pipe transport for report ingest |
| `postfix/dmarc_relay_domains` | Domains accepted for DMARC report delivery | | `postfix/dmarc_relay_domains` | Domains accepted for DMARC report delivery |
| `postfix/queue/` | Postfix transit mail (deferred/active); survives container recreate | | `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) | | `log/mail.log` | Postfix delivery log + rotated copies (excluded from backups) |
| `manifest.json` | Backup version stamp (consumed on restore) | | `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) |
Not in `/data`: TLS certificates for the panel (reverse-proxy mount) — though Not in `/data`: TLS certificates for the panel (reverse-proxy mount) — though
full backups also archive the operator's `./certs` PEM files when present. full backups also archive the operator's `./certs` PEM files when present.
@@ -939,8 +1189,9 @@ full backups also archive the operator's `./certs` PEM files when present.
### Database ### Database
The schema is an embedded baseline, `internal/store/migrations/0001_init.sql`, The schema is an embedded baseline, `internal/store/migrations/0001_init.sql`,
and the migrations after it (`0002_inbound_spam_log.sql`), applied by and the migrations after it (`0002` to `0014` today; the next is `0015`),
`store.Open` in file order; `PRAGMA user_version` counts the files applied and applied by `store.Open` in file order; `PRAGMA user_version` counts the files
applied and
`PRAGMA application_id` is `0x53503230` ("SP20"). There is no 1.x migration `PRAGMA application_id` is `0x53503230` ("SP20"). There is no 1.x migration
chain. **2.0 starts from an empty data directory:** a database with a schema chain. **2.0 starts from an empty data directory:** a database with a schema
version but without the 2.x `application_id` — what every 1.x file looks like — version but without the 2.x `application_id` — what every 1.x file looks like —
@@ -957,7 +1208,11 @@ The tables that carry the panel's model:
the user's own address, edited on Account and set by the global role on the the user's own address, edited on Account and set by the global role on the
user form; `all_domains` / `all_inbound_domains` widen a *domain* user to user form; `all_domains` / `all_inbound_domains` widen a *domain* user to
every domain of that direction, including ones added later (with a flag set every domain of that direction, including ones added later (with a flag set
the assignment rows are ignored). the assignment rows are ignored); `email_confirmed_at` is when `email` was
confirmed, `''` while it is not (v13).
- **`user_links`** — the one-time links that confirm an address and reset a
password (v13): the token's SHA-256, `purpose`, the address it went to, its
expiry; one live link per user and purpose.
- **`user_domains`** and **`user_inbound_domains`** — the outbound and inbound - **`user_domains`** and **`user_inbound_domains`** — the outbound and inbound
domains assigned to a *domain* user. Separate tables: the same name on both domains assigned to a *domain* user. Separate tables: the same name on both
is two assignments, and a sending domain gives no inbound access or the other is two assignments, and a sending domain gives no inbound access or the other
@@ -975,11 +1230,21 @@ The tables that carry the panel's model:
`application_addresses`, `rate_limits` (no `allowed_ips` column), `send_log`, `application_addresses`, `rate_limits` (no `allowed_ips` column), `send_log`,
`send_log_lines` (what Postfix logged under a queue id — § Log tailer), `send_log_lines` (what Postfix logged under a queue id — § Log tailer),
`logtail_state`, `inbound_domains`, `inbound_transports`, `logtail_state`, `inbound_domains`, `inbound_transports`,
`inbound_recipients`, `dmarc_reports`, `dmarc_report_records`. `inbound_recipients`, `inbound_spam_log` (port 25's decisions, § Postfix),
`inbound_spam_list` (the filter lists), `dmarc_reports`,
`dmarc_report_records`, `notify_state` and `notify_addressees` (what the
notifier has said and to whom, § Notifications), `sender_check_days` (the
sender check's downtime and the mail it deferred, per day of the container's zone) and
`delivery_log_days` (the queue sweep's measurements, § Log tailer).
The panel is the only writer; SQLite runs in WAL mode on one connection (see Two processes write: the panel, on one connection, and `dmarc-ingest`, which
[Panel binary](#panel-binary-cmdpanel)), which is why a full backup snapshots Postfix starts per report. SQLite runs in WAL mode (see
with `VACUUM INTO` instead of copying the file. [Panel binary](#panel-binary-cmdpanel)) and allows one write transaction at a
time; every transaction that may write takes the lock at its `BEGIN`
(`_txlock=immediate`, internal/store), so the other process waits for it
within `busy_timeout`, and a schema migration is read and applied under that
lock, once. A full backup snapshots with `VACUUM INTO`, which gives a
consistent copy of a live database, instead of copying the file.
**Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90), which **Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90), which
the stored log lines follow; the stored log lines follow;
@@ -987,18 +1252,23 @@ the stored log lines follow;
`postfix reload` in `postrotate` — see § Log tailer above). `postfix reload` in `postrotate` — see § Log tailer above).
**Restore:** panel button or `selfpost-backup` CLI — self-contained archive: **Restore:** panel button or `selfpost-backup` CLI — self-contained archive:
`data/` (SQLite snapshot + tree minus `log/`, the setup token and any `tls/` `data/` (SQLite snapshot + tree minus `log/`, the Postfix queue
under `/data`), `docker-compose.yml`, `.env`, and `certs/` when present; `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 version check on restore. Requires the project directory mounted read-only at
`SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` in the default compose file). On the `SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` in the default compose file). On the
first successful boot after restore, the panel runs one **Resync** — OpenDKIM's first successful boot after restore, the panel runs one **Resync**
tables, Postfix's sender map, inbound relay maps (when (`mailStack.Resync`, `cmd/panel/httpserver.go`) — the DKIM selector table
`INBOUND_RELAY_ENABLE=true`), and DMARC maps (when `DMARC_REPORTS_ENABLE=true`) (and the keys of deleted domains removed), Postfix's sender map, inbound relay
are re-derived from SQLite and both daemons are reloaded, so drift between the maps and the filter lists (when `INBOUND_RELAY_ENABLE=true`), and DMARC maps
extracted archive and the database is healed before mail flows. Manual (when `DMARC_REPORTS_ENABLE=true`) are re-derived from SQLite and Postfix is
reloaded, so drift between the extracted archive and the database is healed
before mail flows; the signer reads the new table without a reload. Manual
`POST /server/health/reload` (the *Reload configuration* button on Server › `POST /server/health/reload` (the *Reload configuration* button on Server ›
Health) resyncs OpenDKIM, the sender map, and Health) writes the same files in the same order (`HandleReload`; before
inbound maps only — not DMARC maps. Stopped-container `2.5.4` it left out the filter lists and the DMARC maps), and on a failure
stops at that step and names it on the page. Stopped-container
`tar` of `./data` alone remains possible for state-only copies (see guide). `tar` of `./data` alone remains possible for state-only copies (see guide).
**Optional encryption** of the two secret-bearing downloads **Optional encryption** of the two secret-bearing downloads
@@ -1036,12 +1306,17 @@ unsupported rather than as a missing doc:
(`/data/selfpost.db`), `SELFPOST_SETUP_TOKEN_FILE` (`/data/selfpost.db`), `SELFPOST_SETUP_TOKEN_FILE`
(`/data/setup-token`), `PANEL_HTTP_ADDR` (`:8080`), (`/data/setup-token`), `PANEL_HTTP_ADDR` (`:8080`),
`JOURNAL_MILTER_SOCKET` (`/run/selfpost/journal.sock`), `JOURNAL_MILTER_SOCKET` (`/run/selfpost/journal.sock`),
`SENDER_CHECK_SOCKET` (`/run/selfpost/sender.sock`), `MAIL_LOG` `SENDER_CHECK_SOCKET` (`/run/selfpost/sender.sock`),
`DKIM_SIGN_SOCKET` (`/run/selfpost/sign.sock`) and `DKIM_SIGN_LOCAL_SOCKET`
(`/run/selfpost/sign-local.sock` — the DKIM signer's, read by the panel,
`build/postfix-config.sh` and `build/postfix-wrapper.sh`, which must agree),
`INBOUND_JOURNAL_MILTER_SOCKET` (`/run/selfpost/inbound-journal.sock` — the
inbound journal milter's socket, read by the panel, `build/postfix-config.sh`
and `build/postfix-wrapper.sh`, which must agree), `MAIL_LOG`
(`/data/log/mail.log` — read by the panel and written by Postfix, so a change (`/data/log/mail.log` — read by the panel and written by Postfix, so a change
here has to be matched in `build/postfix-config.sh`), here has to be matched in `build/postfix-config.sh`),
`PANEL_COOKIE_SECURE` (`true`), `OPENDKIM_SOCKET` `PANEL_COOKIE_SECURE` (`true`), `DKIM_DIR` (`/data/dkim` — the keys and
(`/run/opendkim/opendkim.sock`), `OPENDKIM_DIR` (`/data/opendkim`), the selector table), `DKIM_SELECTOR_DEFAULT` (`selfpost`), `SASL_DB_PATH`
`DKIM_SELECTOR_DEFAULT` (`selfpost`), `SASL_DB_PATH`
(`/data/sasl/sasldb2`), `SASL_REALM` (defaults to `SELFPOST_HOSTNAME`), (`/data/sasl/sasldb2`), `SASL_REALM` (defaults to `SELFPOST_HOSTNAME`),
`POSTFIX_DIR` (`/data/postfix`), `POSTFIX_SENDER_LOGIN_MAPS` `POSTFIX_DIR` (`/data/postfix`), `POSTFIX_SENDER_LOGIN_MAPS`
(`/data/postfix/sender_login_maps` — read by Postfix config only; the panel (`/data/postfix/sender_login_maps` — read by Postfix config only; the panel
@@ -1056,9 +1331,15 @@ unsupported rather than as a missing doc:
`POSTFIX_DMARC_RECIPIENTS` (`/data/postfix/dmarc_recipients`), `POSTFIX_DMARC_RECIPIENTS` (`/data/postfix/dmarc_recipients`),
`POSTFIX_DMARC_TRANSPORT` (`/data/postfix/dmarc_transport`), `POSTFIX_DMARC_TRANSPORT` (`/data/postfix/dmarc_transport`),
`POSTFIX_DMARC_RELAY_DOMAINS` (`/data/postfix/dmarc_relay_domains`), `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 `SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` — operator project directory for
full backups; mount `.:/selfpost-deploy:ro` in compose). full backups; mount `.:/selfpost-deploy:ro` in compose), `ANTISPAM_DIR`
(`/data/antispam` — the filter lists and the filter's key, which the spam
filter's container mounts). `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 and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`),
`MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`), `MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`),
`MILTER_WAIT_TIMEOUT` (`30` seconds). `MILTER_WAIT_TIMEOUT` (`30` seconds).
-164
View File
@@ -1,164 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>example.org · Outbound domains · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<header class="sp-bar">
<div class="sp-wrap">
<nav>
<ul>
<li><a class="sp-brand" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a></li>
</ul>
<ul class="sp-sections">
<li><a href="status.html">Overview</a></li>
<li><a href="domain.html" aria-current="true">Outbound</a></li>
<li><a href="#">Inbound</a></li>
<li><a href="#">Server</a></li>
</ul>
<ul class="sp-user">
<li>
<details class="dropdown">
<summary>admin</summary>
<ul dir="rtl">
<li><a href="#">Account</a></li>
<li><a href="#">Help</a></li>
<li><a href="login.html">Sign out</a></li>
</ul>
</details>
</li>
</ul>
</nav>
</div>
</header>
<div class="sp-pages">
<div class="sp-wrap">
<a href="#" aria-current="true">Domains</a>
<a href="#">Log</a>
<a href="#">Queue</a>
<a href="#">DMARC reports</a>
</div>
</div>
<main class="sp-wrap">
<div class="sp-head">
<div>
<p class="sp-kicker"><a href="#">Outbound domains</a> /</p>
<h1>example.org</h1>
</div>
<small>1 284 messages in 30 days · peak 96 msg/h · average 1.8 msg/h</small>
</div>
<section class="sp-sheet">
<header><span class="sp-no">01</span><h2>Connection</h2><small>The same for every domain</small></header>
<div class="sp-body sp-cols">
<dl class="sp-kv">
<dt>Server</dt>
<dd><span class="sp-value">mail.example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
</dl>
<dl class="sp-kv">
<dt>Ports</dt>
<dd><span class="sp-value">465 — SSL/TLS (implicit)<br>587 — STARTTLS (submission)</span></dd>
</dl>
</div>
</section>
<section class="sp-sheet">
<header>
<span class="sp-no">02</span><h2>DNS records</h2>
<small>Checked 3 min ago</small>
<form method="post" action="#"><button type="submit" class="outline secondary sp-btn-sm">Re-check</button></form>
</header>
<div class="sp-record">
<div class="sp-record-head"><h3>DKIM</h3><span class="sp-tag ok">published</span><small>Not a secret · selector <code>sp2026</code></small></div>
<dl class="sp-kv">
<dt>Host</dt><dd><span class="sp-value">sp2026._domainkey.example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
<dt>Type</dt><dd><span class="sp-value plain">TXT</span></dd>
<dt>Value</dt><dd><span class="sp-value">v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3kqV0mJd8e2f1Yb7Qw5uT9sLr4nHc6pZaXo1vKe0yWm2gBt8RjN5dCq3hUf7lPs9aEi4oMz6xTb1kGv0nYw…IDAQAB</span><button type="button" class="outline sp-copy">Copy</button></dd>
</dl>
</div>
<div class="sp-record">
<div class="sp-record-head"><h3>SPF</h3><span class="sp-tag fail">mismatch</span><small>Merge into the existing record — never publish a second one</small></div>
<dl class="sp-kv">
<dt>Host</dt><dd><span class="sp-value">example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
<dt>Type</dt><dd><span class="sp-value plain">TXT</span></dd>
<dt>Value</dt><dd><span class="sp-value">v=spf1 ip4:203.0.113.25 ~all</span><button type="button" class="outline sp-copy">Copy</button></dd>
<dt>In DNS now</dt><dd><span class="sp-value">v=spf1 include:_spf.google.com ~all</span></dd>
</dl>
<p class="sp-note fail">The published record does not authorize 203.0.113.25. Add <code>ip4:203.0.113.25</code> before <code>~all</code>.</p>
</div>
<div class="sp-record">
<div class="sp-record-head"><h3>DMARC</h3><span class="sp-tag ok">published</span><small>Report address from <a href="#">Settings</a></small></div>
<dl class="sp-kv">
<dt>Host</dt><dd><span class="sp-value">_dmarc.example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
<dt>Type</dt><dd><span class="sp-value plain">TXT</span></dd>
<dt>Value</dt><dd><span class="sp-value">v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
</dl>
<p class="sp-note"><code>p=none</code> does not affect delivery. Tighten to <code>quarantine</code>, then <code>reject</code>, once the <a href="#">reports</a> look clean.</p>
</div>
</section>
<section class="sp-sheet">
<header>
<span class="sp-no">03</span><h2>Applications</h2>
<small>SASL logins that may send as this domain</small>
<a href="#" role="button" class="sp-btn-sm">Add application</a>
</header>
<table class="sp-apps">
<thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th></th></tr></thead>
<tbody>
<tr>
<td class="sp-login">prod-server</td>
<td>*@example.org</td>
<td>1 102 msg · peak 96/h</td>
<td><span class="sp-tag ok">200 / h</span></td>
<td><a class="sp-link-btn" href="#">Edit</a><a class="sp-link-btn" href="#">New password</a><a class="sp-link-btn danger" href="#">Delete</a></td>
</tr>
<tr>
<td class="sp-login">alerts</td>
<td>alerts@example.org, noc@example.org</td>
<td>182 msg · peak 14/h</td>
<td><span class="sp-tag">domain</span> <span class="sp-tag ok">2 IPs</span></td>
<td><a class="sp-link-btn" href="#">Edit</a><a class="sp-link-btn" href="#">New password</a><a class="sp-link-btn danger" href="#">Delete</a></td>
</tr>
</tbody>
</table>
</section>
<section class="sp-sheet sp-ledger">
<header><span class="sp-no">04</span><h2>Domain settings</h2><small>Rarely changed</small></header>
<details>
<summary><strong>DMARC reports</strong><span class="sp-muted">Same as Settings — dmarc@mail.example.org</span><span class="sp-tag">default</span></summary>
<div class="sp-detail"><p class="sp-muted">The form opens here.</p></div>
</details>
<details>
<summary><strong>Rate limit</strong><span class="sp-muted">Auto ×3 — 288 messages / hour, recalculated yesterday</span><span class="sp-tag ok">active</span></summary>
<div class="sp-detail"><p class="sp-muted">The form opens here.</p></div>
</details>
<details>
<summary><strong>Export</strong><span class="sp-muted">DKIM key and applications as one file, optionally encrypted</span><span></span></summary>
<div class="sp-detail"><p class="sp-muted">The form opens here.</p></div>
</details>
<details>
<summary><strong>Delete domain</strong><span class="sp-muted">Removes the DKIM key and both applications</span><span></span></summary>
<div class="sp-detail"><p class="sp-muted">The confirmation opens here.</p></div>
</details>
</section>
<footer class="sp-foot">
SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty
</footer>
</main>
</body>
</html>
-37
View File
@@ -1,37 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<div class="sp-signin">
<div class="sp-signin-card">
<img src="../../selfpost-stamp.svg" alt="SelfPost">
<section class="sp-sheet">
<header><span class="sp-no">→</span><h2>Sign in</h2></header>
<div class="sp-body">
<form method="post" action="status.html">
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username" required>
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
<button type="submit">Sign in</button>
</form>
</div>
</section>
<p class="sp-tagline">YOUR SMTP. YOUR RULES.</p>
</div>
</div>
<footer class="sp-wrap sp-foot">
© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty
</footer>
</body>
</html>
-136
View File
@@ -1,136 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Overview · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<header class="sp-bar">
<div class="sp-wrap">
<nav>
<ul>
<li><a class="sp-brand" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a></li>
</ul>
<ul class="sp-sections">
<li><a href="status.html" aria-current="page">Overview</a></li>
<li><a href="domain.html">Outbound</a></li>
<li><a href="#">Inbound</a></li>
<li><a href="#">Server</a></li>
</ul>
<ul class="sp-user">
<li>
<details class="dropdown">
<summary>admin</summary>
<ul dir="rtl">
<li><a href="#">Account</a></li>
<li><a href="#">Help</a></li>
<li><a href="login.html">Sign out</a></li>
</ul>
</details>
</li>
</ul>
</nav>
</div>
</header>
<main class="sp-wrap">
<div class="sp-head">
<div>
<p class="sp-kicker">mail.example.org</p>
<h1>Overview</h1>
</div>
<small>Updated 14:02:31 UTC · refreshes every 10 s</small>
</div>
<section class="sp-sheet sp-verdict">
<div class="sp-postmark warn"><span>SELFPOST</span><b>WARN</b><span>1 OF 6 CHECKS</span></div>
<div>
<h2>Mail is flowing. One thing needs attention.</h2>
<p>The TLS certificate expires in 12 days and has not renewed yet. Everything else is healthy.</p>
</div>
</section>
<section class="sp-sheet sp-ledger">
<header><span class="sp-no">01</span><h2>Checks</h2><small>Open a row for its detail</small></header>
<details>
<summary><strong>Processes</strong><span class="sp-muted">5 of 5 running</span><span class="sp-tag ok">ok</span></summary>
<div class="sp-detail">
<table>
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
<tbody>
<tr><td>postfix</td><td><span class="sp-tag ok">running</span></td><td class="sp-muted">pid 41, uptime 18 days</td></tr>
<tr><td>opendkim</td><td><span class="sp-tag ok">running</span></td><td class="sp-muted">pid 39, uptime 18 days</td></tr>
</tbody>
</table>
</div>
</details>
<details open>
<summary><strong>TLS certificate</strong><span class="sp-muted">Expires in 12 days — renewal has not run</span><span class="sp-tag warn">warn</span></summary>
<div class="sp-detail">
<dl class="sp-kv">
<dt>Expires</dt><dd><span class="sp-value">2026-10-03 09:14 UTC</span></dd>
<dt>Names</dt><dd><span class="sp-value">mail.example.org</span></dd>
</dl>
<p class="sp-note">Renewal normally runs 30 days before expiry. Check that port 80 is reachable and look for <code>acme</code> lines in the <a href="#">system log</a>.</p>
</div>
</details>
<details>
<summary><strong>Machine</strong><span class="sp-muted">CPU 12 % · memory 41 % · ↓ 18 kB/s ↑ 44 kB/s</span><span class="sp-tag ok">ok</span></summary>
<div class="sp-detail">
<table>
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
<tbody>
<tr><td>CPU</td><td><meter value="12" min="0" max="100" low="70" high="90" optimum="10"></meter>12 %</td><td class="sp-muted">2 cores, load 0.21</td></tr>
<tr><td>Memory</td><td><meter value="41" min="0" max="100" low="70" high="90" optimum="10"></meter>41 %</td><td class="sp-muted">812 MB of 1.9 GB</td></tr>
<tr><td>Network</td><td>↓ 18 kB/s · ↑ 44 kB/s</td><td class="sp-muted">eth0: 2.1 GB in, 6.4 GB out</td></tr>
</tbody>
</table>
</div>
</details>
<details>
<summary><strong>Queues</strong><span class="sp-muted">Outbound empty · inbound empty</span><span class="sp-tag ok">ok</span></summary>
<div class="sp-detail"><p>No messages are waiting. Open the <a href="#">outbound queue</a> or the <a href="#">inbound queue</a>.</p></div>
</details>
<details>
<summary><strong>Milter sockets</strong><span class="sp-muted">2 of 2 answering</span><span class="sp-tag ok">ok</span></summary>
<div class="sp-detail">
<table>
<thead><tr><th>Milter</th><th>State</th><th>Detail</th></tr></thead>
<tbody>
<tr><td>opendkim</td><td><span class="sp-tag ok">ok</span></td><td class="sp-muted">inet:127.0.0.1:8891</td></tr>
<tr><td>journal-milter</td><td><span class="sp-tag ok">ok</span></td><td class="sp-muted">unix:/run/selfpost/journal.sock</td></tr>
</tbody>
</table>
</div>
</details>
<details>
<summary><strong>Hostname and reverse DNS</strong><span class="sp-muted">mail.example.org ⇄ 203.0.113.25</span><span class="sp-tag ok">ok</span></summary>
<div class="sp-detail">
<dl class="sp-kv">
<dt>Hostname</dt><dd><span class="sp-value">mail.example.org</span></dd>
<dt>Lookup</dt><dd><span class="sp-value">mail.example.org → 203.0.113.25<br>203.0.113.25 → mail.example.org</span></dd>
</dl>
<form method="post" action="#"><button type="submit" class="outline secondary sp-btn-sm">Re-check DNS</button></form>
</div>
</details>
</section>
<footer class="sp-foot">
SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty
</footer>
</main>
</body>
</html>
-206
View File
@@ -1,206 +0,0 @@
/* Direction A — "Blank" (the postal form). Pico CSS v2 carries every generic
control (type, buttons, inputs, tables, details, dropdown); this file maps
the brand onto Pico's variables and adds only what is SelfPost's own:
the two-row header, the sheet, numbered form sections, the postmark and
the DNS record block. */
/* :root[...] rather than [data-theme=light] alone: Pico declares its light
palette on :root:not([data-theme=dark]), which a bare attribute selector
loses to. */
:root[data-theme=light] {
--pico-font-family-sans-serif: var(--sp-sans);
--pico-font-family-monospace: var(--sp-mono);
--pico-font-family: var(--sp-sans);
--pico-border-radius: 2px;
--pico-spacing: .9rem;
--pico-typography-spacing-vertical: .9rem;
--pico-form-element-spacing-vertical: .5rem;
--pico-form-element-spacing-horizontal: .75rem;
--pico-background-color: var(--sp-paper);
--pico-color: var(--sp-ink);
--pico-muted-color: var(--sp-muted);
--pico-muted-border-color: var(--sp-rule);
--pico-h1-color: var(--sp-ink); --pico-h2-color: var(--sp-ink);
--pico-h3-color: var(--sp-ink); --pico-h4-color: var(--sp-ink);
--pico-primary: var(--sp-brick);
--pico-primary-background: var(--sp-brick);
--pico-primary-border: var(--sp-brick);
--pico-primary-underline: rgb(122 59 46 / .35);
--pico-primary-hover: var(--sp-brick-dark);
--pico-primary-hover-background: var(--sp-brick-dark);
--pico-primary-hover-border: var(--sp-brick-dark);
--pico-primary-hover-underline: var(--sp-brick-dark);
--pico-primary-focus: rgb(122 59 46 / .25);
--pico-primary-inverse: #fff;
--pico-secondary: var(--sp-ink-2);
--pico-secondary-background: var(--sp-ink-2);
--pico-secondary-border: var(--sp-ink-2);
--pico-secondary-hover: var(--sp-ink);
--pico-secondary-hover-background: var(--sp-ink);
--pico-secondary-hover-border: var(--sp-ink);
--pico-secondary-focus: rgb(18 22 28 / .2);
--pico-secondary-inverse: #fff;
--pico-form-element-background-color: #fff;
--pico-form-element-border-color: var(--sp-rule-strong);
--pico-form-element-active-border-color: var(--sp-brick);
--pico-form-element-focus-color: rgb(122 59 46 / .25);
--pico-table-border-color: var(--sp-rule);
--pico-code-background-color: var(--sp-well);
--pico-code-color: var(--sp-ink);
--pico-dropdown-background-color: #fff;
--pico-dropdown-border-color: var(--sp-rule);
--pico-dropdown-hover-background-color: var(--sp-brick-tint);
}
/* Pico grows the root font with the viewport (up to 131%). An ops panel wants
one size everywhere. */
:root { font-size: 15px; }
body { min-height: 100vh; display: flex; flex-direction: column; }
h1 { font-size: 1.7rem; font-weight: 600; margin: 0; }
small, .sp-muted { color: var(--sp-muted); }
code, .sp-mono { font-family: var(--sp-mono); font-size: .88em; }
/* ---- header: sections on the first row, the section's pages on the second */
.sp-wrap { width: 100%; max-width: 72rem; margin-inline: auto; padding-inline: 1.5rem; }
.sp-bar { background: var(--sp-sheet); border-bottom: 1px solid var(--sp-rule); }
.sp-bar nav { align-items: center; }
.sp-bar nav li { padding-block: .45rem; }
.sp-brand img { height: 46px; width: auto; display: block; }
.sp-sections a {
font-family: var(--sp-mono); font-size: .8rem; font-weight: 600;
letter-spacing: .09em; text-transform: uppercase; text-decoration: none;
color: var(--sp-muted); padding: .5rem .8rem; margin: 0; border-radius: 0;
}
.sp-sections a:hover { color: var(--sp-ink); }
.sp-sections a[aria-current] { color: var(--sp-brick); box-shadow: inset 0 -3px 0 var(--sp-brick); }
.sp-user summary { font-size: .9rem; }
.sp-pages { background: var(--sp-paper); border-bottom: 1px solid var(--sp-rule); }
.sp-pages .sp-wrap { display: flex; gap: 1.6rem; }
.sp-pages a { color: var(--sp-muted); text-decoration: none; padding: .6rem 0; font-size: .95rem; }
.sp-pages a:hover { color: var(--sp-ink); }
.sp-pages a[aria-current] { color: var(--sp-ink); font-weight: 600; box-shadow: inset 0 -2px 0 var(--sp-ink); }
/* ---- the sheet: one column, one width, every page */
main.sp-wrap { flex: 1; padding-block: 1.8rem 2.5rem; }
.sp-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.4rem; }
.sp-head .sp-kicker { font-family: var(--sp-mono); font-size: .75rem; letter-spacing: .1em; text-transform: uppercase; color: var(--sp-muted); margin: 0 0 .15rem; }
.sp-head .sp-kicker a { color: inherit; }
.sp-head-actions { display: flex; gap: .5rem; align-items: center; }
.sp-head-actions > * { margin: 0; }
.sp-sheet { background: var(--sp-sheet); border: 1px solid var(--sp-rule); margin-bottom: 1.4rem; }
.sp-sheet > header {
display: flex; align-items: baseline; gap: .8rem; padding: .7rem 1.2rem;
border-bottom: 1px solid var(--sp-rule);
}
.sp-sheet > header .sp-no { font-family: var(--sp-mono); font-size: .75rem; color: var(--sp-brick); font-weight: 600; }
.sp-sheet > header h2 {
font-family: var(--sp-mono); font-size: .8rem; font-weight: 600; letter-spacing: .1em;
text-transform: uppercase; margin: 0; flex: 1;
}
.sp-sheet > header small { font-size: .85rem; }
.sp-sheet > .sp-body { padding: 1.1rem 1.2rem; }
.sp-sheet > .sp-body > :last-child { margin-bottom: 0; }
.sp-foot { color: var(--sp-muted); font-size: .8rem; border-top: 1px solid var(--sp-rule); padding-block: 1rem; }
.sp-foot a { color: inherit; }
/* ---- status tag and the postmark */
.sp-tag {
display: inline-block; font-family: var(--sp-mono); font-size: .72rem; font-weight: 600;
letter-spacing: .08em; text-transform: uppercase; padding: .1rem .5rem;
border: 1px solid var(--sp-idle-rule); background: var(--sp-idle-bg); color: var(--sp-idle);
border-radius: 2px; white-space: nowrap; text-decoration: none;
}
.sp-tag.ok { color: var(--sp-ok); background: var(--sp-ok-bg); border-color: var(--sp-ok-rule); }
.sp-tag.warn { color: var(--sp-warn); background: var(--sp-warn-bg); border-color: var(--sp-warn-rule); }
.sp-tag.fail { color: var(--sp-fail); background: var(--sp-fail-bg); border-color: var(--sp-fail-rule); }
.sp-verdict { display: flex; align-items: center; gap: 1.5rem; padding: 1.3rem 1.4rem; }
.sp-verdict h2 { margin: 0 0 .2rem; font-size: 1.35rem; font-weight: 600; }
.sp-verdict p { margin: 0; color: var(--sp-muted); }
.sp-postmark {
flex: none; width: 5.6rem; height: 5.6rem; border-radius: 50%;
border: 2px solid currentColor; outline: 1px solid currentColor; outline-offset: 3px;
display: grid; place-content: center; text-align: center; transform: rotate(-9deg);
font-family: var(--sp-mono); font-weight: 600; letter-spacing: .08em; line-height: 1.15;
color: var(--sp-ok);
}
.sp-postmark.warn { color: var(--sp-warn); }
.sp-postmark.fail { color: var(--sp-fail); }
.sp-postmark b { font-size: 1.25rem; display: block; }
.sp-postmark span { font-size: .55rem; display: block; }
/* ---- ledger: one row per check, the row opens into its detail */
.sp-ledger details { margin: 0; padding: 0; border-bottom: 1px solid var(--sp-rule); }
.sp-ledger details:last-child { border-bottom: 0; }
.sp-ledger summary {
display: grid; grid-template-columns: 13rem 1fr auto 1.2rem; gap: 1rem; align-items: center;
padding: .75rem 1.2rem; margin: 0; color: var(--sp-ink);
}
.sp-ledger summary::after { grid-column: 4; margin: 0; float: none; }
.sp-ledger summary:hover { background: var(--sp-paper); }
.sp-ledger summary strong { font-weight: 600; }
.sp-ledger summary span.sp-muted { font-size: .93rem; }
.sp-ledger .sp-detail { padding: .2rem 1.2rem 1.1rem 15.2rem; }
.sp-ledger .sp-detail table { margin: 0; font-size: .93rem; }
.sp-ledger .sp-detail > :last-child { margin-bottom: 0; }
.sp-ledger meter { width: 9rem; height: .7rem; vertical-align: middle; margin-right: .5rem; }
/* ---- label / value / copy: the DNS record block and connection settings */
.sp-record { padding: 1.1rem 1.2rem; border-bottom: 1px solid var(--sp-rule); }
.sp-record:last-child { border-bottom: 0; }
.sp-record-head { display: flex; align-items: center; gap: .7rem; margin-bottom: .7rem; }
.sp-record-head h3 { margin: 0; font-size: 1.05rem; font-weight: 600; }
.sp-record-head small { margin-left: auto; }
.sp-kv { display: grid; grid-template-columns: 6.5rem 1fr; gap: .45rem 1rem; align-items: start; margin: 0; }
.sp-kv dt {
font-family: var(--sp-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase;
color: var(--sp-muted); padding-top: .5rem; margin: 0;
}
.sp-kv dd { margin: 0; display: flex; gap: .4rem; align-items: stretch; min-width: 0; }
.sp-value {
flex: 1; min-width: 0; font-family: var(--sp-mono); font-size: .86rem; line-height: 1.5;
background: var(--sp-well); border: 1px solid var(--sp-rule); padding: .4rem .65rem;
overflow-wrap: anywhere; border-radius: 2px;
}
.sp-value.plain { background: none; border-color: transparent; padding-left: 0; font-family: var(--sp-sans); font-size: .95rem; }
.sp-copy {
flex: none; align-self: flex-start; height: 2.15rem; margin: 0; padding: 0 .7rem; font-size: .8rem;
--pico-background-color: #fff; --pico-border-color: var(--sp-rule-strong); --pico-color: var(--sp-ink-2);
}
.sp-copy:hover { --pico-background-color: var(--sp-paper); --pico-border-color: var(--sp-ink-2); --pico-color: var(--sp-ink); }
.sp-note { margin: .7rem 0 0 7.5rem; font-size: .9rem; color: var(--sp-muted); }
.sp-note.fail { color: var(--sp-fail); }
.sp-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); gap: 0 2.4rem; }
/* ---- applications */
.sp-apps { margin: 0; font-size: .95rem; }
.sp-apps td, .sp-apps th { padding-inline: 1.2rem .4rem; vertical-align: middle; }
.sp-apps th { font-family: var(--sp-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); font-weight: 400; }
.sp-apps td:last-child { text-align: right; padding-right: 1.2rem; white-space: nowrap; }
.sp-apps .sp-login { font-family: var(--sp-mono); font-weight: 600; }
.sp-link-btn { font-size: .9rem; margin-left: .9rem; }
.sp-link-btn.danger { color: var(--sp-fail); --pico-underline: rgb(180 35 24 / .35); text-decoration-color: rgb(180 35 24 / .35); }
.sp-btn-sm { padding: .35rem .8rem; font-size: .9rem; margin: 0; width: auto; }
/* ---- sign-in */
.sp-signin { flex: 1; display: grid; place-content: center; padding: 2rem 1.5rem; }
.sp-signin-card { width: 23rem; max-width: 100%; }
.sp-signin-card img { display: block; width: 15rem; height: auto; margin: 0 auto 1.6rem; }
.sp-signin-card .sp-body { padding: 1.4rem 1.4rem 1.2rem; }
.sp-signin-card button { margin-bottom: 0; }
.sp-tagline { text-align: center; font-family: var(--sp-mono); font-size: .75rem; letter-spacing: .16em; color: var(--sp-muted); margin-top: 1.2rem; }
@media (max-width: 48rem) {
.sp-ledger summary { grid-template-columns: 1fr auto 1.2rem; }
.sp-ledger summary span.sp-muted { grid-column: 1 / -1; grid-row: 2; }
.sp-ledger summary::after { grid-column: 3; }
.sp-ledger .sp-detail { padding-left: 1.2rem; }
.sp-kv { grid-template-columns: 1fr; gap: .15rem; }
.sp-kv dt { padding-top: .5rem; }
.sp-note { margin-left: 0; }
.sp-pages .sp-wrap { overflow-x: auto; white-space: nowrap; }
}
-212
View File
@@ -1,212 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>example.org · Outbound domains · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.5.1/dist/css/tabler.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<div class="page">
<aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
<div class="container-fluid">
<a class="navbar-brand" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a>
<div class="navbar-collapse collapse show">
<ul class="navbar-nav">
<li class="nav-item"><a class="nav-link" href="status.html"><span class="nav-link-icon"><i class="ti ti-activity-heartbeat"></i></span><span class="nav-link-title">Overview</span></a></li>
<li class="sp-nav-label">Outbound</li>
<li class="nav-item active"><a class="nav-link" href="domain.html"><span class="nav-link-icon"><i class="ti ti-world-upload"></i></span><span class="nav-link-title">Domains</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-list-details"></i></span><span class="nav-link-title">Log</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-stack-2"></i></span><span class="nav-link-title">Queue</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-report-analytics"></i></span><span class="nav-link-title">DMARC reports</span></a></li>
<li class="sp-nav-label">Inbound</li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-world-download"></i></span><span class="nav-link-title">Domains</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-list-details"></i></span><span class="nav-link-title">Log</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-stack-2"></i></span><span class="nav-link-title">Queue</span></a></li>
<li class="sp-nav-label">Server</li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-file-text"></i></span><span class="nav-link-title">System log</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-archive"></i></span><span class="nav-link-title">Backup</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-users"></i></span><span class="nav-link-title">Users</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-settings"></i></span><span class="nav-link-title">Settings</span></a></li>
</ul>
<ul class="navbar-nav sp-nav-foot">
<li class="sp-nav-user">Signed in as admin</li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-user-circle"></i></span><span class="nav-link-title">Account</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-help"></i></span><span class="nav-link-title">Help</span></a></li>
<li class="nav-item"><a class="nav-link" href="login.html"><span class="nav-link-icon"><i class="ti ti-logout"></i></span><span class="nav-link-title">Sign out</span></a></li>
</ul>
</div>
</div>
</aside>
<div class="page-wrapper">
<div class="page-header">
<div class="container-xl">
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle"><a href="#" class="text-reset">Outbound domains</a> /</div>
<h2 class="page-title">example.org <span class="badge bg-red-lt ms-2">DNS: 1 mismatch</span></h2>
</div>
<div class="col-auto">
<div class="btn-list">
<form method="post" action="#"><button type="submit" class="btn"><i class="ti ti-refresh me-1"></i>Re-check DNS</button></form>
<a href="#" class="btn btn-primary"><i class="ti ti-plus me-1"></i>Add application</a>
</div>
</div>
</div>
</div>
</div>
<div class="page-body">
<div class="container-xl">
<div class="row row-cards">
<div class="col-lg-8">
<div class="card">
<div class="card-header"><h3 class="card-title">DNS records</h3><div class="card-actions text-secondary small">Checked 3 min ago</div></div>
<div class="card-body sp-record">
<div class="d-flex align-items-center mb-3">
<h4 class="m-0 me-2">DKIM</h4><span class="badge bg-green-lt">published</span>
<span class="ms-auto text-secondary small">Not a secret · selector <code>sp2026</code></span>
</div>
<div class="row g-3">
<div class="col-md-9">
<label class="form-label">Host / name</label>
<div class="input-group"><input class="form-control" readonly value="sp2026._domainkey.example.org"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
</div>
<div class="col-md-3">
<label class="form-label">Type</label>
<input class="form-control" readonly value="TXT">
</div>
<div class="col-12">
<label class="form-label">Value to publish</label>
<div class="input-group"><textarea class="form-control" rows="3" readonly>v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3kqV0mJd8e2f1Yb7Qw5uT9sLr4nHc6pZaXo1vKe0yWm2gBt8RjN5dCq3hUf7lPs9aEi4oMz6xTb1kGv0nYw…IDAQAB</textarea><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
</div>
</div>
</div>
<div class="card-body sp-record">
<div class="d-flex align-items-center mb-3">
<h4 class="m-0 me-2">SPF</h4><span class="badge bg-red-lt">mismatch</span>
<span class="ms-auto text-secondary small">Merge into the existing record — never publish a second one</span>
</div>
<div class="row g-3">
<div class="col-md-9">
<label class="form-label">Host / name</label>
<div class="input-group"><input class="form-control" readonly value="example.org"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
</div>
<div class="col-md-3">
<label class="form-label">Type</label>
<input class="form-control" readonly value="TXT">
</div>
<div class="col-12">
<label class="form-label">Value to publish</label>
<div class="input-group"><input class="form-control" readonly value="v=spf1 ip4:203.0.113.25 ~all"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
</div>
<div class="col-12">
<label class="form-label">In DNS now</label>
<input class="form-control" readonly value="v=spf1 include:_spf.google.com ~all">
<div class="text-danger small mt-2">The published record does not authorize 203.0.113.25. Add <code>ip4:203.0.113.25</code> before <code>~all</code>.</div>
</div>
</div>
</div>
<div class="card-body sp-record">
<div class="d-flex align-items-center mb-3">
<h4 class="m-0 me-2">DMARC</h4><span class="badge bg-green-lt">published</span>
<span class="ms-auto text-secondary small">Report address from <a href="#">Settings</a></span>
</div>
<div class="row g-3">
<div class="col-md-9">
<label class="form-label">Host / name</label>
<div class="input-group"><input class="form-control" readonly value="_dmarc.example.org"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
</div>
<div class="col-md-3">
<label class="form-label">Type</label>
<input class="form-control" readonly value="TXT">
</div>
<div class="col-12">
<label class="form-label">Value to publish</label>
<div class="input-group"><input class="form-control" readonly value="v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card mb-3">
<div class="card-header"><h3 class="card-title">Connection</h3></div>
<div class="card-body">
<div class="datagrid">
<div class="datagrid-item"><div class="datagrid-title">Server</div><div class="datagrid-content font-monospace">mail.example.org</div></div>
<div class="datagrid-item"><div class="datagrid-title">Ports</div><div class="datagrid-content font-monospace">465 SSL/TLS<br>587 STARTTLS</div></div>
</div>
<p class="text-secondary small mt-3 mb-0">The same for every domain. Authenticate with an application login.</p>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><h3 class="card-title">Sent, 30 days</h3></div>
<div class="card-body">
<div class="datagrid">
<div class="datagrid-item"><div class="datagrid-title">Messages</div><div class="datagrid-content h2 m-0">1 284</div></div>
<div class="datagrid-item"><div class="datagrid-title">Peak</div><div class="datagrid-content h2 m-0">96 <span class="text-secondary fs-5">/h</span></div></div>
<div class="datagrid-item"><div class="datagrid-title">Average</div><div class="datagrid-content h2 m-0">1.8 <span class="text-secondary fs-5">/h</span></div></div>
</div>
</div>
</div>
<div class="card">
<div class="card-header"><h3 class="card-title">Domain settings</h3></div>
<div class="list-group list-group-flush">
<a href="#" class="list-group-item list-group-item-action d-flex align-items-center"><div><div>DMARC reports</div><div class="text-secondary small">Same as Settings</div></div><i class="ti ti-chevron-right ms-auto text-secondary"></i></a>
<a href="#" class="list-group-item list-group-item-action d-flex align-items-center"><div><div>Rate limit <span class="badge bg-green-lt ms-1">active</span></div><div class="text-secondary small">Auto ×3 — 288 messages / hour</div></div><i class="ti ti-chevron-right ms-auto text-secondary"></i></a>
<a href="#" class="list-group-item list-group-item-action d-flex align-items-center"><div><div>Export</div><div class="text-secondary small">Key and applications as one file</div></div><i class="ti ti-chevron-right ms-auto text-secondary"></i></a>
<a href="#" class="list-group-item list-group-item-action d-flex align-items-center text-danger"><div>Delete domain</div><i class="ti ti-chevron-right ms-auto"></i></a>
</div>
</div>
</div>
<div class="col-12">
<div class="card">
<div class="card-header"><h3 class="card-title">Applications</h3><div class="card-actions text-secondary small">SASL logins that may send as this domain</div></div>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th class="w-1"></th></tr></thead>
<tbody>
<tr>
<td class="font-monospace fw-semibold">prod-server</td><td>*@example.org</td><td class="text-secondary">1 102 msg · peak 96/h</td>
<td><span class="badge bg-green-lt">200 / h</span></td>
<td><div class="btn-list flex-nowrap"><a href="#" class="btn btn-sm">Edit</a><a href="#" class="btn btn-sm">New password</a><a href="#" class="btn btn-sm btn-ghost-danger">Delete</a></div></td>
</tr>
<tr>
<td class="font-monospace fw-semibold">alerts</td><td>alerts@example.org, noc@example.org</td><td class="text-secondary">182 msg · peak 14/h</td>
<td><span class="badge bg-secondary-lt">domain</span> <span class="badge bg-green-lt">2 IPs</span></td>
<td><div class="btn-list flex-nowrap"><a href="#" class="btn btn-sm">Edit</a><a href="#" class="btn btn-sm">New password</a><a href="#" class="btn btn-sm btn-ghost-danger">Delete</a></div></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<footer class="footer footer-transparent">
<div class="container-xl sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</div>
</footer>
</div>
</div>
</body>
</html>
-36
View File
@@ -1,36 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.5.1/dist/css/tabler.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body class="d-flex flex-column">
<div class="page page-center">
<div class="container container-tight py-4">
<div class="text-center mb-4"><img class="sp-signin-mark" src="../../selfpost-stamp.svg" alt="SelfPost"></div>
<div class="card card-md">
<div class="card-body">
<h2 class="h2 text-center mb-4">Sign in</h2>
<form method="post" action="status.html">
<div class="mb-3">
<label class="form-label" for="username">Username</label>
<input class="form-control" id="username" name="username" autocomplete="username" required>
</div>
<div class="mb-2">
<label class="form-label" for="password">Password</label>
<input class="form-control" id="password" name="password" type="password" autocomplete="current-password" required>
</div>
<div class="form-footer"><button type="submit" class="btn btn-primary w-100">Sign in</button></div>
</form>
</div>
</div>
<div class="text-center sp-tagline mt-4">YOUR SMTP. YOUR RULES.</div>
<div class="text-center sp-foot mt-3">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</div>
</div>
</div>
</body>
</html>
-168
View File
@@ -1,168 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Overview · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.5.1/dist/css/tabler.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<div class="page">
<aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
<div class="container-fluid">
<a class="navbar-brand" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a>
<div class="navbar-collapse collapse show">
<ul class="navbar-nav">
<li class="nav-item active"><a class="nav-link" href="status.html"><span class="nav-link-icon"><i class="ti ti-activity-heartbeat"></i></span><span class="nav-link-title">Overview</span></a></li>
<li class="sp-nav-label">Outbound</li>
<li class="nav-item"><a class="nav-link" href="domain.html"><span class="nav-link-icon"><i class="ti ti-world-upload"></i></span><span class="nav-link-title">Domains</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-list-details"></i></span><span class="nav-link-title">Log</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-stack-2"></i></span><span class="nav-link-title">Queue</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-report-analytics"></i></span><span class="nav-link-title">DMARC reports</span></a></li>
<li class="sp-nav-label">Inbound</li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-world-download"></i></span><span class="nav-link-title">Domains</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-list-details"></i></span><span class="nav-link-title">Log</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-stack-2"></i></span><span class="nav-link-title">Queue</span></a></li>
<li class="sp-nav-label">Server</li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-file-text"></i></span><span class="nav-link-title">System log</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-archive"></i></span><span class="nav-link-title">Backup</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-users"></i></span><span class="nav-link-title">Users</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-settings"></i></span><span class="nav-link-title">Settings</span></a></li>
</ul>
<ul class="navbar-nav sp-nav-foot">
<li class="sp-nav-user">Signed in as admin</li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-user-circle"></i></span><span class="nav-link-title">Account</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-help"></i></span><span class="nav-link-title">Help</span></a></li>
<li class="nav-item"><a class="nav-link" href="login.html"><span class="nav-link-icon"><i class="ti ti-logout"></i></span><span class="nav-link-title">Sign out</span></a></li>
</ul>
</div>
</div>
</aside>
<div class="page-wrapper">
<div class="page-header">
<div class="container-xl">
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">mail.example.org</div>
<h2 class="page-title">Overview</h2>
</div>
<div class="col-auto text-secondary small">Updated 14:02:31 UTC · refreshes every 10 s</div>
</div>
</div>
</div>
<div class="page-body">
<div class="container-xl">
<div class="alert alert-warning bg-white" role="alert">
<div class="d-flex">
<div><i class="ti ti-alert-triangle alert-icon"></i></div>
<div>
<h4 class="alert-title">Mail is flowing. One thing needs attention.</h4>
<div class="text-secondary">The TLS certificate expires in 12 days and has not renewed yet.</div>
</div>
</div>
</div>
<div class="row row-cards mb-3">
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-cpu sp-tile-icon"></i><div><div class="fw-semibold">Machine</div><div class="text-secondary small">CPU 12 % · mem 41 %</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-server-cog sp-tile-icon"></i><div><div class="fw-semibold">Processes</div><div class="text-secondary small">5 of 5 running</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-stack-2 sp-tile-icon"></i><div><div class="fw-semibold">Queues</div><div class="text-secondary small">Both empty</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-certificate sp-tile-icon"></i><div><div class="fw-semibold">TLS certificate</div><div class="text-secondary small">Expires in 12 days</div></div><span class="status status-yellow"><span class="status-dot"></span>Warn</span></div></div></div>
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-plug-connected sp-tile-icon"></i><div><div class="fw-semibold">Milter sockets</div><div class="text-secondary small">2 of 2 answering</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-arrows-exchange sp-tile-icon"></i><div><div class="fw-semibold">Reverse DNS</div><div class="text-secondary small">Forward = reverse</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
</div>
<div class="row row-deck row-cards">
<div class="col-lg-6">
<div class="card">
<div class="card-header"><h3 class="card-title">Machine</h3><div class="card-actions"><span class="badge bg-green-lt">ok</span></div></div>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
<tbody>
<tr><td>CPU</td><td><progress class="sp-bar" value="12" max="100"></progress>12 %</td><td class="text-secondary">2 cores, load 0.21</td></tr>
<tr><td>Memory</td><td><progress class="sp-bar" value="41" max="100"></progress>41 %</td><td class="text-secondary">812 MB of 1.9 GB</td></tr>
<tr><td>Network</td><td>↓ 18 kB/s · ↑ 44 kB/s</td><td class="text-secondary">eth0: 2.1 GB in, 6.4 GB out</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card">
<div class="card-header"><h3 class="card-title">Processes</h3><div class="card-actions"><span class="badge bg-green-lt">ok</span></div></div>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
<tbody>
<tr><td>postfix</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 41, uptime 18 days</td></tr>
<tr><td>opendkim</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 39, uptime 18 days</td></tr>
<tr><td>journal-milter</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 40, uptime 18 days</td></tr>
<tr><td>panel</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 44, uptime 18 days</td></tr>
<tr><td>dmarc-ingest</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 47, uptime 18 days</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header"><h3 class="card-title">TLS certificate</h3><div class="card-actions"><span class="badge bg-yellow-lt">warn</span></div></div>
<div class="card-body">
<div class="datagrid">
<div class="datagrid-item"><div class="datagrid-title">Expires</div><div class="datagrid-content font-monospace">2026-10-03 09:14 UTC</div></div>
<div class="datagrid-item"><div class="datagrid-title">Names</div><div class="datagrid-content font-monospace">mail.example.org</div></div>
</div>
<p class="text-secondary mt-3 mb-0">Renewal normally runs 30 days before expiry. Check port 80 and the <a href="#">system log</a>.</p>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header"><h3 class="card-title">Milter sockets</h3><div class="card-actions"><span class="badge bg-green-lt">ok</span></div></div>
<table class="table table-vcenter card-table">
<thead><tr><th>Milter</th><th>State</th></tr></thead>
<tbody>
<tr><td>opendkim<div class="text-secondary small font-monospace">inet:127.0.0.1:8891</div></td><td><span class="badge bg-green-lt">ok</span></td></tr>
<tr><td>journal-milter<div class="text-secondary small font-monospace">unix:/run/selfpost/journal.sock</div></td><td><span class="badge bg-green-lt">ok</span></td></tr>
</tbody>
</table>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header"><h3 class="card-title">Hostname and reverse DNS</h3><div class="card-actions"><span class="badge bg-green-lt">ok</span></div></div>
<div class="card-body">
<div class="datagrid">
<div class="datagrid-item"><div class="datagrid-title">Hostname</div><div class="datagrid-content font-monospace">mail.example.org</div></div>
<div class="datagrid-item"><div class="datagrid-title">Lookup</div><div class="datagrid-content font-monospace">→ 203.0.113.25<br>← mail.example.org</div></div>
</div>
</div>
<div class="card-footer"><form method="post" action="#"><button type="submit" class="btn btn-sm"><i class="ti ti-refresh me-1"></i>Re-check DNS</button></form></div>
</div>
</div>
</div>
</div>
</div>
<footer class="footer footer-transparent">
<div class="container-xl sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</div>
</footer>
</div>
</div>
</body>
</html>
-80
View File
@@ -1,80 +0,0 @@
/* Direction B — "Console". Tabler (Bootstrap 5 admin kit) carries the shell,
cards, tables, badges, input groups and the sign-in page as shipped; this
file only re-points Tabler's variables at the brand and adds the two things
Tabler has no word for: a sidebar group label and the DNS record block. */
:root, [data-bs-theme=light] {
--tblr-font-sans-serif: var(--sp-sans);
--tblr-font-monospace: var(--sp-mono);
--tblr-body-bg: var(--sp-paper);
--tblr-body-color: var(--sp-ink);
--tblr-secondary: var(--sp-muted);
--tblr-border-color: var(--sp-rule);
--tblr-border-radius: 3px;
--tblr-border-radius-lg: 4px;
--tblr-primary: #7a3b2e;
--tblr-primary-rgb: 122, 59, 46;
--tblr-link-color: #7a3b2e;
--tblr-link-color-rgb: 122, 59, 46;
--tblr-link-hover-color: #632f25;
--tblr-link-hover-color-rgb: 99, 47, 37;
--tblr-green: #2c6b43; --tblr-green-rgb: 44, 107, 67;
--tblr-yellow: #b5700f; --tblr-yellow-rgb: 181, 112, 15;
--tblr-red: #b42318; --tblr-red-rgb: 180, 35, 24;
--tblr-success: #2c6b43; --tblr-success-rgb: 44, 107, 67;
--tblr-warning: #b5700f; --tblr-warning-rgb: 181, 112, 15;
--tblr-danger: #b42318; --tblr-danger-rgb: 180, 35, 24;
}
body { font-feature-settings: normal; }
.btn-primary { --tblr-btn-hover-bg: #632f25; --tblr-btn-active-bg: #632f25; }
code, .font-monospace { font-family: var(--sp-mono); }
/* ---- sidebar: ink, with the stamp on its own paper */
.navbar-vertical {
--tblr-navbar-bg: var(--sp-ink); background: var(--sp-ink);
--tblr-navbar-color: #aab2bd; --tblr-navbar-hover-color: #fff; --tblr-navbar-active-color: #fff;
--tblr-navbar-border-color: transparent;
}
.navbar-vertical .navbar-brand { padding: 1.1rem 0 .9rem; justify-content: flex-start; }
.navbar-vertical .navbar-brand img { height: 52px; width: auto; }
.navbar-vertical .nav-link { padding-block: .42rem; }
.navbar-vertical .nav-link-icon { font-size: 1.15rem; }
.navbar-vertical .nav-item.active > .nav-link {
background: rgb(255 255 255 / .07); box-shadow: inset 3px 0 0 #c4705e; color: #fff;
}
.sp-nav-label {
font-family: var(--sp-mono); font-size: .66rem; font-weight: 600; letter-spacing: .14em;
text-transform: uppercase; color: #6f7a88; padding: 1.1rem 1.5rem .3rem;
}
.sp-nav-foot { margin-top: auto; padding-bottom: .8rem; border-top: 1px solid rgb(255 255 255 / .08); padding-top: .6rem; }
.sp-nav-user { color: #6f7a88; font-size: .8rem; padding: .3rem 1.5rem; }
/* ---- page */
.page-pretitle { font-family: var(--sp-mono); letter-spacing: .1em; }
.card-title { font-weight: 600; }
.table thead th { font-family: var(--sp-mono); letter-spacing: .08em; }
.sp-tile .card-body { display: flex; align-items: center; gap: .8rem; padding: .85rem 1rem; }
.sp-tile .sp-tile-icon { font-size: 1.5rem; color: var(--sp-muted); }
.sp-tile .fw-semibold { line-height: 1.2; }
.sp-tile .status { margin-left: auto; }
progress.sp-bar {
appearance: none; -webkit-appearance: none; width: 8rem; height: .4rem; border: 0;
border-radius: 1rem; overflow: hidden; vertical-align: middle; margin-right: .6rem; background: var(--sp-well);
}
progress.sp-bar::-webkit-progress-bar { background: var(--sp-well); }
progress.sp-bar::-webkit-progress-value { background: var(--tblr-green); }
progress.sp-bar::-moz-progress-bar { background: var(--tblr-green); }
/* ---- the DNS record block: Tabler's read-only input group, plus a status */
.sp-record + .sp-record { border-top: 1px solid var(--sp-rule); }
.sp-record .form-control[readonly], .sp-record textarea[readonly] {
font-family: var(--sp-mono); font-size: .8rem; background: var(--sp-well);
}
.sp-record textarea { resize: none; }
.sp-record .form-label { font-size: .75rem; color: var(--sp-muted); margin-bottom: .25rem; }
.sp-foot { color: var(--sp-muted); font-size: .8rem; }
.sp-signin-mark { width: 15rem; height: auto; }
.sp-tagline { font-family: var(--sp-mono); font-size: .72rem; letter-spacing: .16em; color: var(--sp-muted); }
-200
View File
@@ -1,200 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>example.org · Outbound domains · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand">
<a class="navbar-item" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a>
</div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="status.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="domain.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item is-active" href="domain.html">Domains</a>
<a class="navbar-item" href="#">Log</a>
<a class="navbar-item" href="#">Queue</a>
<a class="navbar-item" href="#">DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="#">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="#">Domains</a>
<a class="navbar-item" href="#">Log</a>
<a class="navbar-item" href="#">Queue</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="#">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="#">System log</a>
<a class="navbar-item" href="#">Backup</a>
<a class="navbar-item" href="#">Users</a>
<a class="navbar-item" href="#">Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="#">admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="#">Account</a>
<a class="navbar-item" href="#">Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html">Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<main>
<div class="container sp-page">
<div class="level mb-5">
<div class="level-left">
<div>
<p class="sp-kicker"><a href="status.html">Outbound domains</a> /</p>
<h1 class="title is-3 mb-0">example.org</h1>
</div>
</div>
<div class="level-right">
<div class="buttons">
<form method="post" action="#"><button type="submit" class="button">Re-check DNS</button></form>
<a class="button is-primary" href="#">Add application</a>
</div>
</div>
</div>
<div class="columns">
<div class="column is-3">
<aside class="menu sp-side">
<p class="menu-label">This domain</p>
<ul class="menu-list">
<li><a class="is-active" href="#dns">DNS records <span class="tag is-danger is-light">1</span></a></li>
<li><a href="#apps">Applications <span class="tag is-light">2</span></a></li>
<li><a href="#connection">Connection</a></li>
</ul>
<p class="menu-label">Rarely changed</p>
<ul class="menu-list">
<li><a href="#">DMARC reports</a></li>
<li><a href="#">Rate limit <span class="tag is-success is-light">active</span></a></li>
<li><a href="#">Export</a></li>
<li><a class="has-text-danger" href="#">Delete domain</a></li>
</ul>
</aside>
</div>
<div class="column is-9">
<div class="box" id="dns">
<div class="sp-box-head"><h2>DNS records</h2><div class="sp-end">Checked 3 min ago</div></div>
<div class="sp-record">
<div class="sp-record-head"><h3>DKIM</h3><span class="tag is-success is-light">published</span><small>Not a secret · selector <code>sp2026</code></small></div>
<div class="sp-row">
<div class="field">
<label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="sp2026._domainkey.example.org"></div><div class="control"><button type="button" class="button">Copy</button></div></div>
</div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div>
<div class="field">
<label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><textarea class="textarea" rows="3" readonly>v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3kqV0mJd8e2f1Yb7Qw5uT9sLr4nHc6pZaXo1vKe0yWm2gBt8RjN5dCq3hUf7lPs9aEi4oMz6xTb1kGv0nYw…IDAQAB</textarea></div><div class="control"><button type="button" class="button">Copy</button></div></div>
</div>
</div>
<div class="sp-record">
<div class="sp-record-head"><h3>SPF</h3><span class="tag is-danger is-light">mismatch</span><small>Merge into the existing record — never publish a second one</small></div>
<div class="sp-row">
<div class="field">
<label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="example.org"></div><div class="control"><button type="button" class="button">Copy</button></div></div>
</div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div>
<div class="field">
<label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=spf1 ip4:203.0.113.25 ~all"></div><div class="control"><button type="button" class="button">Copy</button></div></div>
</div>
<div class="field">
<label class="label">In DNS now</label>
<div class="control"><input class="input" readonly value="v=spf1 include:_spf.google.com ~all"></div>
<p class="help is-danger">The published record does not authorize 203.0.113.25. Add <code>ip4:203.0.113.25</code> before <code>~all</code>.</p>
</div>
</div>
<div class="sp-record">
<div class="sp-record-head"><h3>DMARC</h3><span class="tag is-success is-light">published</span><small>Report address from <a href="#">Settings</a></small></div>
<div class="sp-row">
<div class="field">
<label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="_dmarc.example.org"></div><div class="control"><button type="button" class="button">Copy</button></div></div>
</div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div>
<div class="field">
<label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org"></div><div class="control"><button type="button" class="button">Copy</button></div></div>
</div>
</div>
</div>
<div class="box" id="apps">
<div class="sp-box-head"><h2>Applications</h2><div class="sp-end">SASL logins that may send as this domain</div></div>
<table class="table is-fullwidth is-hoverable">
<thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th></th></tr></thead>
<tbody>
<tr>
<td class="sp-mono"><strong>prod-server</strong></td><td>*@example.org</td><td class="sp-muted">1 102 msg · peak 96/h</td>
<td><span class="tag is-success is-light">200 / h</span></td>
<td><div class="buttons are-small is-right is-flex-wrap-nowrap"><a class="button" href="#">Edit</a><a class="button" href="#">New password</a><a class="button is-danger is-light" href="#">Delete</a></div></td>
</tr>
<tr>
<td class="sp-mono"><strong>alerts</strong></td><td>alerts@example.org, noc@example.org</td><td class="sp-muted">182 msg · peak 14/h</td>
<td><span class="tag is-light">domain</span> <span class="tag is-success is-light">2 IPs</span></td>
<td><div class="buttons are-small is-right is-flex-wrap-nowrap"><a class="button" href="#">Edit</a><a class="button" href="#">New password</a><a class="button is-danger is-light" href="#">Delete</a></div></td>
</tr>
</tbody>
</table>
</div>
<div class="columns">
<div class="column">
<div class="box" id="connection">
<div class="sp-box-head"><h2>Connection</h2><div class="sp-end">Same for every domain</div></div>
<p class="sp-mono">mail.example.org</p>
<p class="sp-mono sp-muted">465 SSL/TLS · 587 STARTTLS</p>
</div>
</div>
<div class="column">
<div class="box">
<div class="sp-box-head"><h2>Sent, 30 days</h2></div>
<div class="sp-stats">
<div><b>1 284</b><span class="sp-muted">messages</span></div>
<div><b>96</b><span class="sp-muted">peak / h</span></div>
<div><b>1.8</b><span class="sp-muted">average / h</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
-38
View File
@@ -1,38 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<div class="columns is-gapless sp-signin">
<div class="column is-half sp-signin-brand">
<div>
<img src="../../selfpost-stamp.svg" alt="SelfPost">
<p>YOUR SMTP. YOUR RULES.</p>
</div>
</div>
<div class="column is-half sp-signin-form">
<form method="post" action="status.html">
<h1 class="title is-3">Sign in</h1>
<div class="field">
<label class="label" for="username">Username</label>
<div class="control"><input class="input" id="username" name="username" autocomplete="username" required></div>
</div>
<div class="field">
<label class="label" for="password">Password</label>
<div class="control"><input class="input" id="password" name="password" type="password" autocomplete="current-password" required></div>
</div>
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Sign in</button></div>
</form>
<p class="sp-foot">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
</body>
</html>
-117
View File
@@ -1,117 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Overview · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand">
<a class="navbar-item" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a>
</div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item is-active" href="status.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="domain.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="domain.html">Domains</a>
<a class="navbar-item" href="#">Log</a>
<a class="navbar-item" href="#">Queue</a>
<a class="navbar-item" href="#">DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="#">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="#">Domains</a>
<a class="navbar-item" href="#">Log</a>
<a class="navbar-item" href="#">Queue</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="#">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="#">System log</a>
<a class="navbar-item" href="#">Backup</a>
<a class="navbar-item" href="#">Users</a>
<a class="navbar-item" href="#">Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="#">admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="#">Account</a>
<a class="navbar-item" href="#">Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html">Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<main>
<div class="container sp-page">
<div class="level mb-5">
<div class="level-left">
<div>
<p class="sp-kicker">mail.example.org</p>
<h1 class="title is-3 mb-0">Overview</h1>
</div>
</div>
<div class="level-right sp-muted is-size-7">Updated 14:02:31 UTC · refreshes every 10 s</div>
</div>
<div class="box sp-health">
<a href="#"><div class="sp-h-name">Machine</div><div class="sp-h-value">12 % CPU</div><div class="sp-h-sub">41 % memory</div></a>
<a href="#"><div class="sp-h-name">Processes</div><div class="sp-h-value">5 of 5</div><div class="sp-h-sub">running</div></a>
<a href="#"><div class="sp-h-name">Queues</div><div class="sp-h-value">Empty</div><div class="sp-h-sub">outbound and inbound</div></a>
<a href="#" class="warn"><div class="sp-h-name">TLS certificate</div><div class="sp-h-value">12 days</div><div class="sp-h-sub">renewal has not run</div></a>
<a href="#"><div class="sp-h-name">Milter sockets</div><div class="sp-h-value">2 of 2</div><div class="sp-h-sub">answering</div></a>
<a href="#"><div class="sp-h-name">Reverse DNS</div><div class="sp-h-value">Match</div><div class="sp-h-sub">203.0.113.25</div></a>
</div>
<div class="columns">
<div class="column is-7">
<div class="box">
<div class="sp-box-head"><h2>Outbound domains</h2><div class="sp-end"><a class="button is-small is-primary" href="#">Add domain</a></div></div>
<table class="table is-fullwidth is-hoverable">
<thead><tr><th>Domain</th><th>DNS</th><th>Apps</th><th>30 days</th></tr></thead>
<tbody>
<tr><td><a href="domain.html"><strong>example.org</strong></a></td><td><span class="tag is-success is-light">DKIM</span> <span class="tag is-danger is-light">SPF</span> <span class="tag is-success is-light">DMARC</span></td><td>2</td><td class="sp-muted">1 284 msg</td></tr>
<tr><td><a href="#"><strong>shop.example.org</strong></a></td><td><span class="tag is-success is-light">DKIM</span> <span class="tag is-success is-light">SPF</span> <span class="tag is-success is-light">DMARC</span></td><td>1</td><td class="sp-muted">18 920 msg</td></tr>
<tr><td><a href="#"><strong>notify.acme.io</strong></a></td><td><span class="tag is-success is-light">DKIM</span> <span class="tag is-success is-light">SPF</span> <span class="tag is-warning is-light">DMARC</span></td><td>3</td><td class="sp-muted">402 msg</td></tr>
</tbody>
</table>
</div>
</div>
<div class="column is-5">
<div class="box">
<div class="sp-box-head"><h2>Inbound domains</h2><div class="sp-end"><a class="button is-small" href="#">Add domain</a></div></div>
<table class="table is-fullwidth is-hoverable">
<thead><tr><th>Domain</th><th>MX</th><th>Upstream</th></tr></thead>
<tbody>
<tr><td><a href="#"><strong>lists.example.org</strong></a></td><td><span class="tag is-success is-light">MX</span></td><td class="sp-muted sp-mono is-size-7">mx.internal:25</td></tr>
<tr><td><a href="#"><strong>acme.io</strong></a></td><td><span class="tag is-success is-light">MX</span></td><td class="sp-muted sp-mono is-size-7">10.0.4.12:2525</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
-98
View File
@@ -1,98 +0,0 @@
/* Direction C — "Counter". Bulma v1 (pure CSS, no JavaScript, no data: URIs)
carries the navbar with hover dropdowns, columns, boxes, tables, tags,
the side menu and input-with-addon fields. This file points Bulma's HSL
variables at the brand and adds the health strip and the record block. */
:root {
--bulma-family-primary: var(--sp-sans);
--bulma-family-secondary: var(--sp-sans);
--bulma-family-code: var(--sp-mono);
--bulma-primary-h: 10deg; --bulma-primary-s: 45%; --bulma-primary-l: 33%;
--bulma-link-h: 10deg; --bulma-link-s: 45%; --bulma-link-l: 33%;
--bulma-success-h: 138deg; --bulma-success-s: 42%; --bulma-success-l: 30%;
--bulma-warning-h: 36deg; --bulma-warning-s: 85%; --bulma-warning-l: 55%;
--bulma-danger-h: 4deg; --bulma-danger-s: 76%; --bulma-danger-l: 40%;
--bulma-radius: 6px; --bulma-radius-large: 10px;
--bulma-body-size: 15px;
/* Bulma derives link text and the on-primary colour from lightness steps;
at the brick's 33 % both land off-brand, so they are pinned. */
--bulma-link-text: var(--sp-brick);
--bulma-link-text-hover: var(--sp-brick-dark);
}
.button.is-primary, .button.is-primary:hover { color: #fff; }
a strong { color: inherit; }
html { background: var(--sp-paper); }
body { background: var(--sp-paper); color: var(--sp-ink); min-height: 100vh; display: flex; flex-direction: column; }
main { flex: 1; }
code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; padding: .1em .35em; font-size: .85em; }
/* ---- navbar: brick, the stamp on it, hover dropdowns */
.navbar.is-primary { --bulma-navbar-height: 4rem; }
.navbar-brand img { max-height: 3rem; height: 3rem; }
.navbar.is-primary .navbar-item, .navbar.is-primary .navbar-link { color: var(--sp-on-brick); font-weight: 500; }
.navbar.is-primary .navbar-link::after { border-color: var(--sp-on-brick); }
.navbar.is-primary .navbar-start > .navbar-item.is-active,
.navbar.is-primary .navbar-start > .has-dropdown.sp-current > .navbar-link { box-shadow: inset 0 -3px 0 var(--sp-on-brick); }
.navbar-dropdown { border-top: 0; box-shadow: 0 8px 24px rgb(18 22 28 / .14); }
.navbar.is-primary .navbar-dropdown .navbar-item { color: var(--sp-ink); font-weight: 400; }
.navbar.is-primary .navbar-dropdown .navbar-item.is-active { color: var(--sp-brick); font-weight: 600; background: var(--sp-brick-tint); }
/* ---- page */
.sp-page { padding-block: 2rem 2.5rem; }
.sp-kicker { font-family: var(--sp-mono); font-size: .75rem; letter-spacing: .1em; text-transform: uppercase; color: var(--sp-muted); }
.sp-kicker a { color: inherit; }
.title { color: var(--sp-ink); font-weight: 600; }
.box { box-shadow: none; border: 1px solid var(--sp-rule); }
.box > .sp-box-head { display: flex; align-items: center; gap: .75rem; margin-bottom: 1rem; }
.box > .sp-box-head h2 { font-size: 1.1rem; font-weight: 600; margin: 0; }
.box > .sp-box-head .sp-end { margin-left: auto; color: var(--sp-muted); font-size: .85rem; display: flex; gap: .6rem; align-items: center; }
.table { background: transparent; }
.table thead th { font-family: var(--sp-mono); font-size: .7rem; font-weight: 400; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); }
.table td { vertical-align: middle; }
.tag { font-family: var(--sp-mono); font-weight: 600; letter-spacing: .04em; }
.sp-muted { color: var(--sp-muted); }
.sp-mono { font-family: var(--sp-mono); }
/* ---- health strip */
.sp-health { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0; padding: 0; overflow: hidden; }
.sp-health > a {
display: block; padding: 1rem 1.2rem; color: var(--sp-ink); border-right: 1px solid var(--sp-rule);
box-shadow: inset 0 3px 0 var(--sp-ok);
}
.sp-health > a:last-child { border-right: 0; }
.sp-health > a:hover { background: var(--sp-paper); }
.sp-health > a.warn { box-shadow: inset 0 3px 0 #d9962b; background: var(--sp-warn-bg); }
.sp-health > a.fail { box-shadow: inset 0 3px 0 var(--sp-fail); background: var(--sp-fail-bg); }
.sp-health .sp-h-name { font-family: var(--sp-mono); font-size: .68rem; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); }
.sp-health .sp-h-value { font-size: 1.15rem; font-weight: 600; line-height: 1.3; }
.sp-health .sp-h-sub { font-size: .8rem; color: var(--sp-muted); }
/* ---- domain page */
.sp-side { position: sticky; top: 1.5rem; }
.menu-label { font-family: var(--sp-mono); letter-spacing: .1em; }
.menu-list a { display: flex; align-items: center; gap: .5rem; }
.menu-list a .tag { margin-left: auto; }
.sp-record + .sp-record { border-top: 1px solid var(--sp-rule); margin-top: 1.25rem; padding-top: 1.25rem; }
.sp-record-head { display: flex; align-items: center; gap: .6rem; margin-bottom: .75rem; }
.sp-record-head h3 { font-size: 1rem; font-weight: 600; margin: 0; }
.sp-record-head small { margin-left: auto; color: var(--sp-muted); }
.sp-record .label { font-size: .75rem; font-weight: 400; color: var(--sp-muted); margin-bottom: .25rem; }
.sp-record .input[readonly], .sp-record .textarea[readonly] {
font-family: var(--sp-mono); font-size: .82rem; background: var(--sp-well); box-shadow: none;
}
.sp-record .textarea { resize: none; min-height: 0; }
.sp-record .field:not(:last-child) { margin-bottom: .6rem; }
.sp-row { display: grid; grid-template-columns: 1fr 7rem; gap: .75rem; }
.sp-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
.sp-stats b { display: block; font-size: 1.5rem; font-weight: 600; line-height: 1.2; }
/* ---- sign-in */
.sp-signin { min-height: 100vh; margin: 0; }
.sp-signin-brand { background: var(--sp-brick); display: grid; place-content: center; text-align: center; padding: 3rem; }
.sp-signin-brand img { width: 20rem; max-width: 100%; height: auto; }
.sp-signin-brand p { font-family: var(--sp-mono); letter-spacing: .18em; font-size: .8rem; color: var(--sp-on-brick); margin-top: 1.6rem; }
.sp-signin-form { display: flex; flex-direction: column; justify-content: center; padding: 3rem; }
.sp-signin-form form { width: 21rem; max-width: 100%; margin-inline: auto; }
.sp-foot { color: var(--sp-muted); font-size: .8rem; padding-block: 1.2rem; }
.sp-foot a { color: inherit; text-decoration: underline; }
.sp-signin-form .sp-foot { text-align: center; margin-top: 2.5rem; }
+12 -83
View File
@@ -3,19 +3,21 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Panel redesign · three directions</title> <title>Panel mockups</title>
<link rel="stylesheet" href="shared/brand.css"> <link rel="stylesheet" href="shared/brand.css">
<link rel="stylesheet" href="index.css"> <link rel="stylesheet" href="index.css">
</head> </head>
<body> <body>
<main> <main>
<p class="kicker">SelfPost · panel redesign · stage 2</p> <p class="kicker">SelfPost · panel mockups</p>
<h1>The accepted direction, every screen</h1> <h1>Every screen of the panel</h1>
<p class="lead">Direction <strong>C</strong> (Bulma) was picked as the base: the important things in view, details one <p class="lead">The accepted design (<a href="../../plans/panel-redesign.md">the plan</a>): Bulma as the base, the
or two clicks away. It takes the postal signature of <strong>A</strong> — the postmark verdict, numbered mono section important things in view, details one or two clicks away, with a postal signature — the postmark verdict, numbered
heads, the stamp's perforated paper edge under a brick bar that carries the wordmark — and the icons of <strong>B</strong>. Thirty-four screens and a components page live in mono section heads, the stamp's perforated paper edge under a brick bar that carries the wordmark — and Tabler icons.
<a href="panel/overview.html">panel/</a>. Serve this folder from the repository root: the pages load the panel's own The screens and a components page live in <a href="panel/overview.html">panel/</a>; a page of the running panel that
fonts and mark by relative path.</p> disagrees with its mockup is wrong. Serve this folder from the repository root: the pages load the panel's own fonts
and mark by relative path. The three directions of the redesign's first stage and the earlier 1.x mockups were
removed on 2026-10-16; they are in the git history.</p>
<h2>Screen map</h2> <h2>Screen map</h2>
<div class="scroll"> <div class="scroll">
@@ -34,7 +36,7 @@
<a href="panel/in-queue.html">Queue</a> (empty state)<br> <a href="panel/in-queue.html">Queue</a> (empty state)<br>
<a href="panel/in-quarantine.html">Quarantine</a> · <a href="panel/in-quarantine-message.html">held message</a> · <a href="panel/in-quarantine.html">Quarantine</a> · <a href="panel/in-quarantine-message.html">held message</a> ·
<a href="panel/in-filter-lists.html">Filter lists</a> — roadmap items, drawn as their target look</td></tr> <a href="panel/in-filter-lists.html">Filter lists</a> — roadmap items, drawn as their target look</td></tr>
<tr><th>Server</th><td><a href="panel/health.html">Health</a> · <a href="panel/preflight.html">Preflight</a> (roadmap) · <a href="panel/system-log.html">System log</a> · <tr><th>Server</th><td><a href="panel/health.html">Health</a> · <a href="panel/system-log.html">System log</a> ·
<a href="panel/backup.html">Backup</a> · <a href="panel/users.html">Users</a> · <a href="panel/user.html">user form</a> · <a href="panel/backup.html">Backup</a> · <a href="panel/users.html">Users</a> · <a href="panel/user.html">user form</a> ·
<a href="panel/user-delete.html">delete user</a> · <a href="panel/settings.html">Settings</a></td></tr> <a href="panel/user-delete.html">delete user</a> · <a href="panel/settings.html">Settings</a></td></tr>
<tr><th>User menu</th><td><a href="panel/account.html">Account</a> · <a href="panel/help.html">Help</a></td></tr> <tr><th>User menu</th><td><a href="panel/account.html">Account</a> · <a href="panel/help.html">Help</a></td></tr>
@@ -44,7 +46,7 @@
</table> </table>
</div> </div>
<h2>What changed against today's panel</h2> <h2>What changed against the 1.x panel</h2>
<ul> <ul>
<li><strong>Overview shows verdicts, Health shows tables.</strong> Process lists, socket paths and interface counters moved to <li><strong>Overview shows verdicts, Health shows tables.</strong> Process lists, socket paths and interface counters moved to
<em>Server › Health</em>, one click from each card.</li> <em>Server › Health</em>, one click from each card.</li>
@@ -60,79 +62,6 @@
<li><code>panel/build.py</code> wraps the fragments in <code>panel/src/</code> with the shared navbar; the built pages are committed, <li><code>panel/build.py</code> wraps the fragments in <code>panel/src/</code> with the shared navbar; the built pages are committed,
so nothing has to be run to view them.</li> so nothing has to be run to view them.</li>
</ul> </ul>
<h2>Stage 1 · three directions on three key screens</h2>
<p class="lead">Kept for the record: Overview, an outbound domain and sign-in, drawn three ways.</p>
<h2>Shared by all three</h2>
<ul>
<li><strong>Navigation by mail direction.</strong> Overview · <em>Outbound</em> (Domains, Log, Queue, DMARC reports) ·
<em>Inbound</em> (Domains, Log, Queue) · <em>Server</em> (System log, Backup, Users, Settings). Account, Help and
Sign out live with the user name. Eleven flat entries become four groups.</li>
<li><strong>One block per DNS record.</strong> Today "DNS status" and "records to publish" show every host twice.
Here a record is one block: its status, what to publish, and — only on a mismatch — what DNS holds now.</li>
<li><strong>The domain page stops being nine cards.</strong> DNS and applications are the page; limits, report address,
export and delete are "rarely changed" and sit behind one click.</li>
<li><strong>Applications are a table</strong>; editing one is its own form page instead of a three-column panel inside a list row.</li>
<li>Brand carriers stay: the stamp, brick, IBM Plex. No inline <code>style</code> or script in any mockup.</li>
</ul>
<h2>The directions</h2>
<div class="cards">
<section>
<p class="kicker">A · Pico CSS 2.1</p>
<h3>Blank</h3>
<p>The panel as a postal form: one centred sheet, numbered sections, mono field labels, a postmark for the
verdict. Overview is a ledger — one row per check, the row opens into its detail. Sections on the first
header row, the section's pages on the second.</p>
<p class="links"><a href="a/status.html">Overview</a><a href="a/domain.html">Domain</a><a href="a/login.html">Sign in</a></p>
</section>
<section>
<p class="kicker">B · Tabler 1.5 (Bootstrap 5)</p>
<h3>Console</h3>
<p>The classic admin kit: ink sidebar with every page one click away, status tiles, cards and tables in a
12-column grid, read-only input groups with Copy. Almost nothing is ours; it also looks the most like
every other admin panel.</p>
<p class="links"><a href="b/status.html">Overview</a><a href="b/domain.html">Domain</a><a href="b/login.html">Sign in</a></p>
</section>
<section>
<p class="kicker">C · Bulma 1.0</p>
<h3>Counter</h3>
<p>Brick top bar with CSS-only hover menus. Overview is the whole service on one screen: a health strip over
the outbound and inbound domain lists. The domain page gets a sticky side menu. Softer, rounder, the
boldest use of the brand colour.</p>
<p class="links"><a href="c/status.html">Overview</a><a href="c/domain.html">Domain</a><a href="c/login.html">Sign in</a></p>
</section>
</div>
<h2>What each one costs</h2>
<div class="scroll">
<table>
<thead><tr><th></th><th>A · Pico</th><th>B · Tabler</th><th>C · Bulma</th></tr></thead>
<tbody>
<tr><th>Licence</th><td>MIT</td><td>MIT</td><td>MIT</td></tr>
<tr><th>Vendored CSS (minified)</th><td>83 KB</td><td>694 KB</td><td>678 KB</td></tr>
<tr><th>JavaScript</th><td>none</td><td>none for these screens; Bootstrap bundle only if dropdowns / modals are wanted</td><td>none</td></tr>
<tr><th>Build step</th><td>none</td><td>none</td><td>none (a Sass build could trim it; not needed)</td></tr>
<tr><th>CSP <code>default-src 'self'</code></th>
<td>needs <code>img-src 'self' data:</code> — 14 <code>data:</code> SVGs (select chevron, checkbox tick)</td>
<td>needs <code>img-src 'self' data:</code> — 28 <code>data:</code> SVGs</td>
<td>works as is — no <code>data:</code> URIs</td></tr>
<tr><th>Our own CSS in the mockup</th><td>≈ 200 lines</td><td>≈ 80 lines</td><td>≈ 100 lines</td></tr>
<tr><th>Markup style</th><td>semantic HTML, few classes — closest to today's templates</td><td>class-heavy Bootstrap markup</td><td>class-based, moderate</td></tr>
<tr><th>Own components</th><td>sheet, ledger row, postmark, record block</td><td>sidebar group label, record block</td><td>health strip, record block</td></tr>
<tr><th>Recognisable</th><td>most</td><td>least</td><td>middle</td></tr>
</tbody>
</table>
</div>
<h2>Open points</h2>
<ul>
<li>Icons in B come from Tabler Icons (MIT) through its webfont for the mockup; the panel would vendor only the used glyphs as an SVG sprite.</li>
<li><em>Inbound log</em> rides on the agreed <code>inbound-antispam-panel</code> journal; <em>Inbound queue</em> is a new filter over the one Postfix queue and needs its own roadmap row.</li>
<li>Dark scheme and the narrow-screen shell are not drawn at this stage; all three libraries ship both.</li>
<li>The earlier <a href="../panel-ui/index.html">panel-ui</a> experiment and <a href="../../plans/panel-redesign.md">panel-redesign.md</a> are superseded by whichever direction is accepted.</li>
</ul>
</main> </main>
</body> </body>
</html> </html>
+11 -6
View File
@@ -12,8 +12,8 @@
<body> <body>
<nav class="navbar is-primary" aria-label="main navigation"> <nav class="navbar is-primary" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div> <div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group"><summary>Outbound</summary><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group"><summary>Inbound</summary><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></details><details class="sp-touchnav-group" open><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
<div class="navbar-menu is-active"> <div class="navbar-menu">
<div class="navbar-start"> <div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a> <a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
@@ -39,7 +39,6 @@
<a class="navbar-link" href="health.html">Server</a> <a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a> <a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a> <a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a> <a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a> <a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
@@ -80,12 +79,18 @@
<div class="sp-box-body"> <div class="sp-box-body">
<form class="sp-form" method="post" action="#"> <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="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> <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. A domain's report address can be filled with it from Domain settings.</p></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> <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> <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> <button type="submit" class="button is-primary">Save profile</button>
</form> </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> </div>
</div> </div>
@@ -108,7 +113,7 @@
</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> </div>
</main> </main>
</body> </body>
+6 -5
View File
@@ -12,8 +12,8 @@
<body> <body>
<nav class="navbar is-primary" aria-label="main navigation"> <nav class="navbar is-primary" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div> <div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group"><summary>Outbound</summary><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group"><summary>Inbound</summary><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group" open><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html" aria-current="page"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
<div class="navbar-menu is-active"> <div class="navbar-menu">
<div class="navbar-start"> <div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a> <a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
@@ -39,7 +39,6 @@
<a class="navbar-link" href="health.html">Server</a> <a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a> <a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a> <a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item is-active" href="backup.html"><i class="ti ti-archive"></i>Backup</a> <a class="navbar-item is-active" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a> <a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
@@ -62,7 +61,7 @@
</div> </div>
</nav> </nav>
<div class="sp-perf" aria-hidden="true"></div> <div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html" aria-current="page"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></div></div> <div class="sp-subnav"><div class="container"><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html" aria-current="page"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></div></div>
<main> <main>
<div class="container sp-page"> <div class="container sp-page">
<div class="sp-head"> <div class="sp-head">
@@ -86,6 +85,8 @@
<div class="field"><label class="label" for="pw2">Repeat password</label><div class="control"><input class="input" id="pw2" type="password"></div></div> <div class="field"><label class="label" for="pw2">Repeat password</label><div class="control"><input class="input" id="pw2" type="password"></div></div>
</div> </div>
<p class="help mb-3">Keep this password: SelfPost does not store it, and the file cannot be opened without it.</p> <p class="help mb-3">Keep this password: SelfPost does not store it, and the file cannot be opened without it.</p>
<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, asked at every download: the archive holds every secret of the server, and a session left open must not be enough to take it.</p></div>
<button type="submit" class="button is-primary"><i class="ti ti-download"></i>Download full backup</button> <button type="submit" class="button is-primary"><i class="ti ti-download"></i>Download full backup</button>
</form> </form>
</div> </div>
@@ -109,7 +110,7 @@
</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> </div>
</main> </main>
</body> </body>
+27 -4
View File
@@ -31,7 +31,6 @@ NAV = [
]), ]),
("server", "Server", "health.html", "ti-server-2", [ ("server", "Server", "health.html", "ti-server-2", [
("health", "Health", "health.html", "ti-heartbeat"), ("health", "Health", "health.html", "ti-heartbeat"),
("preflight", "Preflight", "preflight.html", "ti-checklist"),
("system-log", "System log", "system-log.html", "ti-file-text"), ("system-log", "System log", "system-log.html", "ti-file-text"),
("backup", "Backup", "backup.html", "ti-archive"), ("backup", "Backup", "backup.html", "ti-archive"),
("users", "Users", "users.html", "ti-users"), ("users", "Users", "users.html", "ti-users"),
@@ -52,15 +51,39 @@ HEAD = """<!DOCTYPE html>
</head> </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>') ' · <a href="#">Source</a> · No warranty</footer>')
def touchnav(section, page):
"""The navigation below 1024 px: a Menu that opens over the page, its
sections folded but the current one - details elements, no script."""
out = ['<details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel">']
for key, label, href, _icon, pages in NAV:
if not pages:
cur = ' aria-current="page"' if key == section else ""
out.append(f'<a href="{href}"{cur}>{label}</a>')
continue
opened = " open" if key == section else ""
out.append(f'<details class="sp-touchnav-group"{opened}><summary>{label}</summary>')
for pkey, plabel, phref, picon in pages:
cur = ' aria-current="page"' if key == section and pkey == page else ""
out.append(f'<a href="{phref}"{cur}><i class="ti {picon}"></i>{plabel}</a>')
out.append('</details>')
opened = " open" if section == "user" else ""
out += [f'<details class="sp-touchnav-group"{opened}><summary><i class="ti ti-user-circle"></i>admin</summary>',
'<a href="account.html"><i class="ti ti-key"></i>Account</a>',
'<a href="help.html"><i class="ti ti-help"></i>Help</a>',
'<form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form>',
'</details>', '</div></details>']
return "".join(out)
def navbar(section, page): def navbar(section, page):
out = ['<nav class="navbar is-primary" aria-label="main navigation">', ' <div class="container">', out = ['<nav class="navbar is-primary" aria-label="main navigation">', ' <div class="container">',
' <div class="navbar-brand"><a class="navbar-item" href="overview.html">' ' <div class="navbar-brand"><a class="navbar-item" href="overview.html">'
'<img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>', '<img src="../../selfpost-wordmark.svg" alt="SelfPost"></a>' + touchnav(section, page) + '</div>',
' <div class="navbar-menu is-active">', ' <div class="navbar-start">'] ' <div class="navbar-menu">', ' <div class="navbar-start">']
for key, label, href, _icon, pages in NAV: for key, label, href, _icon, pages in NAV:
cur = " sp-current" if key == section else "" cur = " sp-current" if key == section else ""
if not pages: if not pages:
@@ -12,8 +12,8 @@
<body> <body>
<nav class="navbar is-primary" aria-label="main navigation"> <nav class="navbar is-primary" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div> <div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group"><summary>Outbound</summary><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group"><summary>Inbound</summary><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
<div class="navbar-menu is-active"> <div class="navbar-menu">
<div class="navbar-start"> <div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a> <a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
@@ -39,7 +39,6 @@
<a class="navbar-link" href="health.html">Server</a> <a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a> <a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a> <a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a> <a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a> <a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
@@ -84,7 +83,7 @@
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>list</dt><dd>page_head, then full-width boxes: the add form first, the table second. <a href="out-domains.html">Example</a></dd></div> <div><dt>list</dt><dd>page_head, then full-width boxes: the add form first, the table second. <a href="out-domains.html">Example</a></dd></div>
<div><dt>detail</dt><dd>page_head, then <code>columns</code>: <code>is-3</code> side menu + <code>is-9</code> boxes. <a href="out-domain.html">Example</a></dd></div> <div><dt>detail</dt><dd>page_head, then <code>columns</code>: <code>is-3</code> side menu + <code>is-9</code> boxes. <a href="out-domain.html">Example</a></dd></div>
<div><dt>cards</dt><dd>page_head, then <code>columns sp-cards</code>: two <code>is-6</code> stacks of numbered boxes read in rows, odd on the left and even on the right, each stack closed up. On a phone the boxes come in the order of their numbers. The two stacks under this box are laid out so. <a href="health.html">Example</a></dd></div> <div><dt>cards</dt><dd>page_head, then <code>columns sp-cards</code>: two <code>is-6</code> stacks of numbered boxes read in rows, odd on the left and even on the right, each stack closed up. On a phone the boxes come in the order of their numbers: each box carries <code>data-order</code>, its own number, up to twelve. The two stacks under this box are laid out so. <a href="health.html">Example</a></dd></div>
<div><dt>form</dt><dd>page_head, then boxes in equal <code>columns</code>, one submit row under them. <a href="out-app.html">Example</a></dd></div> <div><dt>form</dt><dd>page_head, then boxes in equal <code>columns</code>, one submit row under them. <a href="out-app.html">Example</a></dd></div>
<div><dt>signed out</dt><dd>The split brand / form screen, no navbar. <a href="login.html">Example</a></dd></div> <div><dt>signed out</dt><dd>The split brand / form screen, no navbar. <a href="login.html">Example</a></dd></div>
</dl> </dl>
@@ -93,7 +92,7 @@
<div class="columns sp-cards"> <div class="columns sp-cards">
<div class="column is-6"> <div class="column is-6">
<div class="box"> <div class="box" data-order="1">
<div class="sp-box-head"><span class="sp-no">01</span><h2>postmark</h2><div class="sp-end">Worst status of the page's checks</div></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>postmark</h2><div class="sp-end">Worst status of the page's checks</div></div>
<div class="sp-box-body"> <div class="sp-box-body">
<div class="buttons"> <div class="buttons">
@@ -105,14 +104,14 @@
</div> </div>
</div> </div>
<div class="box"> <div class="box" data-order="3">
<div class="sp-box-head"><span class="sp-no">03</span><h2>status_tag</h2><div class="sp-end">One mapping, one function</div></div> <div class="sp-box-head"><span class="sp-no">03</span><h2>status_tag</h2><div class="sp-end">One mapping, one function</div></div>
<div class="table-container"><table class="table is-fullwidth"> <div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Status</th><th>Tag</th><th>Bulma classes</th></tr></thead> <thead><tr><th>Status</th><th>Tag</th><th>Bulma classes</th></tr></thead>
<tbody> <tbody>
<tr><td>ok, published, running, sent</td><td><span class="tag is-success is-light">ok</span></td><td class="sp-mono sp-small">tag is-success is-light</td></tr> <tr><td>ok, published, running, sent</td><td><span class="tag is-success is-light">ok</span></td><td class="sp-mono sp-small">tag is-success is-light</td></tr>
<tr><td>warn, deferred, quiet</td><td><span class="tag is-warning is-light">warn</span></td><td class="sp-mono sp-small">tag is-warning is-light</td></tr> <tr><td>warn, deferred, quiet</td><td><span class="tag is-warning is-light">warn</span></td><td class="sp-mono sp-small">tag is-warning is-light</td></tr>
<tr><td>error, fail, mismatch, bounced</td><td><span class="tag is-danger is-light">fail</span></td><td class="sp-mono sp-small">tag is-danger is-light</td></tr> <tr><td>error, fail, mismatch, bounced, reject, rate-limited</td><td><span class="tag is-danger is-light">fail</span></td><td class="sp-mono sp-small">tag is-danger is-light</td></tr>
<tr><td>unknown, off, neutral counts</td><td><span class="tag is-light">off</span></td><td class="sp-mono sp-small">tag is-light</td></tr> <tr><td>unknown, off, neutral counts</td><td><span class="tag is-light">off</span></td><td class="sp-mono sp-small">tag is-light</td></tr>
<tr><td>held: quarantine · role: global</td><td><span class="tag is-primary is-light">quarantine</span></td><td class="sp-mono sp-small">tag is-primary is-light</td></tr> <tr><td>held: quarantine · role: global</td><td><span class="tag is-primary is-light">quarantine</span></td><td class="sp-mono sp-small">tag is-primary is-light</td></tr>
<tr><td>a tag that opens a <code>sheet</code></td><td><button type="button" class="tag is-danger is-light" popovertarget="kit-sheet">reject</button><div class="sp-sheet" id="kit-sheet" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-danger is-light">reject</span><h2>sheet</h2><button type="button" popovertarget="kit-sheet" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>What it holds</th><td>What does not fit the row, as name and value</td></tr><tr><th>How it closes</th><td class="sp-mono sp-small">Esc<br>a click outside<br>the cross</td></tr></tbody></table></div></div></div></div></td><td class="sp-mono sp-small">button.tag + sp-sheet (popover)</td></tr> <tr><td>a tag that opens a <code>sheet</code></td><td><button type="button" class="tag is-danger is-light" popovertarget="kit-sheet">reject</button><div class="sp-sheet" id="kit-sheet" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-danger is-light">reject</span><h2>sheet</h2><button type="button" popovertarget="kit-sheet" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>What it holds</th><td>What does not fit the row, as name and value</td></tr><tr><th>How it closes</th><td class="sp-mono sp-small">Esc<br>a click outside<br>the cross</td></tr></tbody></table></div></div></div></div></td><td class="sp-mono sp-small">button.tag + sp-sheet (popover)</td></tr>
@@ -122,7 +121,7 @@
</div> </div>
<div class="column is-6"> <div class="column is-6">
<div class="box"> <div class="box" data-order="2">
<div class="sp-box-head"><span class="sp-no">02</span><h2>box · box_head</h2><div class="sp-end">The end slot: a note, a tag, a link or a filter form</div><a class="sp-help" href="help.html#checks" title="Optional: the section's Help topic"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>box · box_head</h2><div class="sp-end">The end slot: a note, a tag, a link or a filter form</div><a class="sp-help" href="help.html#checks" title="Optional: the section's Help topic"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"><p class="mb-2">The <i class="ti ti-help-circle"></i> after the end slot is <code>sp-help</code>: an optional link to the section's topic on the Help page. It replaces today's help drawer.</p><p>box_body — padded content. A <code>table</code> or a run of <code>sp-record</code> blocks sits directly in the box, without box_body.</p></div> <div class="sp-box-body"><p class="mb-2">The <i class="ti ti-help-circle"></i> after the end slot is <code>sp-help</code>: an optional link to the section's topic on the Help page. It replaces today's help drawer.</p><p>box_body — padded content. A <code>table</code> or a run of <code>sp-record</code> blocks sits directly in the box, without box_body.</p></div>
<div class="sp-box-foot"><span>box_foot — a legend, paging</span><span class="sp-end">end slot</span></div> <div class="sp-box-foot"><span>box_foot — a legend, paging</span><span class="sp-end">end slot</span></div>
@@ -150,9 +149,9 @@
<div class="columns"> <div class="columns">
<div class="column is-7"> <div class="column is-7">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">05</span><h2>dns_record · copy_field</h2><div class="sp-end">One block per record</div></div> <div class="sp-box-head"><span class="sp-no">05</span><h2>dns_record · copy_field</h2><div class="sp-end">One block per record · folded when DNS holds it as shown</div></div>
<div class="sp-record"> <details class="sp-record">
<div class="sp-record-head"><h3>Record</h3><span class="tag is-success is-light">published</span><small>A one-line note</small></div> <summary class="sp-record-head"><h3>Record</h3><span class="tag is-success is-light">published</span><small>A one-line note</small><span class="sp-record-toggle"><i class="ti ti-eye"></i><span>Show values</span><span>Hide values</span></span></summary>
<div class="sp-row"> <div class="sp-row">
<div class="field"><label class="label">Host / name</label> <div class="field"><label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="copy_field — single line"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div> <div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="copy_field — single line"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
@@ -160,7 +159,7 @@
</div> </div>
<div class="field"><label class="label">Value to publish</label> <div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><textarea class="textarea" rows="2" readonly>copy_field — multi-line, for a DKIM key</textarea></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div> <div class="field has-addons"><div class="control is-expanded"><textarea class="textarea" rows="2" readonly>copy_field — multi-line, for a DKIM key</textarea></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
</div> </details>
<div class="sp-record"> <div class="sp-record">
<div class="sp-record-head"><h3>Record</h3><span class="tag is-danger is-light">mismatch</span><small>"In DNS now" appears only when it differs</small></div> <div class="sp-record-head"><h3>Record</h3><span class="tag is-danger is-light">mismatch</span><small>"In DNS now" appears only when it differs</small></div>
<div class="field"><label class="label">In DNS now</label> <div class="field"><label class="label">In DNS now</label>
@@ -172,7 +171,7 @@
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">07</span><h2>Table conventions</h2><div class="sp-end">Always inside <code>table-container</code> · an address breaks after its @</div></div> <div class="sp-box-head"><span class="sp-no">07</span><h2>Table conventions</h2><div class="sp-end">Always inside <code>table-container</code> · an address breaks after its @</div></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Time, UTC</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead> <thead><tr><th>Time</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
<tbody> <tbody>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td><a href="#"><strong>the row's link</strong></a></td><td class="sp-clip">A long subject is clipped with an ellipsis instead of wrapping the row</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="#">Details</a><form method="post" action="#"><button type="submit" class="has-text-danger">Delete</button></form></td></tr> <tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td><a href="#"><strong>the row's link</strong></a></td><td class="sp-clip">A long subject is clipped with an ellipsis instead of wrapping the row</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="#">Details</a><form method="post" action="#"><button type="submit" class="has-text-danger">Delete</button></form></td></tr>
</tbody> </tbody>
@@ -182,9 +181,10 @@
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">09</span><h2>log_pane</h2><div class="sp-end">sp-d time · sp-w warning · sp-e error</div></div> <div class="sp-box-head"><span class="sp-no">09</span><h2>log_pane</h2><div class="sp-end">sp-d time · sp-w warning · sp-e error</div></div>
<pre class="sp-log"><span class="sp-d">13:58:07</span> an ordinary line <pre class="sp-log"><span class="sp-ll"><span class="sp-d">13:58:07</span> an ordinary line
<span class="sp-d">13:58:09</span> <span class="sp-w">a deferred delivery — sp-w</span> </span><span class="sp-ll"><span class="sp-d">13:58:09</span> <span class="sp-w">a deferred delivery — sp-w</span>
<span class="sp-d">13:57:41</span> <span class="sp-e">a reject or a bounce — sp-e</span></pre> </span><span class="sp-ll"><span class="sp-d">13:57:41</span> <span class="sp-e">a reject or a bounce — sp-e</span>
</span></pre>
</div> </div>
</div> </div>
@@ -195,7 +195,9 @@
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Label</dt><dd>Value</dd></div> <div><dt>Label</dt><dd>Value</dd></div>
<div><dt>Mono value</dt><dd class="sp-mono">4XcB7k2Jm9z1</dd></div> <div><dt>Mono value</dt><dd class="sp-mono">4XcB7k2Jm9z1</dd></div>
<div><dt>Label with a tag <span class="tag is-success is-light">matches</span></dt><dd>a verdict on the value</dd></div>
<div><dt>sp-big</dt><dd class="sp-big">5 days</dd><dd class="sp-muted">a supporting line</dd></div> <div><dt>sp-big</dt><dd class="sp-big">5 days</dd><dd class="sp-muted">a supporting line</dd></div>
<div class="sp-wide"><dt>sp-wide</dt><dd class="sp-mono">a value that takes the whole row, a line of a server's own</dd></div>
</dl> </dl>
</div> </div>
</div> </div>
@@ -204,9 +206,9 @@
<div class="sp-box-head"><span class="sp-no">08</span><h2>timeline</h2></div> <div class="sp-box-head"><span class="sp-no">08</span><h2>timeline</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
<ol class="sp-timeline"> <ol class="sp-timeline">
<li><time>13:58:07 UTC</time><strong>Done</strong> — default</li> <li><time>13:58:07</time><strong>Done</strong> — default</li>
<li class="sp-warn"><time>13:58:09 UTC</time><strong>Deferred</strong> — sp-warn</li> <li class="sp-warn"><time>13:58:09</time><strong>Deferred</strong> — sp-warn</li>
<li class="sp-fail"><time>14:40:00 UTC</time><strong>Bounced</strong> — sp-fail</li> <li class="sp-fail"><time>14:40:00</time><strong>Bounced</strong> — sp-fail</li>
<li class="sp-pending"><time>not yet</time>Expected next — sp-pending</li> <li class="sp-pending"><time>not yet</time>Expected next — sp-pending</li>
</ol> </ol>
</div> </div>
@@ -289,7 +291,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 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> </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> </div>
</main> </main>
</body> </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>
@@ -12,8 +12,8 @@
<body> <body>
<nav class="navbar is-primary" aria-label="main navigation"> <nav class="navbar is-primary" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div> <div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group" open><summary>Outbound</summary><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html" aria-current="page"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group"><summary>Inbound</summary><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
<div class="navbar-menu is-active"> <div class="navbar-menu">
<div class="navbar-start"> <div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a> <a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current"> <div class="navbar-item has-dropdown is-hoverable sp-current">
@@ -39,7 +39,6 @@
<a class="navbar-link" href="health.html">Server</a> <a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a> <a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a> <a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a> <a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a> <a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
@@ -92,7 +91,7 @@
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Reports</h2></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>Reports</h2></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Received, UTC</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead> <thead><tr><th>Received</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
<tbody> <tbody>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 04:40</td><td class="sp-muted">Outlook.com</td><td>20 Sep</td><td>1 903</td><td><span class="tag is-warning is-light">7</span></td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr> <tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 04:40</td><td class="sp-muted">Outlook.com</td><td>20 Sep</td><td>1 903</td><td><span class="tag is-warning is-light">7</span></td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 02:15</td><td class="sp-muted">google.com</td><td>20 Sep</td><td>6 410</td><td>0</td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr> <tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 02:15</td><td class="sp-muted">google.com</td><td>20 Sep</td><td>6 410</td><td>0</td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
@@ -103,7 +102,7 @@
</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> </div>
</main> </main>
</body> </body>
@@ -12,8 +12,8 @@
<body> <body>
<nav class="navbar is-primary" aria-label="main navigation"> <nav class="navbar is-primary" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div> <div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group" open><summary>Outbound</summary><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html" aria-current="page"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group"><summary>Inbound</summary><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
<div class="navbar-menu is-active"> <div class="navbar-menu">
<div class="navbar-start"> <div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a> <a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current"> <div class="navbar-item has-dropdown is-hoverable sp-current">
@@ -39,7 +39,6 @@
<a class="navbar-link" href="health.html">Server</a> <a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a> <a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a> <a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a> <a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a> <a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
@@ -68,7 +67,7 @@
<div class="sp-head"> <div class="sp-head">
<div class="sp-grow"> <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> <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> <p class="sp-route"><span>shop.example.org</span><span class="tag is-success is-light">1 903 pass</span><span class="tag is-warning is-light">7 fail</span></p>
</div> </div>
</div> </div>
@@ -81,8 +80,8 @@
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Reporter</dt><dd>Outlook.com</dd></div> <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>Report id</dt><dd class="sp-mono sp-small">5f1c9a0e7b2d4e61a3</dd></div>
<div><dt>Window</dt><dd>20 Sep 00:00 – 21 Sep 00:00 UTC</dd></div> <div><dt>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 UTC</dd></div> <div><dt>Received</dt><dd>2026-09-21 04:40</dd></div>
</dl> </dl>
</div> </div>
</div> </div>
@@ -112,7 +111,7 @@
</table></div> </table></div>
</div> </div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer> <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> </div>
</main> </main>
</body> </body>
+5 -6
View File
@@ -12,8 +12,8 @@
<body> <body>
<nav class="navbar is-primary" aria-label="main navigation"> <nav class="navbar is-primary" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div> <div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group" open><summary>Outbound</summary><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html" aria-current="page"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group"><summary>Inbound</summary><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
<div class="navbar-menu is-active"> <div class="navbar-menu">
<div class="navbar-start"> <div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a> <a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current"> <div class="navbar-item has-dropdown is-hoverable sp-current">
@@ -39,7 +39,6 @@
<a class="navbar-link" href="health.html">Server</a> <a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a> <a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a> <a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a> <a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a> <a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
@@ -77,7 +76,7 @@
<div class="sp-box-head"><span class="sp-no">01</span><h2>Ingest</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#dmarc" title="What the reports are and where they go"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Ingest</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#dmarc" title="What the reports are and where they go"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Last report</dt><dd>2026-09-21 06:12 UTC</dd></div> <div><dt>Last report</dt><dd>2026-09-21 06:12</dd></div>
<div><dt>This week</dt><dd>14 kept · 0 parse failures</dd></div> <div><dt>This week</dt><dd>14 kept · 0 parse failures</dd></div>
<div><dt>Hosted addresses</dt><dd class="sp-mono sp-small">dmarc-reports+&lt;domain&gt;@mail.example.org</dd></div> <div><dt>Hosted addresses</dt><dd class="sp-mono sp-small">dmarc-reports+&lt;domain&gt;@mail.example.org</dd></div>
<div><dt>Retention</dt><dd>500 reports a domain or 90 days</dd></div> <div><dt>Retention</dt><dd>500 reports a domain or 90 days</dd></div>
@@ -88,7 +87,7 @@
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Recent reports</h2></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>Recent reports</h2></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Received, UTC</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead> <thead><tr><th>Received</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
<tbody> <tbody>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 06:12</td><td><a href="dmarc-domain.html">example.org</a></td><td class="sp-muted">google.com</td><td>20 Sep</td><td>412</td><td>0</td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr> <tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 06:12</td><td><a href="dmarc-domain.html">example.org</a></td><td class="sp-muted">google.com</td><td>20 Sep</td><td>412</td><td>0</td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 04:40</td><td><a href="dmarc-domain.html">shop.example.org</a></td><td class="sp-muted">Outlook.com</td><td>20 Sep</td><td>1 903</td><td><span class="tag is-warning is-light">7</span></td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr> <tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 04:40</td><td><a href="dmarc-domain.html">shop.example.org</a></td><td class="sp-muted">Outlook.com</td><td>20 Sep</td><td>1 903</td><td><span class="tag is-warning is-light">7</span></td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
@@ -98,7 +97,7 @@
</table></div> </table></div>
</div> </div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer> <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> </div>
</main> </main>
</body> </body>
+39 -21
View File
@@ -12,8 +12,8 @@
<body> <body>
<nav class="navbar is-primary" aria-label="main navigation"> <nav class="navbar is-primary" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div> <div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group"><summary>Outbound</summary><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group"><summary>Inbound</summary><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group" open><summary>Server</summary><a href="health.html" aria-current="page"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
<div class="navbar-menu is-active"> <div class="navbar-menu">
<div class="navbar-start"> <div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a> <a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
@@ -39,7 +39,6 @@
<a class="navbar-link" href="health.html">Server</a> <a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item is-active" href="health.html"><i class="ti ti-heartbeat"></i>Health</a> <a class="navbar-item is-active" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a> <a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a> <a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a> <a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
@@ -62,34 +61,33 @@
</div> </div>
</nav> </nav>
<div class="sp-perf" aria-hidden="true"></div> <div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="health.html" aria-current="page"><i class="ti ti-heartbeat"></i>Health</a><a href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></div></div> <div class="sp-subnav"><div class="container"><a href="health.html" aria-current="page"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></div></div>
<main> <main>
<div class="container sp-page"> <div class="container sp-page">
<div class="sp-head"> <div class="sp-head">
<div class="sp-grow"> <div class="sp-grow">
<p class="sp-kicker">Server</p> <p class="sp-kicker">Server</p>
<h1 class="title is-3">Health</h1> <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> </div>
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form> <form method="post" action="#"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form>
</div> </div>
<div class="columns sp-cards"> <div class="columns sp-cards">
<div class="column is-6"> <div class="column is-6">
<div class="box" id="processes"> <div class="box" id="processes" data-order="1">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Processes</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Processes</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
<div class="table-container"><table class="table is-fullwidth"> <div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead> <thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
<tbody> <tbody>
<tr><td>postfix</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 41, uptime 18 days</td></tr> <tr><td>postfix</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 41, uptime 18 days</td></tr>
<tr><td>opendkim</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 39, uptime 18 days</td></tr>
<tr><td>journal-milter</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 40, uptime 18 days</td></tr> <tr><td>journal-milter</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 40, uptime 18 days</td></tr>
<tr><td>panel</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 44, uptime 18 days</td></tr> <tr><td>panel</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 44, uptime 18 days</td></tr>
<tr><td>dmarc-ingest</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 47, uptime 18 days</td></tr> <tr><td>dmarc-ingest</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 47, uptime 18 days</td></tr>
</tbody> </tbody>
</table></div> </table></div>
</div> </div>
<div class="box" id="sender-check"> <div class="box" id="sender-check" data-order="3">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Sender check</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#sender-check" title="What is checked and what these numbers are for"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">03</span><h2>Sender check</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#sender-check" title="What is checked and what these numbers are for"><i class="ti ti-help-circle"></i></a></div>
<div class="table-container"><table class="table is-fullwidth"> <div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Period</th><th>Panel not answering</th><th>Connections turned away</th><th>Messages deferred</th></tr></thead> <thead><tr><th>Period</th><th>Panel not answering</th><th>Connections turned away</th><th>Messages deferred</th></tr></thead>
@@ -103,16 +101,17 @@
<p class="sp-muted">The panel holds each message's <code>From:</code> against the application that sends it. While the panel does not answer, Postfix answers the sending application with a temporary error instead of accepting mail unchecked, and the application tries again later. Time is counted only while Postfix itself was running; ≈ marks a period with a start at which that could not be asked, and its time is at most what is shown. Counted since <span class="sp-mono">2026-10-13</span>.</p> <p class="sp-muted">The panel holds each message's <code>From:</code> against the application that sends it. While the panel does not answer, Postfix answers the sending application with a temporary error instead of accepting mail unchecked, and the application tries again later. Time is counted only while Postfix itself was running; ≈ marks a period with a start at which that could not be asked, and its time is at most what is shown. Counted since <span class="sp-mono">2026-10-13</span>.</p>
</div> </div>
</div> </div>
<div class="box" id="hostname"> <div class="box" id="hostname" data-order="5">
<div class="sp-box-head"><span class="sp-no">05</span><h2>Hostname and reverse DNS</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#rdns" title="Forward and reverse DNS"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">05</span><h2>Hostname and reverse DNS</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#rdns" title="Forward and reverse DNS"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Hostname</dt><dd class="sp-mono">mail.example.org</dd></div> <div><dt>Hostname</dt><dd class="sp-mono">mail.example.org</dd></div>
<div><dt>Lookup</dt><dd class="sp-mono">→ 203.0.113.25<br>← mail.example.org</dd></div> <div><dt>Lookup</dt><dd class="sp-mono">→ 203.0.113.25<br>← mail.example.org</dd></div>
<div class="sp-wide"><dt>Greeting</dt><dd class="sp-mono">220 mail.example.org ESMTP</dd></div>
</dl> </dl>
</div> </div>
</div> </div>
<div class="box" id="machine"> <div class="box" id="machine" data-order="7">
<div class="sp-box-head"><span class="sp-no">07</span><h2>Machine</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#checks" title="What these readings mean"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">07</span><h2>Machine</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#checks" title="What these readings mean"><i class="ti ti-help-circle"></i></a></div>
<div class="table-container"><table class="table is-fullwidth"> <div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead> <thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
@@ -125,54 +124,73 @@
</div> </div>
</div> </div>
<div class="column is-6"> <div class="column is-6">
<div class="box" id="sockets"> <div class="box" id="sockets" data-order="2">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Milter sockets</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>Milter sockets</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
<div class="table-container"><table class="table is-fullwidth"> <div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Milter</th><th>State</th></tr></thead> <thead><tr><th>Milter</th><th>State</th></tr></thead>
<tbody> <tbody>
<tr><td>opendkim<br><span class="sp-muted sp-mono sp-small">inet:127.0.0.1:8891</span></td><td><span class="tag is-success is-light">ok</span></td></tr> <tr><td>dkim-signer <span class="sp-muted">· applications</span><br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/sign.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
<tr><td>dkim-signer <span class="sp-muted">· notifications</span><br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/sign-local.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
<tr><td>journal-milter<br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/journal.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr> <tr><td>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>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> <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> </tbody>
</table></div> </table></div>
</div> </div>
<div class="box" id="certificate"> <div class="box" id="certificate" data-order="4">
<div class="sp-box-head"><span class="sp-no">04</span><h2>TLS certificate</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#certificate" title="When it warns and what to do"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">04</span><h2>TLS certificate</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#certificate" title="When it warns and what to do"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Expires</dt><dd class="sp-mono">2026-11-02 09:14 UTC</dd></div> <div><dt>Expires</dt><dd class="sp-mono">2026-11-02 09:14</dd></div>
<div><dt>Names</dt><dd class="sp-mono">mail.example.org</dd></div> <div><dt>Names <span class="tag is-success is-light">matches</span></dt><dd class="sp-mono">mail.example.org</dd></div>
<div><dt>Renewal expires</dt><dd class="sp-mono">2027-01-31 08:02 UTC</dd></div> <div><dt>Renewal expires</dt><dd class="sp-mono">2027-01-31 08:02</dd></div>
<div><dt>Renewal mounted</dt><dd class="sp-mono">2026-10-15 03:12 UTC</dd></div> <div><dt>Renewal mounted</dt><dd class="sp-mono">2026-10-15 03:12</dd></div>
</dl> </dl>
<p class="mt-4 sp-muted">Postfix still serves the certificate above. A renewed one is waiting in the mounted file; Postfix takes it up at its next check for a renewal, at most a day after it was mounted.</p> <p class="mt-4 sp-muted">Postfix still serves the certificate above. A renewed one is waiting in the mounted file; Postfix takes it up at its next check for a renewal, at most a day after it was mounted.</p>
</div> </div>
</div> </div>
<div class="box" id="notification-mail"> <div class="box" id="notification-mail" data-order="6">
<div class="sp-box-head"><span class="sp-no">06</span><h2>Notification mail</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div><a class="sp-help" href="help.html#notifications" title="What the panel sends and what makes it arrive"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">06</span><h2>Notification mail</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div><a class="sp-help" href="help.html#notifications" title="What the panel sends and what makes it arrive"><i class="ti ti-help-circle"></i></a></div>
<div class="table-container"><table class="table is-fullwidth"> <div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Record</th><th>State</th></tr></thead> <thead><tr><th>Record</th><th>State</th></tr></thead>
<tbody> <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>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>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> </tbody>
</table></div> </table></div>
<div class="sp-box-body"> <div class="sp-box-body">
<p class="sp-muted">Sent from <span class="sp-mono">postmaster@mail.example.org</span>. What to publish is under <a href="settings.html#notification-mail">Settings</a>.</p> <p class="sp-muted">Sent from <span class="sp-mono">postmaster@mail.example.org</span>. What to publish is under <a href="settings.html#notification-mail">Settings</a>.</p>
</div> </div>
</div> </div>
<div class="box" id="configuration"> <div class="box" id="configuration" data-order="8">
<div class="sp-box-head"><span class="sp-no">08</span><h2>Configuration</h2><div class="sp-end">Safe to run at any time</div></div> <div class="sp-box-head"><span class="sp-no">08</span><h2>Configuration</h2><div class="sp-end">Safe to run at any time</div></div>
<div class="sp-box-body"> <div class="sp-box-body">
<p class="mb-3">Regenerates the OpenDKIM and Postfix configuration from the database and reloads both daemons. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p> <div class="mb-3"><dl class="sp-facts">
<div><dt>Your address</dt><dd class="sp-mono">198.51.100.7</dd><dd class="sp-muted">From X-Forwarded-For, which the trusted proxy 172.18.0.1 set</dd></div>
</dl></div>
<p class="mb-3">Regenerates the Postfix configuration and the spam filter's lists from the database, removes the keys of deleted domains, and reloads Postfix. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-reload"></i>Reload configuration</button></form> <form method="post" action="#"><button type="submit" class="button"><i class="ti ti-reload"></i>Reload configuration</button></form>
</div> </div>
</div> </div>
<div class="box" id="test-message" data-order="9">
<div class="sp-box-head"><span class="sp-no">09</span><h2>Test message</h2><div class="sp-end">Shows what the receiving server answers</div><a class="sp-help" href="help.html#test-message" title="What the test proves"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body">
<form class="sp-form" method="post" action="out-message.html">
<div class="field"><label class="label" for="rcpt">Recipient</label>
<div class="field has-addons">
<div class="control is-expanded has-icons-left"><input class="input" id="rcpt" name="recipient" type="email" placeholder="you@elsewhere.example" required><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
<div class="control"><button type="submit" class="button is-primary"><i class="ti ti-send"></i>Send</button></div>
</div>
<p class="help">From <span class="sp-mono">postmaster@mail.example.org</span>, signed with the server's own key, through Postfix as its notifications go. It opens in the <a href="out-log.html">Outbound log</a>, where its page follows it to the receiving server. Ten an hour.</p></div>
</form>
</div>
</div>
</div> </div>
</div> </div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer> <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> </div>
</main> </main>
</body> </body>
+23 -20
View File
@@ -12,8 +12,8 @@
<body> <body>
<nav class="navbar is-primary" aria-label="main navigation"> <nav class="navbar is-primary" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div> <div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group"><summary>Outbound</summary><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group"><summary>Inbound</summary><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></details><details class="sp-touchnav-group" open><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
<div class="navbar-menu is-active"> <div class="navbar-menu">
<div class="navbar-start"> <div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a> <a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
@@ -39,7 +39,6 @@
<a class="navbar-link" href="health.html">Server</a> <a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a> <a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a> <a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a> <a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a> <a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
@@ -80,6 +79,7 @@
<ul class="menu-list"> <ul class="menu-list">
<li><a class="is-active" href="#checks"><i class="ti ti-activity-heartbeat"></i>Health checks</a></li> <li><a class="is-active" href="#checks"><i class="ti ti-activity-heartbeat"></i>Health checks</a></li>
<li><a href="#certificate"><i class="ti ti-certificate"></i>TLS certificate</a></li> <li><a href="#certificate"><i class="ti ti-certificate"></i>TLS certificate</a></li>
<li><a href="#test-message"><i class="ti ti-send"></i>Test message</a></li>
<li><a href="#rdns"><i class="ti ti-arrows-exchange"></i>Reverse DNS</a></li> <li><a href="#rdns"><i class="ti ti-arrows-exchange"></i>Reverse DNS</a></li>
</ul> </ul>
<p class="menu-label">Outbound</p> <p class="menu-label">Outbound</p>
@@ -99,6 +99,7 @@
<li><a href="#recipients"><i class="ti ti-address-book"></i>Valid recipients</a></li> <li><a href="#recipients"><i class="ti ti-address-book"></i>Valid recipients</a></li>
<li><a href="#filter"><i class="ti ti-shield-check"></i>Spam filter</a></li> <li><a href="#filter"><i class="ti ti-shield-check"></i>Spam filter</a></li>
<li><a href="#inbound-log"><i class="ti ti-list-details"></i>Inbound log</a></li> <li><a href="#inbound-log"><i class="ti ti-list-details"></i>Inbound log</a></li>
<li><a href="#inbound-queue"><i class="ti ti-stack-2"></i>Inbound queue</a></li>
<li><a href="#filter-lists"><i class="ti ti-filter"></i>Filter lists</a></li> <li><a href="#filter-lists"><i class="ti ti-filter"></i>Filter lists</a></li>
</ul> </ul>
<p class="menu-label">Server</p> <p class="menu-label">Server</p>
@@ -117,44 +118,46 @@
<div class="column is-9"> <div class="column is-9">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Overview</h2></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Overview</h2></div>
<div class="sp-help-topic" id="checks"><h3>Health checks</h3><p>Six checks, and the worst of them is the postmark. <span class="tag is-success is-light">ok</span> needs nothing; <span class="tag is-warning is-light">warn</span> still delivers mail but will stop doing so if ignored; <span class="tag is-danger is-light">fail</span> means mail is being lost or refused now. <a href="health.html">Health</a> shows the readings behind them: CPU and memory are the container's own, not the host's spare capacity, and network is a short window, not a daily total.</p></div> <div class="sp-help-topic" id="checks"><h3>Health checks</h3><p>Six checks, and the worst of them is the postmark. <span class="tag is-success is-light">ok</span> needs nothing; <span class="tag is-warning is-light">warn</span> still delivers mail but will stop doing so if ignored; <span class="tag is-danger is-light">fail</span> means mail is being lost or refused now. <a href="health.html">Health</a> shows the readings behind them: CPU and memory are the container's own, not the host's spare capacity, and network is a short window, not a daily total. <em>Your address</em>, in the Configuration card, is the address the panel sees your browser at: behind a reverse proxy it must be your own, taken from <code>X-Forwarded-For</code>; when it is the proxy's, every visitor shares one sign-in limit, and Health warns and names the address to put in <code>TRUSTED_PROXY_CIDR</code>.</p></div>
<div class="sp-help-topic" id="certificate"><h3>TLS certificate</h3><p>The certificate Postfix presents on 465 and 587. The reverse proxy or the image mounts it, the panel does not issue it. Postfix serves its own copy of the mounted file and looks for a renewed one once a day by default; until it takes one up, the card shows it as a renewal waiting, and warns once it has waited longer than that check allows - the container log's cert-reload lines say why. It warns 14 days before expiry unless a renewal is already waiting, and fails once expired, or when the file is missing or unreadable — clients that verify certificates then refuse to connect.</p></div> <div class="sp-help-topic" id="certificate"><h3>TLS certificate</h3><p>The certificate Postfix presents on 465 and 587. The reverse proxy or the image mounts it, the panel does not issue it. Postfix serves its own copy of the mounted file and looks for a renewed one once a day by default; until it takes one up, the card shows it as a renewal waiting, and warns once it has waited longer than that check allows - the container log's cert-reload lines say why. It warns 14 days before expiry unless a renewal is already waiting, and fails once expired, or when the file is missing or unreadable — clients that verify certificates then refuse to connect. It also fails when none of its names is the hostname (<code>SELFPOST_HOSTNAME</code>) - the tag beside <em>Names</em> says which: a client that verifies the name refuses it however valid it is.</p></div>
<div class="sp-help-topic" id="rdns"><h3>Hostname and reverse DNS</h3><p>The hostname (<code>SELFPOST_HOSTNAME</code>) must resolve to this server's address, and that address must resolve back to the hostname. Large receivers reject or spam-folder mail from hosts where the two disagree. The PTR record is set at your hosting provider, not in your DNS zone.</p></div> <div class="sp-help-topic" id="test-message"><h3>Test message</h3><p>A message from <code>postmaster@</code> the server's hostname to one address you type, sent the way notifications are: through Postfix, signed with the DKIM key of the server's own name. It proves the path from this server - TLS, its address's reputation, the SPF and DKIM records of its name - not your domains', which only mail from an application does. The page that opens is its row in the Outbound log: it shows whether the receiving server took it, put it off or refused it, with the lines of <code>mail.log</code>. Whether it then reached the inbox or the spam folder only the mailbox can say. The global role sends it, at most ten an hour.</p></div>
<div class="sp-help-topic" id="rdns"><h3>Hostname and reverse DNS</h3><p>The hostname (<code>SELFPOST_HOSTNAME</code>) must resolve to this server's address, and that address must resolve back to the hostname. Large receivers reject or spam-folder mail from hosts where the two disagree. The PTR record is set at your hosting provider, not in your DNS zone. <em>Greeting</em> is the line Postfix opens every connection with, <code>220</code> and its banner; it must name the hostname too - a receiver compares it with the name it looked up - and a container id or <code>localhost</code> there is a warning.</p></div>
</div> </div>
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Outbound</h2></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>Outbound</h2></div>
<div class="sp-help-topic" id="dns"><h3>DNS records</h3><p>DKIM proves the message was signed by this relay, SPF lists the addresses allowed to send for the domain, DMARC tells receivers what to do when both fail and where to report. Publish all three exactly as shown; merge SPF into an existing record rather than adding a second one. A domain's status is the worst of its three records, and results are cached for a few minutes, so use Re-check DNS after publishing. The SPF check is shallow: it looks for this server's address in the record itself and does not follow <code>include:</code> or <code>redirect=</code>, so a record that relies on them is reported as a warning, not a failure. <code>p=none</code> does not affect delivery; tighten to <code>quarantine</code>, then <code>reject</code>, once the reports look clean. A domain's report address is set in its Domain settings.</p></div> <div class="sp-help-topic" id="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 both senders a message has. The envelope sender is held against the login by Postfix. The <code>From:</code> line, the one a mail program shows and the one the DKIM signature is chosen by, is held against it by the panel, under the same rule: an application set to any address of its domain may write any address of that domain, and one with a list of addresses only those. A message with any other <code>From:</code> - another of your domains, the server's own name, an address somewhere else, or none at all - is refused for good with <code>550 5.7.1</code>. It is a rejected row of the Outbound log, and its page says which address was refused. This is what keeps one application from getting mail signed with another domain's key.<br><br>The check is strict. While the panel does not answer, Postfix does not accept mail unchecked: it replies with a temporary error and the application sends again later. Server › Health counts what that costs: for how long the panel did not answer while Postfix itself was running; 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="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="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> 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="queue"><h3>Queue and retries</h3><p>The page lists the messages Postfix has accepted and not yet delivered, one row per recipient: mail being sent right now and mail deferred for a later attempt, with the last reply of the receiving server, or why no connection was made. Mail for an inbound domain waits under Inbound › Queue instead. Only the global role can open it, and it only shows; nothing here deletes or forces a message, and Details opens the message in the Outbound log. 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; the last reply says which.</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>
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Inbound</h2></div> <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="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="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="filter"><h3>Spam filter</h3><p>The spam filter belongs to the server, not to a domain. The server's administrator attaches it in the server's <code>.env</code>, and from then on every inbound domain is screened by it before mail is handed on; the box shows on or off and has nothing to switch. Off means mail goes to the upstream unfiltered. While the filter is down, mail is either accepted unfiltered (the default) or refused with a temporary error, so the sending server tries again later, as the server's setting says; the box states which.</p></div>
<div class="sp-help-topic" id="inbound-log"><h3>Inbound log</h3><p>One row per recipient of a message offered on port 25 for an inbound domain, with what was decided: <span class="tag is-success is-light">accept</span> — handed on to the upstream; <span class="tag is-danger is-light">reject</span> — refused for good; <span class="tag is-warning is-light">tempfail</span> — the sender was asked to come back later; <span class="tag is-primary is-light">quarantine</span> — held by the filter. <em>Why</em> names who decided. <code>rspamd</code> is the spam filter, with its action, the score against the reject threshold and the heaviest symbols; <code>postfix</code> is the relay's own rule — an unknown recipient, a sender domain that does not exist — with its reply; <code>selfpost</code> means nobody judged the message: it was relayed with no filter attached. Accept says what this server did, not what the upstream did with the message afterwards.<br><br>Accepted mail is recorded as it passes; refused mail is read from <code>mail.log</code>, so its row has no subject, and a message refused after its text was sent is one row however many recipients it had. A refusal to relay for a domain that is not an inbound one is every scanner on the internet and is not recorded. Rows are kept for the number of days set in Settings, and never more than 10 000.</p></div> <div class="sp-help-topic" id="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>
<div class="sp-help-topic" id="inbound-queue"><h3>Inbound queue</h3><p>Mail accepted on port 25 and not yet taken by its upstream, one row per recipient, with the upstream's last reply or why it could not be reached. It is the same Postfix queue as Outbound › Queue, split by the recipient's domain, and retried the same way; an upstream that refuses or does not answer is what makes it grow. Only the global role can open it, and it only shows.</p></div>
<div class="sp-help-topic" id="filter-lists"><h3>Filter lists</h3><p>Two lists for the whole server that correct the spam filter by hand. An entry is a sender address, a sender domain — that domain, not its subdomains — or a client address or network in CIDR form. A sender on the Deny list is refused outright. A sender on the Allow list is given a weight no spam score outweighs, so the filter lets it through. The sender's address is whatever the sending server says it is - anyone can claim to be a sender you allow - so allow a client address or network where you can, and a sender address only for mail you would rather receive forged than lose. When entries of both lists match one message, <strong>Deny wins</strong>.<br><br>SelfPost does not apply the lists itself: it writes them as files under <code>/data/antispam/</code>, and rspamd rereads a file within seconds of a change. With the spam filter off the lists are kept and can be edited, but nothing reads them. The sender matched is the envelope sender — the one in the log — not the From line a mail program shows. Matched counts the messages in the Inbound log that the entry decided, so it reaches back as far as the log does; never means not once in that time, and such an entry is one to consider removing.</p></div> <div class="sp-help-topic" id="filter-lists"><h3>Filter lists</h3><p>Two lists for the whole server that correct the spam filter by hand. An entry is a sender address, a sender domain — that domain, not its subdomains — or a client address or network in CIDR form. A sender on the Deny list is refused outright. A sender on the Allow list is given a weight no spam score outweighs, so the filter lets it through. The sender's address is whatever the sending server says it is - anyone can claim to be a sender you allow - so allow a client address or network where you can, and a sender address only for mail you would rather receive forged than lose. When entries of both lists match one message, <strong>Deny wins</strong>.<br><br>SelfPost does not apply the lists itself: it writes them as files under <code>/data/antispam/</code>, and rspamd rereads a file within seconds of a change. With the spam filter off the lists are kept and can be edited, but nothing reads them. The sender matched is the envelope sender — the one in the log — not the From line a mail program shows. Matched counts the messages in the Inbound log that the entry decided, so it reaches back as far as the log does; never means not once in that time, and such an entry is one to consider removing.</p></div>
</div> </div>
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div> <div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div>
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys and application credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. The Postfix queue is not in it. The backup is taken while the server is running, and Postfix keeps moving messages through the queue, so a copy could hold a message twice or miss it; mail still waiting for delivery is therefore not carried over, and a restored server starts with an empty queue. To keep the queue, stop the container and archive the project directory instead. The private key in <code>certs/</code> has to be readable by the panel: give it to group 998 on the host (<code>chgrp 998</code>, <code>chmod 640</code>). Until then the panel refuses to make a backup and names the file, rather than hand over an archive without the key. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div> <div class="sp-help-topic" id="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 of their other sessions.</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. 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="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 limit. The TLS certificate is 14 days from expiry, and again at 3 days if it is still the same certificate. A message has waited in the queue for more than three days, with one more message when none is left. A global user is told all of it; a domain administrator is told about the DNS records and limits of their own domains.<br><br>Each user turns notifications on or off under Account and needs an e-mail there; Server › Settings turns them off for everyone. They are the panel's own mail: not rows of the Outbound log, and not counted against any limit.<br><br>For that mail to arrive, the server's own name needs two DNS records of its own, as each of your domains does: a DKIM key, which the panel generates and signs notifications with, and an SPF record, <code>v=spf1 a -all</code>. Server › Settings gives both with the values to publish, and Server › Health shows whether they are in place. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains does not depend on either.</p></div> <div 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>
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">05</span><h2>Account</h2></div> <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> </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> </div>
</main> </main>
</body> </body>
@@ -12,8 +12,8 @@
<body> <body>
<nav class="navbar is-primary" aria-label="main navigation"> <nav class="navbar is-primary" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div> <div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group"><summary>Outbound</summary><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group" open><summary>Inbound</summary><a href="in-domains.html" aria-current="page"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
<div class="navbar-menu is-active"> <div class="navbar-menu">
<div class="navbar-start"> <div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a> <a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
@@ -39,7 +39,6 @@
<a class="navbar-link" href="health.html">Server</a> <a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a> <a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a> <a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a> <a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a> <a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
@@ -94,7 +93,7 @@
</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> </div>
</main> </main>
</body> </body>
+13 -14
View File
@@ -12,8 +12,8 @@
<body> <body>
<nav class="navbar is-primary" aria-label="main navigation"> <nav class="navbar is-primary" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div> <div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group"><summary>Outbound</summary><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group" open><summary>Inbound</summary><a href="in-domains.html" aria-current="page"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
<div class="navbar-menu is-active"> <div class="navbar-menu">
<div class="navbar-start"> <div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a> <a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
@@ -39,7 +39,6 @@
<a class="navbar-link" href="health.html">Server</a> <a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a> <a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a> <a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a> <a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a> <a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
@@ -70,7 +69,7 @@
<div class="sp-grow"> <div class="sp-grow">
<p class="sp-kicker"><a href="in-domains.html">Inbound domains</a> /</p> <p class="sp-kicker"><a href="in-domains.html">Inbound domains</a> /</p>
<h1 class="title is-3">lists.example.org</h1> <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>
<div class="buttons"> <div class="buttons">
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form> <form method="post" action="#"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form>
@@ -101,8 +100,8 @@
<div class="column is-9"> <div class="column is-9">
<div class="box" id="dns"> <div class="box" id="dns">
<div class="sp-box-head"><span class="sp-no">01</span><h2>MX record</h2><div class="sp-end">Checked 1 min ago</div><a class="sp-help" href="help.html#inbound" title="Backup-MX and forwarding"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>MX record</h2><div class="sp-end">Checked 1 min ago</div><a class="sp-help" href="help.html#inbound" title="Backup-MX and forwarding"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-record"> <details class="sp-record">
<div class="sp-record-head"><h3>MX</h3><span class="tag is-success is-light">published</span><small>Keep any existing primary MX if this is a backup-MX</small></div> <summary class="sp-record-head"><h3>MX</h3><span class="tag is-success is-light">published</span><small>Keep any existing primary MX if this is a backup-MX</small><span class="sp-record-toggle"><i class="ti ti-eye"></i><span>Show values</span><span>Hide values</span></span></summary>
<div class="sp-row"> <div class="sp-row">
<div class="field"><label class="label">Host / name</label> <div class="field"><label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="lists.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div> <div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="lists.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
@@ -110,7 +109,7 @@
</div> </div>
<div class="field"><label class="label">Value to publish</label> <div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="10 mail.example.org."></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div> <div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="10 mail.example.org."></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
</div> </details>
</div> </div>
<div class="columns"> <div class="columns">
@@ -124,7 +123,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 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>
<div class="field"><label class="label" for="tls">TLS to upstream</label> <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> <button type="submit" class="button is-primary">Save upstream</button>
</form> </form>
</div> </div>
@@ -142,7 +142,7 @@
dev@lists.example.org dev@lists.example.org
owner@lists.example.org owner@lists.example.org
security@lists.example.org</textarea></div> 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> <button type="submit" class="button is-primary">Save recipients</button>
</form> </form>
</div> </div>
@@ -154,17 +154,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-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"> <div class="sp-box-body">
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Engine</dt><dd>rspamd, for every inbound domain of this server</dd></div> <div><dt>Applies to</dt><dd>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>Filter</dt><dd class="sp-mono">inet:antispam:11332</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>If the filter is down</dt><dd>Mail is accepted unfiltered</dd></div>
<div><dt>Allow and deny lists</dt><dd class="sp-muted">Kept by the global administrator, one pair for the whole server</dd></div>
</dl> </dl>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer> <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> </div>
</main> </main>
</body> </body>
@@ -12,8 +12,8 @@
<body> <body>
<nav class="navbar is-primary" aria-label="main navigation"> <nav class="navbar is-primary" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div> <div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group"><summary>Outbound</summary><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group" open><summary>Inbound</summary><a href="in-domains.html" aria-current="page"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
<div class="navbar-menu is-active"> <div class="navbar-menu">
<div class="navbar-start"> <div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a> <a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
@@ -39,7 +39,6 @@
<a class="navbar-link" href="health.html">Server</a> <a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a> <a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a> <a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a> <a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a> <a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
@@ -91,14 +90,14 @@
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <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> <thead><tr><th>Domain</th><th>DNS</th><th>Upstream</th><th>Recipients</th><th>TLS to upstream</th><th></th></tr></thead>
<tbody> <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> <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> </tbody>
</table></div> </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 class="sp-box-foot">The MX tag is green when at least one MX of the domain points at this server.</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> </div>
</main> </main>
</body> </body>
@@ -12,8 +12,8 @@
<body> <body>
<nav class="navbar is-primary" aria-label="main navigation"> <nav class="navbar is-primary" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div> <div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group"><summary>Outbound</summary><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group" open><summary>Inbound</summary><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html" aria-current="page"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
<div class="navbar-menu is-active"> <div class="navbar-menu">
<div class="navbar-start"> <div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a> <a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
@@ -39,7 +39,6 @@
<a class="navbar-link" href="health.html">Server</a> <a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a> <a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a> <a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a> <a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a> <a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
@@ -132,13 +131,13 @@
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Applied by</dt><dd>rspamd, from map files it rereads within seconds of a change</dd></div> <div><dt>Applied by</dt><dd>rspamd, from map files it rereads within seconds of a change</dd></div>
<div><dt>Files</dt><dd><span class="sp-mono">/data/antispam/</span> — six, one for each list and kind of entry</dd></div> <div><dt>Files</dt><dd><span class="sp-mono">/data/antispam/</span> — six, one for each list and kind of entry</dd></div>
<div><dt>Last written</dt><dd>2026-09-11 08:40 UTC</dd></div> <div><dt>Last written</dt><dd>2026-09-11 08:40</dd></div>
<div><dt>When both lists match</dt><dd>Deny wins, whichever entries they are.</dd></div> <div><dt>When both lists match</dt><dd>Deny wins, whichever entries they are.</dd></div>
</dl> </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> </div>
</main> </main>
</body> </body>
+13 -13
View File
@@ -12,8 +12,8 @@
<body> <body>
<nav class="navbar is-primary" aria-label="main navigation"> <nav class="navbar is-primary" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div> <div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group"><summary>Outbound</summary><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group" open><summary>Inbound</summary><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html" aria-current="page"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
<div class="navbar-menu is-active"> <div class="navbar-menu">
<div class="navbar-start"> <div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a> <a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
@@ -39,7 +39,6 @@
<a class="navbar-link" href="health.html">Server</a> <a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a> <a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a> <a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a> <a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a> <a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
@@ -69,7 +68,7 @@
<div class="sp-grow"> <div class="sp-grow">
<p class="sp-kicker">Inbound</p> <p class="sp-kicker">Inbound</p>
<h1 class="title is-3">Inbound log</h1> <h1 class="title is-3">Inbound log</h1>
<p class="sp-lead">What port 25 decided about each message, one row per recipient. Accepted mail is recorded as it passes, refused mail is read from <code>mail.log</code>; the filter's verdict appears when the spam filter is on. Rows older than 90 days are deleted — change that in <a href="settings.html">Settings</a>.</p> <p class="sp-lead">What port 25 decided about each message and what the upstream answered, one row per recipient. Accepted mail is recorded as it passes, refused mail is read from <code>mail.log</code>; the filter's verdict appears when the spam filter is on. Rows older than 90 days are deleted — change that in <a href="settings.html">Settings</a>.</p>
</div> </div>
</div> </div>
@@ -86,20 +85,21 @@
<a class="sp-help" href="help.html#inbound-log" title="What the decisions mean"><i class="ti ti-help-circle"></i></a> <a class="sp-help" href="help.html#inbound-log" title="What the decisions mean"><i class="ti ti-help-circle"></i></a>
</div> </div>
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th></th></tr></thead> <thead><tr><th>Time</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th>Upstream</th><th></th></tr></thead>
<tbody> <tbody>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:00:18</td><td>t.berg@<wbr>uni.example<br><span class="sp-mono sp-muted sp-small">192.0.2.77</span></td><td>dev@<wbr>lists.example.org</td><td class="sp-clip">Re: [dev] patch series v3</td><td><button type="button" class="tag is-success is-light" popovertarget="why-1">accept</button><div class="sp-sheet" id="why-1" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-success is-light">accept</span><h2>Why</h2><button type="button" popovertarget="why-1" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Verdict</th><td>no action, score 0.40 / 15.00</td></tr><tr><th>Subject</th><td>Re: [dev] patch series v3</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr> <tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:00:31</td><td>ci@<wbr>builds.example<br><span class="sp-mono sp-muted sp-small">192.0.2.90</span></td><td>dev@<wbr>lists.example.org</td><td class="sp-clip">[ci] nightly build passed</td><td><button type="button" class="tag is-success is-light" popovertarget="why-7">accept</button><div class="sp-sheet" id="why-7" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-success is-light">accept</span><h2>Why</h2><button type="button" popovertarget="why-7" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Verdict</th><td>no action, score 1.10 / 15.00</td></tr><tr><th>Subject</th><td>[ci] nightly build passed</td></tr></tbody></table></div></div></div></div></td><td><span class="tag is-light">queued</span></td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:59:02</td><td>invoice@<wbr>partner-billing.example<br><span class="sp-mono sp-muted sp-small">203.0.113.140</span></td><td>owner@<wbr>lists.example.org</td><td class="sp-clip">Outstanding invoice — action required</td><td><button type="button" class="tag is-success is-light" popovertarget="why-2">accept</button><div class="sp-sheet" id="why-2" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-success is-light">accept</span><h2>Why</h2><button type="button" popovertarget="why-2" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Verdict</th><td>add header, score 9.80 / 15.00</td></tr><tr><th>Symbols</th><td class="sp-mono sp-small">PHISHING(5.00)<br>FORGED_SENDER(3.00)<br>R_SPF_FAIL(1.00)</td></tr><tr><th>Subject</th><td>Outstanding invoice — action required</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr> <tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:00:18</td><td>t.berg@<wbr>uni.example<br><span class="sp-mono sp-muted sp-small">192.0.2.77</span></td><td>dev@<wbr>lists.example.org</td><td class="sp-clip">Re: [dev] patch series v3</td><td><button type="button" class="tag is-success is-light" popovertarget="why-1">accept</button><div class="sp-sheet" id="why-1" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-success is-light">accept</span><h2>Why</h2><button type="button" popovertarget="why-1" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Verdict</th><td>no action, score 0.40 / 15.00</td></tr><tr><th>Subject</th><td>Re: [dev] patch series v3</td></tr></tbody></table></div></div></div></div></td><td><button type="button" class="tag is-success is-light" popovertarget="up-1">sent</button><div class="sp-sheet" id="up-1" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-success is-light">sent</span><h2>Upstream</h2><button type="button" popovertarget="up-1" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Host</th><td class="sp-mono sp-small">mx.upstream.example[198.51.100.5]:25</td></tr><tr><th>Reply</th><td class="sp-mono sp-small">250 2.0.0 Ok: queued as CE05D10261D</td></tr><tr><th>At</th><td class="sp-mono sp-small">09-21 14:00:19</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:57:41</td><td>promo@<wbr>bulk.example<br><span class="sp-mono sp-muted sp-small">198.51.100.90</span></td><td>sales@<wbr>lists.example.org</td><td class="sp-clip sp-muted">—</td><td><button type="button" class="tag is-danger is-light" popovertarget="why-3">reject</button><div class="sp-sheet" id="why-3" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-danger is-light">reject</span><h2>Why</h2><button type="button" popovertarget="why-3" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>postfix</td></tr><tr><th>Reply</th><td class="sp-mono sp-small">550 5.1.1 &lt;sales@lists.example.org&gt;: Recipient address rejected: User unknown in relay recipient table</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"></td></tr> <tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:59:02</td><td>invoice@<wbr>partner-billing.example<br><span class="sp-mono sp-muted sp-small">203.0.113.140</span></td><td>owner@<wbr>lists.example.org</td><td class="sp-clip">Outstanding invoice — action required</td><td><button type="button" class="tag is-success is-light" popovertarget="why-2">accept</button><div class="sp-sheet" id="why-2" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-success is-light">accept</span><h2>Why</h2><button type="button" popovertarget="why-2" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Verdict</th><td>add header, score 9.80 / 15.00</td></tr><tr><th>Symbols</th><td class="sp-mono sp-small">PHISHING(5.00)<br>FORGED_SENDER(3.00)<br>R_SPF_FAIL(1.00)</td></tr><tr><th>Subject</th><td>Outstanding invoice — action required</td></tr></tbody></table></div></div></div></div></td><td><button type="button" class="tag is-danger is-light" popovertarget="up-2">bounced</button><div class="sp-sheet" id="up-2" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-danger is-light">bounced</span><h2>Upstream</h2><button type="button" popovertarget="up-2" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Host</th><td class="sp-mono sp-small">mx.upstream.example[198.51.100.5]:25</td></tr><tr><th>Reply</th><td class="sp-mono sp-small">550 5.7.1 Message rejected as spam by the upstream filter</td></tr><tr><th>At</th><td class="sp-mono sp-small">09-21 13:59:04</td></tr><tr><th>Then</th><td>The sender was sent a bounce</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:51:30</td><td>billing@<wbr>partner.example<br><span class="sp-mono sp-muted sp-small">198.51.100.12</span></td><td>owner@<wbr>lists.example.org</td><td class="sp-clip sp-muted">—</td><td><button type="button" class="tag is-danger is-light" popovertarget="why-4">reject</button><div class="sp-sheet" id="why-4" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-danger is-light">reject</span><h2>Why</h2><button type="button" popovertarget="why-4" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Reply</th><td class="sp-mono sp-small">5.7.1 Spam message rejected</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Allow sender</a></td></tr> <tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:57:41</td><td>promo@<wbr>bulk.example<br><span class="sp-mono sp-muted sp-small">198.51.100.90</span></td><td>sales@<wbr>lists.example.org</td><td class="sp-clip sp-muted">—</td><td><button type="button" class="tag is-danger is-light" popovertarget="why-3">reject</button><div class="sp-sheet" id="why-3" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-danger is-light">reject</span><h2>Why</h2><button type="button" popovertarget="why-3" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>postfix</td></tr><tr><th>Reply</th><td class="sp-mono sp-small">550 5.1.1 &lt;sales@lists.example.org&gt;: Recipient address rejected: User unknown in relay recipient table</td></tr></tbody></table></div></div></div></div></td><td class="sp-muted">—</td><td class="sp-actions"></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:44:02</td><td>hr@<wbr>partner.example<br><span class="sp-mono sp-muted sp-small">192.0.2.31</span></td><td>jobs@<wbr>acme.io</td><td class="sp-clip sp-muted">—</td><td><button type="button" class="tag is-warning is-light" popovertarget="why-5">tempfail</button><div class="sp-sheet" id="why-5" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-warning is-light">tempfail</span><h2>Why</h2><button type="button" popovertarget="why-5" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Reply</th><td class="sp-mono sp-small">4.7.1 Try again later</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Allow sender</a></td></tr> <tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:51:30</td><td>billing@<wbr>partner.example<br><span class="sp-mono sp-muted sp-small">198.51.100.12</span></td><td>owner@<wbr>lists.example.org</td><td class="sp-clip sp-muted">—</td><td><button type="button" class="tag is-danger is-light" popovertarget="why-4">reject</button><div class="sp-sheet" id="why-4" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-danger is-light">reject</span><h2>Why</h2><button type="button" popovertarget="why-4" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Reply</th><td class="sp-mono sp-small">5.7.1 Spam message rejected</td></tr></tbody></table></div></div></div></div></td><td class="sp-muted">—</td><td class="sp-actions"><a href="in-filter-lists.html">Allow sender</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:30:55</td><td>noreply@<wbr>github.example<br><span class="sp-mono sp-muted sp-small">192.0.2.200</span></td><td>security@<wbr>lists.example.org</td><td class="sp-clip">[advisory] new report</td><td><button type="button" class="tag is-success is-light" popovertarget="why-6">accept</button><div class="sp-sheet" id="why-6" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-success is-light">accept</span><h2>Why</h2><button type="button" popovertarget="why-6" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Verdict</th><td>no action, score -98.10 / 15.00</td></tr><tr><th>Symbols</th><td class="sp-mono sp-small">SELFPOST_ALLOW_FROM(-100.00)<br>R_DKIM_ALLOW(-0.20)</td></tr><tr><th>Subject</th><td>[advisory] new report</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr> <tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:44:02</td><td>hr@<wbr>partner.example<br><span class="sp-mono sp-muted sp-small">192.0.2.31</span></td><td>jobs@<wbr>acme.io</td><td class="sp-clip sp-muted">—</td><td><button type="button" class="tag is-warning is-light" popovertarget="why-5">tempfail</button><div class="sp-sheet" id="why-5" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-warning is-light">tempfail</span><h2>Why</h2><button type="button" popovertarget="why-5" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Reply</th><td class="sp-mono sp-small">4.7.1 Try again later</td></tr></tbody></table></div></div></div></div></td><td class="sp-muted">—</td><td class="sp-actions"><a href="in-filter-lists.html">Allow sender</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:30:55</td><td>noreply@<wbr>github.example<br><span class="sp-mono sp-muted sp-small">192.0.2.200</span></td><td>security@<wbr>lists.example.org</td><td class="sp-clip">[advisory] new report</td><td><button type="button" class="tag is-success is-light" popovertarget="why-6">accept</button><div class="sp-sheet" id="why-6" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-success is-light">accept</span><h2>Why</h2><button type="button" popovertarget="why-6" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Verdict</th><td>no action, score -98.10 / 15.00</td></tr><tr><th>Symbols</th><td class="sp-mono sp-small">SELFPOST_ALLOW_FROM(-100.00)<br>R_DKIM_ALLOW(-0.20)</td></tr><tr><th>Subject</th><td>[advisory] new report</td></tr></tbody></table></div></div></div></div></td><td><button type="button" class="tag is-warning is-light" popovertarget="up-6">deferred</button><div class="sp-sheet" id="up-6" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-warning is-light">deferred</span><h2>Upstream</h2><button type="button" popovertarget="up-6" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Reply</th><td class="sp-mono sp-small">connect to mx.upstream.example[198.51.100.5]:25: Connection refused</td></tr><tr><th>At</th><td class="sp-mono sp-small">09-21 13:45:12</td></tr><tr><th>Then</th><td>Postfix tries again; the message is in the queue</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
</tbody> </tbody>
</table></div> </table></div>
<div class="sp-box-foot"><span>Page 1 of 38</span><a href="#">Older →</a><span class="sp-end">A decision opens its reason · Allow / Deny sender opens <a href="in-filter-lists.html">Filter lists</a> with the entry filled in</span></div> <div 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> </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> </div>
</main> </main>
</body> </body>
@@ -12,8 +12,8 @@
<body> <body>
<nav class="navbar is-primary" aria-label="main navigation"> <nav class="navbar is-primary" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div> <div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group"><summary>Outbound</summary><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group" open><summary>Inbound</summary><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html" aria-current="page"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
<div class="navbar-menu is-active"> <div class="navbar-menu">
<div class="navbar-start"> <div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a> <a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
@@ -39,7 +39,6 @@
<a class="navbar-link" href="health.html">Server</a> <a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a> <a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a> <a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a> <a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a> <a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
@@ -81,7 +80,7 @@
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Inbound domain</dt><dd><a href="in-domain.html">lists.example.org</a></dd></div> <div><dt>Inbound domain</dt><dd><a href="in-domain.html">lists.example.org</a></dd></div>
<div><dt>Client</dt><dd class="sp-mono">203.0.113.140</dd></div> <div><dt>Client</dt><dd class="sp-mono">203.0.113.140</dd></div>
<div><dt>Held</dt><dd>2026-09-21 13:59:02 UTC</dd></div> <div><dt>Held</dt><dd>2026-09-21 13:59:02</dd></div>
<div><dt>Deleted on</dt><dd>2026-10-05 · in 14 days</dd></div> <div><dt>Deleted on</dt><dd>2026-10-05 · in 14 days</dd></div>
<div><dt>Size</dt><dd>48 kB · 1 attachment (<span class="sp-mono">invoice.html</span>)</dd></div> <div><dt>Size</dt><dd>48 kB · 1 attachment (<span class="sp-mono">invoice.html</span>)</dd></div>
<div><dt>Would go to</dt><dd class="sp-mono">mx.internal:25</dd></div> <div><dt>Would go to</dt><dd class="sp-mono">mx.internal:25</dd></div>
@@ -106,13 +105,14 @@
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Headers</h2><div class="sp-end">The body and attachments are never rendered in the panel</div></div> <div class="sp-box-head"><span class="sp-no">03</span><h2>Headers</h2><div class="sp-end">The body and attachments are never rendered in the panel</div></div>
<pre class="sp-log"><span class="sp-d">Received:</span> from mail.partner-billing.example (unknown [203.0.113.140]) by mail.example.org <pre class="sp-log"><span class="sp-ll"><span class="sp-d">Received:</span> from mail.partner-billing.example (unknown [203.0.113.140]) by mail.example.org
<span class="sp-d">From:</span> "Partner Billing" &lt;invoice@partner-billing.example&gt; </span><span class="sp-ll"><span class="sp-d">From:</span> "Partner Billing" &lt;invoice@partner-billing.example&gt;
<span class="sp-d">Reply-To:</span> <span class="sp-w">payments.dept@freemail.example</span> </span><span class="sp-ll"><span class="sp-d">Reply-To:</span> <span class="sp-w">payments.dept@freemail.example</span>
<span class="sp-d">To:</span> owner@lists.example.org </span><span class="sp-ll"><span class="sp-d">To:</span> owner@lists.example.org
<span class="sp-d">Subject:</span> Outstanding invoice — action required </span><span class="sp-ll"><span class="sp-d">Subject:</span> Outstanding invoice — action required
<span class="sp-d">Date:</span> Mon, 21 Sep 2026 13:58:57 +0000 </span><span class="sp-ll"><span class="sp-d">Date:</span> Mon, 21 Sep 2026 13:58:57 +0000
<span class="sp-d">Authentication-Results:</span> <span class="sp-e">spf=fail dkim=none dmarc=fail</span></pre> </span><span class="sp-ll"><span class="sp-d">Authentication-Results:</span> <span class="sp-e">spf=fail dkim=none dmarc=fail</span>
</span></pre>
</div> </div>
<div class="columns"> <div class="columns">
@@ -142,7 +142,7 @@
</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> </div>
</main> </main>
</body> </body>
@@ -12,8 +12,8 @@
<body> <body>
<nav class="navbar is-primary" aria-label="main navigation"> <nav class="navbar is-primary" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div> <div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group"><summary>Outbound</summary><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group" open><summary>Inbound</summary><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html" aria-current="page"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
<div class="navbar-menu is-active"> <div class="navbar-menu">
<div class="navbar-start"> <div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a> <a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
@@ -39,7 +39,6 @@
<a class="navbar-link" href="health.html">Server</a> <a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a> <a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a> <a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a> <a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a> <a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
@@ -82,7 +81,7 @@
</form> </form>
</div> </div>
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Held, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Why</th><th>Size</th><th>Deleted in</th><th></th></tr></thead> <thead><tr><th>Held</th><th>From</th><th>To</th><th>Subject</th><th>Why</th><th>Size</th><th>Deleted in</th><th></th></tr></thead>
<tbody> <tbody>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:59:02</td><td>invoice@<wbr>partner-billing.example</td><td>owner@<wbr>lists.example.org</td><td class="sp-clip"><a href="in-quarantine-message.html"><strong>Outstanding invoice — action required</strong></a></td><td class="sp-muted sp-small">score 9.8 · PHISHING</td><td class="sp-muted sp-nowrap">48 kB</td><td class="sp-nowrap">14 days</td><td class="sp-actions"><a href="in-quarantine-message.html">Review</a></td></tr> <tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:59:02</td><td>invoice@<wbr>partner-billing.example</td><td>owner@<wbr>lists.example.org</td><td class="sp-clip"><a href="in-quarantine-message.html"><strong>Outstanding invoice — action required</strong></a></td><td class="sp-muted sp-small">score 9.8 · PHISHING</td><td class="sp-muted sp-nowrap">48 kB</td><td class="sp-nowrap">14 days</td><td class="sp-actions"><a href="in-quarantine-message.html">Review</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-20 22:14:40</td><td>no-reply@<wbr>shipping-update.example</td><td>jobs@<wbr>acme.io</td><td class="sp-clip"><a href="in-quarantine-message.html"><strong>Your parcel could not be delivered</strong></a></td><td class="sp-muted sp-small">score 8.1 · URL_SHORTENER</td><td class="sp-muted sp-nowrap">12 kB</td><td class="sp-nowrap">13 days</td><td class="sp-actions"><a href="in-quarantine-message.html">Review</a></td></tr> <tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-20 22:14:40</td><td>no-reply@<wbr>shipping-update.example</td><td>jobs@<wbr>acme.io</td><td class="sp-clip"><a href="in-quarantine-message.html"><strong>Your parcel could not be delivered</strong></a></td><td class="sp-muted sp-small">score 8.1 · URL_SHORTENER</td><td class="sp-muted sp-nowrap">12 kB</td><td class="sp-nowrap">13 days</td><td class="sp-actions"><a href="in-quarantine-message.html">Review</a></td></tr>
@@ -92,7 +91,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 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> </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> </div>
</main> </main>
</body> </body>
@@ -12,8 +12,8 @@
<body> <body>
<nav class="navbar is-primary" aria-label="main navigation"> <nav class="navbar is-primary" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div> <div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group"><summary>Outbound</summary><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group" open><summary>Inbound</summary><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html" aria-current="page"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
<div class="navbar-menu is-active"> <div class="navbar-menu">
<div class="navbar-start"> <div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a> <a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
@@ -39,7 +39,6 @@
<a class="navbar-link" href="health.html">Server</a> <a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a> <a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a> <a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a> <a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a> <a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
@@ -74,7 +73,7 @@
</div> </div>
<div class="box"> <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><a class="sp-help" href="help.html#inbound-queue" title="What waits here and why"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-empty"><i class="ti ti-stack-2"></i>Nothing is waiting. Every accepted message has reached its upstream.</div> <div class="sp-empty"><i class="ti ti-stack-2"></i>Nothing is waiting. Every accepted message has reached its upstream.</div>
</div> </div>
@@ -90,7 +89,7 @@
</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> </div>
</main> </main>
</body> </body>
@@ -23,7 +23,7 @@
<h1 class="title is-3">No administrator yet</h1> <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> <p class="sp-muted">Open the one-time setup link printed in the container log to create one.</p>
</div> </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>
</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 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>
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Sign in</button></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> </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>
</div> </div>
@@ -12,8 +12,8 @@
<body> <body>
<nav class="navbar is-primary" aria-label="main navigation"> <nav class="navbar is-primary" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div> <div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group" open><summary>Outbound</summary><a href="out-domains.html" aria-current="page"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group"><summary>Inbound</summary><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
<div class="navbar-menu is-active"> <div class="navbar-menu">
<div class="navbar-start"> <div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a> <a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current"> <div class="navbar-item has-dropdown is-hoverable sp-current">
@@ -39,7 +39,6 @@
<a class="navbar-link" href="health.html">Server</a> <a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a> <a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a> <a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a> <a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a> <a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
@@ -100,7 +99,7 @@
</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> </div>
</main> </main>
</body> </body>
@@ -12,8 +12,8 @@
<body> <body>
<nav class="navbar is-primary" aria-label="main navigation"> <nav class="navbar is-primary" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div> <div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group" open><summary>Outbound</summary><a href="out-domains.html" aria-current="page"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group"><summary>Inbound</summary><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
<div class="navbar-menu is-active"> <div class="navbar-menu">
<div class="navbar-start"> <div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a> <a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current"> <div class="navbar-item has-dropdown is-hoverable sp-current">
@@ -39,7 +39,6 @@
<a class="navbar-link" href="health.html">Server</a> <a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a> <a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a> <a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a> <a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a> <a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
@@ -69,7 +68,7 @@
<div class="sp-grow"> <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> <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> <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>
<div class="buttons"> <div class="buttons">
<a class="button" href="out-app-created.html"><i class="ti ti-key"></i>New password</a> <a class="button" href="out-app-created.html"><i class="ti ti-key"></i>New password</a>
@@ -85,7 +84,7 @@
<div class="sp-box-body"> <div class="sp-box-body">
<div class="field"><label class="label" for="login">Login</label> <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> <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="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="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> <div class="field"><label class="label" for="addresses">Addresses</label>
@@ -129,7 +128,7 @@
</div> </div>
</form> </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> </div>
</main> </main>
</body> </body>
@@ -12,8 +12,8 @@
<body> <body>
<nav class="navbar is-primary" aria-label="main navigation"> <nav class="navbar is-primary" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div> <div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group" open><summary>Outbound</summary><a href="out-domains.html" aria-current="page"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group"><summary>Inbound</summary><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
<div class="navbar-menu is-active"> <div class="navbar-menu">
<div class="navbar-start"> <div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a> <a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current"> <div class="navbar-item has-dropdown is-hoverable sp-current">
@@ -39,7 +39,6 @@
<a class="navbar-link" href="health.html">Server</a> <a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a> <a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a> <a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a> <a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a> <a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
@@ -81,7 +80,8 @@
<ul class="sp-list-check"> <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>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 <strong>both applications</strong> (<span class="sp-mono">prod-server</span>, <span class="sp-mono">alerts</span>) with their credentials and sender bindings;</li>
<li><i class="ti ti-x"></i>reload OpenDKIM so the domain is no longer signed.</li> <li><i class="ti ti-x"></i>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> </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> <p class="sp-muted mb-4">The published DKIM record becomes invalid — remove it from DNS unless you plan to re-add the domain. Inbound domains are not touched.</p>
<form method="post" action="out-domains.html"> <form method="post" action="out-domains.html">
@@ -103,7 +103,7 @@
</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> </div>
</main> </main>
</body> </body>
@@ -12,8 +12,8 @@
<body> <body>
<nav class="navbar is-primary" aria-label="main navigation"> <nav class="navbar is-primary" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div> <div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group" open><summary>Outbound</summary><a href="out-domains.html" aria-current="page"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group"><summary>Inbound</summary><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
<div class="navbar-menu is-active"> <div class="navbar-menu">
<div class="navbar-start"> <div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a> <a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current"> <div class="navbar-item has-dropdown is-hoverable sp-current">
@@ -39,7 +39,6 @@
<a class="navbar-link" href="health.html">Server</a> <a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a> <a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a> <a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a> <a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a> <a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
@@ -104,6 +103,8 @@
<div class="field"><label class="label" for="encpw2">Repeat password</label><div class="control"><input class="input" id="encpw2" type="password"></div></div> <div class="field"><label class="label" for="encpw2">Repeat password</label><div class="control"><input class="input" id="encpw2" type="password"></div></div>
</div> </div>
<p class="help mb-3">Keep this password: SelfPost does not store it, and the file cannot be opened without it.</p> <p class="help mb-3">Keep this password: SelfPost does not store it, and the file cannot be opened without it.</p>
<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, asked at every export: the file holds the domain's DKIM key and its applications' credentials.</p></div>
<button type="submit" class="button"><i class="ti ti-download"></i>Export domain</button> <button type="submit" class="button"><i class="ti ti-download"></i>Export domain</button>
</form> </form>
</div> </div>
@@ -117,9 +118,9 @@
<form class="sp-form" method="post" action="#"> <form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="mode">Limit mode</label> <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="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> <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 UTC.</p></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"> <div class="buttons">
<button type="submit" class="button is-primary">Save limit</button> <button type="submit" class="button is-primary">Save limit</button>
<button type="submit" class="button" formaction="#recalc">Recalculate now</button> <button type="submit" class="button" formaction="#recalc">Recalculate now</button>
@@ -140,7 +141,7 @@
</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> </div>
</main> </main>
</body> </body>
@@ -12,8 +12,8 @@
<body> <body>
<nav class="navbar is-primary" aria-label="main navigation"> <nav class="navbar is-primary" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div> <div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group" open><summary>Outbound</summary><a href="out-domains.html" aria-current="page"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group"><summary>Inbound</summary><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
<div class="navbar-menu is-active"> <div class="navbar-menu">
<div class="navbar-start"> <div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a> <a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current"> <div class="navbar-item has-dropdown is-hoverable sp-current">
@@ -39,7 +39,6 @@
<a class="navbar-link" href="health.html">Server</a> <a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a> <a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a> <a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a> <a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a> <a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
@@ -106,8 +105,8 @@
<div class="box" id="dns"> <div class="box" id="dns">
<div class="sp-box-head"><span class="sp-no">01</span><h2>DNS records</h2><div class="sp-end">Checked 3 min ago</div><a class="sp-help" href="help.html#dns" title="What to publish and why"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>DNS records</h2><div class="sp-end">Checked 3 min ago</div><a class="sp-help" href="help.html#dns" title="What to publish and why"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-record"> <details class="sp-record">
<div class="sp-record-head"><h3>DKIM</h3><span class="tag is-success is-light">published</span><small>Not a secret · selector <code>sp2026</code></small></div> <summary class="sp-record-head"><h3>DKIM</h3><span class="tag is-success is-light">published</span><small>Not a secret · selector <code>sp2026</code></small><span class="sp-record-toggle"><i class="ti ti-eye"></i><span>Show values</span><span>Hide values</span></span></summary>
<div class="sp-row"> <div class="sp-row">
<div class="field"><label class="label">Host / name</label> <div class="field"><label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="sp2026._domainkey.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div> <div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="sp2026._domainkey.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
@@ -115,7 +114,7 @@
</div> </div>
<div class="field"><label class="label">Value to publish</label> <div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><textarea class="textarea" rows="3" readonly>v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3kqV0mJd8e2f1Yb7Qw5uT9sLr4nHc6pZaXo1vKe0yWm2gBt8RjN5dCq3hUf7lPs9aEi4oMz6xTb1kGv0nYw…IDAQAB</textarea></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div> <div class="field has-addons"><div class="control is-expanded"><textarea class="textarea" rows="3" readonly>v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3kqV0mJd8e2f1Yb7Qw5uT9sLr4nHc6pZaXo1vKe0yWm2gBt8RjN5dCq3hUf7lPs9aEi4oMz6xTb1kGv0nYw…IDAQAB</textarea></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
</div> </details>
<div class="sp-record"> <div class="sp-record">
<div class="sp-record-head"><h3>SPF</h3><span class="tag is-danger is-light">mismatch</span><small>Merge into the existing record — never publish a second one</small></div> <div class="sp-record-head"><h3>SPF</h3><span class="tag is-danger is-light">mismatch</span><small>Merge into the existing record — never publish a second one</small></div>
@@ -131,8 +130,8 @@
<p class="help is-danger">The published record does not authorize 203.0.113.25. Add <code>ip4:203.0.113.25</code> before <code>~all</code>. Shallow check: <code>include:</code> is not followed.</p></div> <p class="help is-danger">The published record does not authorize 203.0.113.25. Add <code>ip4:203.0.113.25</code> before <code>~all</code>. Shallow check: <code>include:</code> is not followed.</p></div>
</div> </div>
<div class="sp-record"> <details class="sp-record">
<div class="sp-record-head"><h3>DMARC</h3><span class="tag is-success is-light">published</span><small>Report address: <a href="out-domain-settings.html">example.org@dmarc.mail.example.org</a></small></div> <summary class="sp-record-head"><h3>DMARC</h3><span class="tag is-success is-light">published</span><small>Report address: <a href="out-domain-settings.html">example.org@dmarc.mail.example.org</a></small><span class="sp-record-toggle"><i class="ti ti-eye"></i><span>Show values</span><span>Hide values</span></span></summary>
<div class="sp-row"> <div class="sp-row">
<div class="field"><label class="label">Host / name</label> <div class="field"><label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="_dmarc.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div> <div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="_dmarc.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
@@ -141,7 +140,7 @@
<div class="field"><label class="label">Value to publish</label> <div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=DMARC1; p=none; rua=mailto:example.org@dmarc.mail.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div> <div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=DMARC1; p=none; rua=mailto:example.org@dmarc.mail.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div>
<p class="help"><code>p=none</code> does not affect delivery. Tighten to <code>quarantine</code>, then <code>reject</code>, once the <a href="dmarc-domain.html">reports</a> look clean.</p></div> <p class="help"><code>p=none</code> does not affect delivery. Tighten to <code>quarantine</code>, then <code>reject</code>, once the <a href="dmarc-domain.html">reports</a> look clean.</p></div>
</div> </details>
</div> </div>
<div class="box" id="apps"> <div class="box" id="apps">
@@ -172,7 +171,7 @@
</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> </div>
</main> </main>
</body> </body>
@@ -12,8 +12,8 @@
<body> <body>
<nav class="navbar is-primary" aria-label="main navigation"> <nav class="navbar is-primary" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div> <div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group" open><summary>Outbound</summary><a href="out-domains.html" aria-current="page"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group"><summary>Inbound</summary><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
<div class="navbar-menu is-active"> <div class="navbar-menu">
<div class="navbar-start"> <div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a> <a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current"> <div class="navbar-item has-dropdown is-hoverable sp-current">
@@ -39,7 +39,6 @@
<a class="navbar-link" href="health.html">Server</a> <a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a> <a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a> <a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a> <a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a> <a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
@@ -100,7 +99,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 class="sp-box-foot">Each tag is one check. Green — published and correct; amber — published but weak; red — missing or wrong.</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> </div>
</main> </main>
</body> </body>
@@ -12,8 +12,8 @@
<body> <body>
<nav class="navbar is-primary" aria-label="main navigation"> <nav class="navbar is-primary" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div> <div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group" open><summary>Outbound</summary><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html" aria-current="page"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group"><summary>Inbound</summary><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
<div class="navbar-menu is-active"> <div class="navbar-menu">
<div class="navbar-start"> <div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a> <a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current"> <div class="navbar-item has-dropdown is-hoverable sp-current">
@@ -39,7 +39,6 @@
<a class="navbar-link" href="health.html">Server</a> <a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a> <a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a> <a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a> <a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a> <a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
@@ -79,27 +78,29 @@
<form class="sp-end" method="get" action="#"> <form class="sp-end" method="get" action="#">
<div class="select"><select name="domain"><option>All domains</option><option>example.org</option><option>shop.example.org</option><option>notify.acme.io</option></select></div> <div class="select"><select name="domain"><option>All domains</option><option>example.org</option><option>shop.example.org</option><option>notify.acme.io</option></select></div>
<div class="select"><select name="app"><option>All applications</option><option>prod-server</option><option>alerts</option></select></div> <div class="select"><select name="app"><option>All applications</option><option>prod-server</option><option>alerts</option></select></div>
<div class="select"><select name="status"><option>Any status</option><option>sent</option><option>deferred</option><option>bounced</option><option>rate-limited</option></select></div> <div class="select"><select name="status"><option>Any status</option><option>queued</option><option>sent</option><option>deferred</option><option>bounced</option><option>rejected</option></select></div>
<input class="input" type="date" name="from" value="2026-09-14" aria-label="From date">
<input class="input" type="date" name="to" value="2026-09-21" aria-label="To date">
<button type="submit" class="button"><i class="ti ti-filter"></i>Filter</button> <button type="submit" class="button"><i class="ti ti-filter"></i>Filter</button>
</form> </form>
<a class="sp-help" href="help.html#log" title="What the statuses mean"><i class="ti ti-help-circle"></i></a> <a class="sp-help" href="help.html#log" title="What the statuses mean"><i class="ti ti-help-circle"></i></a>
</div> </div>
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr></thead> <thead><tr><th>Time</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr></thead>
<tbody> <tbody>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td>shop@<wbr>shop.example.org</td><td>m.keller@<wbr>gmx.de</td><td class="sp-clip">Your order #48213 has shipped</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr> <tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td>shop@<wbr>shop.example.org</td><td>m.keller@<wbr>gmx.de</td><td class="sp-clip">Your order #48213 has shipped</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:50</td><td>shop@<wbr>shop.example.org</td><td>anna.lind@<wbr>outlook.com</td><td class="sp-clip">Your order #48212 has shipped</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr> <tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:50</td><td>shop@<wbr>shop.example.org</td><td>anna.lind@<wbr>outlook.com</td><td class="sp-clip">Your order #48212 has shipped</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:58:07</td><td>alerts@<wbr>example.org</td><td>noc@<wbr>example.org</td><td class="sp-clip">[FIRING] disk usage above 90 % on db-2</td><td><span class="tag is-warning is-light">deferred</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr> <tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:58:07</td><td>alerts@<wbr>example.org</td><td>noc@<wbr>example.org</td><td class="sp-clip">[FIRING] disk usage above 90 % on db-2</td><td><span class="tag is-warning is-light">deferred</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:41:19</td><td>no-reply@<wbr>notify.acme.io</td><td>j.doe@<wbr>nonexistent.example</td><td class="sp-clip">Reset your password</td><td><span class="tag is-danger is-light">bounced</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr> <tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:41:19</td><td>no-reply@<wbr>notify.acme.io</td><td>j.doe@<wbr>nonexistent.example</td><td class="sp-clip">Reset your password</td><td><span class="tag is-danger is-light">bounced</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:40:55</td><td>shop@<wbr>shop.example.org</td><td>k.berg@<wbr>web.de</td><td class="sp-clip">A message from the director</td><td><span class="tag is-danger is-light">wrong From</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr> <tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:40:55</td><td>shop@<wbr>shop.example.org</td><td>k.berg@<wbr>web.de</td><td class="sp-clip">A message from the director</td><td><span class="tag is-danger is-light">wrong From</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:40:02</td><td>prod@<wbr>example.org</td><td>billing@<wbr>partner.example</td><td class="sp-clip">Invoice 2026-0917</td><td><span class="tag is-light">rate-limited</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr> <tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:40:02</td><td>prod@<wbr>example.org</td><td>billing@<wbr>partner.example</td><td class="sp-clip">Invoice 2026-0917</td><td><span class="tag is-danger is-light">rate-limited</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:12:44</td><td>shop@<wbr>shop.example.org</td><td>p.novak@<wbr>seznam.cz</td><td class="sp-clip">We received your return request</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr> <tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:12:44</td><td>shop@<wbr>shop.example.org</td><td>p.novak@<wbr>seznam.cz</td><td class="sp-clip">We received your return request</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
</tbody> </tbody>
</table></div> </table></div>
<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 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> </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> </div>
</main> </main>
</body> </body>
@@ -12,8 +12,8 @@
<body> <body>
<nav class="navbar is-primary" aria-label="main navigation"> <nav class="navbar is-primary" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div> <div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group" open><summary>Outbound</summary><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html" aria-current="page"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group"><summary>Inbound</summary><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
<div class="navbar-menu is-active"> <div class="navbar-menu">
<div class="navbar-start"> <div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a> <a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current"> <div class="navbar-item has-dropdown is-hoverable sp-current">
@@ -39,7 +39,6 @@
<a class="navbar-link" href="health.html">Server</a> <a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a> <a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a> <a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a> <a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a> <a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
@@ -81,8 +80,8 @@
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Domain</dt><dd><a href="out-domain.html">example.org</a></dd></div> <div><dt>Domain</dt><dd><a href="out-domain.html">example.org</a></dd></div>
<div><dt>Application</dt><dd class="sp-mono"><a href="out-app.html">alerts</a></dd></div> <div><dt>Application</dt><dd class="sp-mono"><a href="out-app.html">alerts</a></dd></div>
<div><dt>Accepted</dt><dd>2026-09-21 13:58:07 UTC</dd></div> <div><dt>Accepted</dt><dd>2026-09-21 13:58:07</dd></div>
<div><dt>Status reported</dt><dd>2026-09-21 13:58:09 UTC</dd></div> <div><dt>Status reported</dt><dd>2026-09-21 13:58:09</dd></div>
<div><dt>Queue id</dt><dd class="sp-mono">4XcB7k2Jm9z1</dd></div> <div><dt>Queue id</dt><dd class="sp-mono">4XcB7k2Jm9z1</dd></div>
<div><dt>Journal id</dt><dd class="sp-mono">184220</dd></div> <div><dt>Journal id</dt><dd class="sp-mono">184220</dd></div>
</dl> </dl>
@@ -94,9 +93,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-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"> <div class="sp-box-body">
<ol class="sp-timeline"> <ol class="sp-timeline">
<li><time>13:58:07 UTC</time><strong>Accepted</strong> from <span class="sp-mono">alerts</span> at 198.51.100.7</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>13:58:09 UTC</time><strong>Deferred</strong> — the receiving server asked to retry later</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>next attempt ≈ 14:08 UTC</time>Still in the <a href="out-queue.html">queue</a>; kept for 5 days, then bounced</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> </ol>
</div> </div>
</div> </div>
@@ -105,13 +104,14 @@
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Delivery log</h2><div class="sp-end">Lines from <code>mail.log</code> with this queue id</div></div> <div class="sp-box-head"><span class="sp-no">03</span><h2>Delivery log</h2><div class="sp-end">Lines from <code>mail.log</code> with this queue id</div></div>
<pre class="sp-log"><span class="sp-d">13:58:07</span> postfix/smtpd[2114]: 4XcB7k2Jm9z1: client=unknown[198.51.100.7], sasl_username=alerts <pre class="sp-log"><span class="sp-ll"><span class="sp-d">13:58:07</span> postfix/smtpd[2114]: 4XcB7k2Jm9z1: client=unknown[198.51.100.7], sasl_username=alerts
<span class="sp-d">13:58:07</span> postfix/cleanup[2120]: 4XcB7k2Jm9z1: message-id=&lt;a81f0c@alertmanager&gt; </span><span class="sp-ll"><span class="sp-d">13:58:07</span> postfix/cleanup[2120]: 4XcB7k2Jm9z1: message-id=&lt;a81f0c@alertmanager&gt;
<span class="sp-d">13:58:07</span> opendkim[39]: 4XcB7k2Jm9z1: DKIM-Signature field added (s=sp2026, d=example.org) </span><span class="sp-ll"><span class="sp-d">13:58:07</span> postfix/qmgr[2061]: 4XcB7k2Jm9z1: from=&lt;alerts@example.org&gt;, size=2214, nrcpt=1 (queue active)
<span class="sp-d">13:58:09</span> <span class="sp-w">postfix/smtp[2123]: 4XcB7k2Jm9z1: to=&lt;noc@example.org&gt;, relay=mx1.example.org[192.0.2.10]:25, delay=1.9, status=deferred (host mx1.example.org said: 451 4.7.1 Greylisted, try again in 5 minutes)</span></pre> </span><span class="sp-ll"><span class="sp-d">13:58:09</span> <span class="sp-w">postfix/smtp[2123]: 4XcB7k2Jm9z1: to=&lt;noc@example.org&gt;, relay=mx1.example.org[192.0.2.10]:25, delay=1.9, status=deferred (host mx1.example.org said: 451 4.7.1 Greylisted, try again in 5 minutes)</span>
</span></pre>
</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> </div>
</main> </main>
</body> </body>
@@ -12,8 +12,8 @@
<body> <body>
<nav class="navbar is-primary" aria-label="main navigation"> <nav class="navbar is-primary" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div> <div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group" open><summary>Outbound</summary><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html" aria-current="page"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group"><summary>Inbound</summary><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
<div class="navbar-menu is-active"> <div class="navbar-menu">
<div class="navbar-start"> <div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a> <a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current"> <div class="navbar-item has-dropdown is-hoverable sp-current">
@@ -39,7 +39,6 @@
<a class="navbar-link" href="health.html">Server</a> <a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a> <a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a> <a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a> <a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a> <a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
@@ -69,14 +68,14 @@
<div class="sp-grow"> <div class="sp-grow">
<p class="sp-kicker">Outbound</p> <p class="sp-kicker">Outbound</p>
<h1 class="title is-3">Outbound queue</h1> <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>
</div> </div>
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Waiting</h2><div class="sp-end">1 message · 3.1 kB</div><a class="sp-help" href="help.html#queue" title="What waits here and how Postfix retries"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Waiting</h2><div class="sp-end">1 message · 3.1 kB</div><a class="sp-help" href="help.html#queue" title="What waits here and how Postfix retries"><i class="ti ti-help-circle"></i></a></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Queue id</th><th>Arrived, UTC</th><th>From</th><th>To</th><th>Last reply</th><th></th></tr></thead> <thead><tr><th>Queue id</th><th>Arrived</th><th>From</th><th>To</th><th>Last reply</th><th></th></tr></thead>
<tbody> <tbody>
<tr><td class="sp-mono">4XcB7k2Jm9z1</td><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:58:07</td><td>alerts@<wbr>example.org</td><td>noc@<wbr>example.org</td><td class="sp-muted">451 4.7.1 Greylisted, try again in 5 minutes</td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr> <tr><td class="sp-mono">4XcB7k2Jm9z1</td><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:58:07</td><td>alerts@<wbr>example.org</td><td>noc@<wbr>example.org</td><td class="sp-muted">451 4.7.1 Greylisted, try again in 5 minutes</td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
</tbody> </tbody>
@@ -95,7 +94,7 @@
</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> </div>
</main> </main>
</body> </body>
+16 -19
View File
@@ -90,7 +90,8 @@
"box[Milter sockets]:table", "box[Milter sockets]:table",
"box[TLS certificate]:body", "box[TLS certificate]:body",
"box[Notification mail]:table+body", "box[Notification mail]:table+body",
"box[Configuration]:body" "box[Configuration]:body",
"box[Test message]:body"
] ]
} }
] ]
@@ -107,9 +108,9 @@
}, },
{ {
"is-9": [ "is-9": [
"box[Overview]:help×3", "box[Overview]:help×4",
"box[Outbound]:help×7", "box[Outbound]:help×7",
"box[Inbound]:help×6", "box[Inbound]:help×7",
"box[Server]:help×4", "box[Server]:help×4",
"box[Account]:help" "box[Account]:help"
] ]
@@ -379,32 +380,28 @@
] ]
} }
], ],
"preflight": [
"head+postmark+actions",
"box[Checks]:table",
"box[Send a test e-mail]:body"
],
"settings": [ "settings": [
"head", "head+actions",
{ {
"form": [ "columns": [
{ {
"columns": [ "is-6": [
{ {
"is-6": [ "form": [
"box[Log retention]:body", "box[Log retention]:body",
"box[Sending rate limits]:body"
]
},
{
"is-6": [
"box[Notifications]:body", "box[Notifications]:body",
"box[Notification mail]:record×2" "submit_row"
] ]
} }
] ]
}, },
"submit_row" {
"is-6": [
"box[Notification mail]:record×3",
"box[Sending rate limits]:body",
"box[Time zone]:body"
]
}
] ]
} }
], ],
@@ -12,8 +12,8 @@
<body> <body>
<nav class="navbar is-primary" aria-label="main navigation"> <nav class="navbar is-primary" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div> <div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html" aria-current="page">Overview</a><details class="sp-touchnav-group"><summary>Outbound</summary><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group"><summary>Inbound</summary><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
<div class="navbar-menu is-active"> <div class="navbar-menu">
<div class="navbar-start"> <div class="navbar-start">
<a class="navbar-item sp-current" href="overview.html">Overview</a> <a class="navbar-item sp-current" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
@@ -39,7 +39,6 @@
<a class="navbar-link" href="health.html">Server</a> <a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a> <a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a> <a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a> <a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a> <a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
@@ -70,9 +69,9 @@
<div class="sp-grow"> <div class="sp-grow">
<p class="sp-kicker">mail.example.org</p> <p class="sp-kicker">mail.example.org</p>
<h1 class="title is-3">Mail is flowing. One thing needs attention.</h1> <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>
<div class="sp-muted sp-small">Updated 14:02:31 UTC<br>refreshes every 10 s</div> <div class="sp-muted sp-small">Updated 14:02:31<br>refreshes on its own</div>
</div> </div>
<div class="box"> <div class="box">
@@ -82,7 +81,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"><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="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="out-queue.html"><span class="sp-h-icon"><i class="ti ti-stack-2"></i></span><span><span class="sp-h-name">Queues</span><span class="sp-h-value">Both empty</span><span class="sp-h-sub">outbound and inbound</span></span></a>
<a href="health.html"><span class="sp-h-icon"><i class="ti ti-plug-connected"></i></span><span><span class="sp-h-name">Milter sockets</span><span class="sp-h-value">3 of 3 answering</span><span class="sp-h-sub">incl. the spam filter</span></span></a> <a href="health.html"><span class="sp-h-icon"><i class="ti ti-plug-connected"></i></span><span><span class="sp-h-name">Milter sockets</span><span class="sp-h-value">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> <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>
</div> </div>
@@ -115,7 +114,7 @@
</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> </div>
</main> </main>
</body> </body>
@@ -1,124 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Preflight · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item is-active" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="preflight.html" aria-current="page"><i class="ti ti-checklist"></i>Preflight</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></div></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-postmark sp-warn"><span>PREFLIGHT</span><b>WARN</b><span>1 OF 7 CHECKS</span></div>
<div class="sp-grow">
<p class="sp-kicker">Server</p>
<h1 class="title is-3">Preflight</h1>
<p class="sp-lead">Is this installation ready to deliver mail? Run it after the first start, after moving the server, or when receivers start refusing you. Health watches what runs; Preflight checks what the outside world sees.</p>
</div>
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-refresh"></i>Run again</button></form>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Checks</h2><div class="sp-end">Ran 14:02 UTC · 3.1 s</div></div>
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Check</th><th>Result</th><th>Found</th><th>What to do</th></tr></thead>
<tbody>
<tr><td class="sp-nowrap"><strong>Reverse DNS</strong></td><td><span class="tag is-success is-light">ok</span></td><td class="sp-mono sp-small">203.0.113.25 ⇄ mail.example.org</td><td class="sp-muted">—</td></tr>
<tr><td class="sp-nowrap"><strong>TLS certificate</strong></td><td><span class="tag is-warning is-light">warn</span></td><td class="sp-small">Valid for <span class="sp-mono">mail.example.org</span>, expires in 12 days</td><td>Renewal has not run. Check that port 80 reaches the ACME client and look for <code>acme</code> in the <a href="system-log.html">system log</a>.</td></tr>
<tr><td class="sp-nowrap"><strong>Port 25</strong></td><td><span class="tag is-success is-light">ok</span></td><td class="sp-mono sp-small">220 mail.example.org ESMTP</td><td class="sp-muted">—</td></tr>
<tr><td class="sp-nowrap"><strong>Ports 465, 587</strong></td><td><span class="tag is-success is-light">ok</span></td><td class="sp-mono sp-small">TLS handshake, 220 banner on both</td><td class="sp-muted">—</td></tr>
<tr><td class="sp-nowrap"><strong>HELO name</strong></td><td><span class="tag is-success is-light">ok</span></td><td class="sp-mono sp-small">mail.example.org</td><td class="sp-muted">—</td></tr>
<tr><td class="sp-nowrap"><strong>Proxy headers</strong></td><td><span class="tag is-success is-light">ok</span></td><td class="sp-small">Peer <span class="sp-mono">172.18.0.2</span> is trusted; <span class="sp-mono">X-Forwarded-Proto: https</span></td><td class="sp-muted">—</td></tr>
<tr><td class="sp-nowrap"><strong>DKIM signing</strong></td><td><span class="tag is-success is-light">ok</span></td><td class="sp-mono sp-small">inet:127.0.0.1:8891 answers</td><td class="sp-muted">—</td></tr>
</tbody>
</table></div>
<div class="sp-box-foot"><span>Ports are dialled from inside the container at the public address; a firewall that treats inside and outside differently can make this pass while the world is still blocked. SPF, DKIM and DMARC are per domain — each <a href=\"out-domains.html\">outbound domain</a> shows its own.</span></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Send a test e-mail</h2><div class="sp-end">Confirms the relay accepted it, not that it arrived</div></div>
<div class="sp-box-body">
<div class="columns">
<div class="column is-6">
<form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="rcpt">Recipient</label>
<div class="field has-addons">
<div class="control is-expanded has-icons-left"><input class="input" id="rcpt" name="recipient" type="email" placeholder="you@elsewhere.example" required><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
<div class="control"><button type="submit" class="button is-primary"><i class="ti ti-send"></i>Send</button></div>
</div>
<p class="help">Sent from <span class="sp-mono">postmaster@mail.example.org</span> through the local Postfix.</p></div>
</form>
</div>
<div class="column is-6">
<dl class="sp-facts">
<div><dt>Last test</dt><dd>14:03 UTC to <span class="sp-mono">mix@elsewhere.example</span></dd></div>
<div><dt>Result</dt><dd><span class="tag is-success is-light">queued</span> <span class="sp-mono sp-small">4XcC1d8Pq2z1</span></dd></div>
<div><dt>Then</dt><dd><a href="out-message.html">Follow it in the outbound log</a></dd></div>
</dl>
</div>
</div>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
+68 -44
View File
@@ -12,8 +12,8 @@
<body> <body>
<nav class="navbar is-primary" aria-label="main navigation"> <nav class="navbar is-primary" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div> <div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group"><summary>Outbound</summary><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group"><summary>Inbound</summary><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group" open><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html" aria-current="page"><i class="ti ti-settings"></i>Settings</a></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
<div class="navbar-menu is-active"> <div class="navbar-menu">
<div class="navbar-start"> <div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a> <a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
@@ -39,7 +39,6 @@
<a class="navbar-link" href="health.html">Server</a> <a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a> <a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a> <a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a> <a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a> <a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
@@ -62,7 +61,7 @@
</div> </div>
</nav> </nav>
<div class="sp-perf" aria-hidden="true"></div> <div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html" aria-current="page"><i class="ti ti-settings"></i>Settings</a></div></div> <div class="sp-subnav"><div class="container"><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html" aria-current="page"><i class="ti ti-settings"></i>Settings</a></div></div>
<main> <main>
<div class="container sp-page"> <div class="container sp-page">
<div class="sp-head"> <div class="sp-head">
@@ -71,11 +70,14 @@
<h1 class="title is-3">Settings</h1> <h1 class="title is-3">Settings</h1>
<p class="sp-lead">What holds for the whole instance, whoever is signed in. Your own name, e-mail and password are under <a href="account.html">Account</a>.</p> <p class="sp-lead">What holds for the whole instance, whoever is signed in. Your own name, e-mail and password are under <a href="account.html">Account</a>.</p>
</div> </div>
<div class="buttons">
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form>
</div>
</div> </div>
<form class="sp-form" method="post" action="#"> <div class="columns">
<div class="columns"> <div class="column is-6">
<div class="column is-6"> <form class="sp-form" method="post" action="#">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Log retention</h2><a class="sp-help" href="help.html#settings" title="How long rows are kept"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Log retention</h2><a class="sp-help" href="help.html#settings" title="How long rows are kept"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
@@ -88,58 +90,80 @@
</div> </div>
</div> </div>
<div class="box" id="rate-limits">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Sending rate limits</h2><div class="sp-end">Read-only</div><a class="sp-help" href="help.html#limits" title="Levels 1 and 2"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Level 1 · per client IP</dt><dd class="sp-big">600 / h</dd><dd class="sp-muted">Set in <code>.env</code>; restart to change. Nothing below may exceed it.</dd></div>
<div><dt>Level 2 · domain, application</dt><dd>Set on each <a href="out-domain-settings.html">domain</a> and <a href="out-app.html">application</a>.</dd></div>
</dl>
</div>
</div>
</div>
<div class="column is-6">
<div class="box" id="notifications"> <div class="box" id="notifications">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Notifications</h2><a class="sp-help" href="help.html#notifications" title="What is sent and when"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>Notifications</h2><a class="sp-help" href="help.html#notifications" title="What is sent and when"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<div class="field"><label class="checkbox"><input type="checkbox" name="notify" checked> Send notification e-mails</label> <div class="field"><label class="checkbox"><input type="checkbox" name="notify" checked> Send notification e-mails</label>
<p class="help">From <span class="sp-mono">postmaster@mail.example.org</span>, to every user who has an e-mail and has not turned them off under <a href="account.html">Account</a>.</p></div> <p class="help">From <span class="sp-mono">postmaster@mail.example.org</span>, to every user who has an e-mail and has not turned them off under <a href="account.html">Account</a>.</p></div>
</div> </div>
</div> </div>
<div class="buttons"><button type="submit" class="button is-primary">Save settings</button></div>
</form>
</div>
<div class="box" id="notification-mail"> <div class="column is-6">
<div class="sp-box-head"><span class="sp-no">04</span><h2>Notification mail</h2><div class="sp-end">DNS records of the server's own name</div><a class="sp-help" href="help.html#notifications" title="What the panel sends and what makes it arrive"><i class="ti ti-help-circle"></i></a></div> <div class="box" id="notification-mail">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Notification mail</h2><div class="sp-end">The server's own name · checked 3 min ago</div><a class="sp-help" href="help.html#notifications" title="What the panel sends and what makes it arrive"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-record"> <details class="sp-record">
<div class="sp-record-head"><h3>DKIM</h3><span class="tag is-success is-light">published</span><small>Not a secret · selector <code>sp2026</code></small></div> <summary class="sp-record-head"><h3>DKIM</h3><span class="tag is-success is-light">published</span><small>Not a secret · selector <code>sp2026</code></small><span class="sp-record-toggle"><i class="ti ti-eye"></i><span>Show values</span><span>Hide values</span></span></summary>
<div class="sp-row"> <div class="sp-row">
<div class="field"><label class="label">Host / name</label> <div class="field"><label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="sp2026._domainkey.mail.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div> <div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="sp2026._domainkey.mail.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div> <div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div>
<div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><textarea class="textarea" rows="5" readonly>v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq7n2Lw0c5vXeR1dYt8uJb3mKs6pHf9aZoWg4iNy2TqCx0lVjE7rUh5sBk1dMa8fPz3oGc6yXw9tIe4nQv2uL…IDAQAB</textarea></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
</div> </div>
<div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><textarea class="textarea" rows="5" readonly>v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq7n2Lw0c5vXeR1dYt8uJb3mKs6pHf9aZoWg4iNy2TqCx0lVjE7rUh5sBk1dMa8fPz3oGc6yXw9tIe4nQv2uL…IDAQAB</textarea></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
</details>
<div class="sp-record"> <div class="sp-record">
<div class="sp-record-head"><h3>SPF</h3><span class="tag is-warning is-light">missing</span><small>For the server's own name, not for your domains</small></div> <div class="sp-record-head"><h3>SPF</h3><span class="tag is-warning is-light">missing</span><small>For the server's own name, not for your domains</small></div>
<div class="sp-row"> <div class="sp-row">
<div class="field"><label class="label">Host / name</label> <div class="field"><label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="mail.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div> <div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="mail.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div> <div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div>
<div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=spf1 a -all"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<p class="help">Notifications are sent without it, and a receiver may file them as spam.</p>
</div> </div>
<div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=spf1 a -all"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<p class="help">Notifications are sent without it, and a receiver may file them as spam.</p>
</div>
<div 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">
<div class="sp-box-head"><span class="sp-no">04</span><h2>Sending rate limits</h2><div class="sp-end">Read-only</div><a class="sp-help" href="help.html#limits" title="Levels 1 and 2"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Level 1 · per client IP</dt><dd class="sp-big">600 / h</dd><dd class="sp-muted">Set in <code>.env</code>; restart to change. Nothing below may exceed it.</dd></div>
<div><dt>Level 2 · domain, application</dt><dd>Set on each <a href="out-domain-settings.html">domain</a> and <a href="out-app.html">application</a>.</dd></div>
</dl>
</div>
</div>
<div class="box" id="time-zone">
<div class="sp-box-head"><span class="sp-no">05</span><h2>Time zone</h2><div class="sp-end">Read-only</div><a class="sp-help" href="help.html#settings" title="Times and days of the panel"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body">
<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>. One exception: the day a DMARC report covers is the reporter's, in UTC.</p>
</div> </div>
</div> </div>
</div> </div>
<div class="buttons"><button type="submit" class="button is-primary">Save settings</button></div> </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> </div>
</main> </main>
</body> </body>
+1 -1
View File
@@ -36,7 +36,7 @@
</div> </div>
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Create administrator</button></div> <div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Create administrator</button></div>
</form> </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>
</div> </div>
@@ -14,12 +14,18 @@
<div class="sp-box-body"> <div class="sp-box-body">
<form class="sp-form" method="post" action="#"> <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="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> <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. A domain's report address can be filled with it from Domain settings.</p></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> <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> <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> <button type="submit" class="button is-primary">Save profile</button>
</form> </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> </div>
</div> </div>
@@ -20,6 +20,8 @@
<div class="field"><label class="label" for="pw2">Repeat password</label><div class="control"><input class="input" id="pw2" type="password"></div></div> <div class="field"><label class="label" for="pw2">Repeat password</label><div class="control"><input class="input" id="pw2" type="password"></div></div>
</div> </div>
<p class="help mb-3">Keep this password: SelfPost does not store it, and the file cannot be opened without it.</p> <p class="help mb-3">Keep this password: SelfPost does not store it, and the file cannot be opened without it.</p>
<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, asked at every download: the archive holds every secret of the server, and a session left open must not be enough to take it.</p></div>
<button type="submit" class="button is-primary"><i class="ti ti-download"></i>Download full backup</button> <button type="submit" class="button is-primary"><i class="ti ti-download"></i>Download full backup</button>
</form> </form>
</div> </div>

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