56 Commits
Author SHA1 Message Date
mixandClaude Opus 5.5 b7f04217f2 release: 2.13.1: docs-audit - the documents and the panel's words against the code
test / test (push) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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
231 changed files with 7676 additions and 1203 deletions
+252 -4
View File
@@ -5,6 +5,224 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased]
## [2.13.1] - 2026-10-17
The documents and the panel's words checked against the code. No migration;
schema 12.
### Fixed
- **A panel password's twelve characters are characters**: the minimum
counted bytes, so six letters of a non-Latin alphabet passed for twelve.
It counts characters now, as the backup password always did. Passwords
already set are not affected.
### Changed
- **The panel and its mockups say the same** (docs-audit): an inbound
domain's MX box says when it was checked and its side menu links the
domain's Inbound log; a deferred message's history ends with the wait in
the queue; Overview says it refreshes on its own; DMARC's hint for an
unaligned third party reads as the mockup. Help says what the code does:
an application's password is kept by the server so an export can carry
it, every password check of a backup counts, a password set for another
user signs them out everywhere, the limit mail is daily per sending
domain, and a domain administrator is no longer shown the Filter lists
topic (their page is the global role's).
### Documentation
- The documents checked against the code (docs-audit, 2026-10-17): the
guide, architecture, schema-migrations, security, development, README and
product say what the code does.
## [2.13.0] - 2026-10-17
The panel in the container's time zone. No migration; schema 12.
### Added
- **The panel in your time zone** (local-time): set `TZ` in `.env` (an IANA
name, e.g. `Europe/Moscow`) and every time the panel shows, every day it
counts - today on Health, the statistics' days, the days of the log
filters - and the stamps of `mail.log` are that zone's. The screens no
longer write "UTC" beside a time; **Server › Settings** names the zone in
a new read-only box. Unset, it is UTC as before. Changing it is a
restart; days counted before stay the old zone's.
## [2.12.0] - 2026-10-17
A test message from Server › Health. No migration; schema 12.
### Added
- **A test message from Server › Health** (test-email): one message to an
address you type, from `postmaster@` the hostname, the way notifications
go. The page that opens is its row in the Outbound log, which follows it
to the receiving server. It tests the server's own path, not a domain's.
Global role, ten an hour.
## [2.11.0] - 2026-10-16
Health checks the certificate's name, Postfix's greeting and the reverse
proxy. No migration; schema 12.
### Added
- **Server › Health checks three things it did not** (health-setup-checks):
whether the TLS certificate is for the server's own name - a tag beside
its names, and a failure when it is not, since a client that checks the
name refuses it however valid it is; Postfix's greeting, the `220` line,
which must name the hostname; and the address the panel sees your browser
at, warning when a reverse proxy it does not trust stands in front - every
visitor then shares one sign-in limit - and naming what to put in
`TRUSTED_PROXY_CIDR`.
## [2.10.4] - 2026-10-16
The origin check is strict. No migration; schema 12.
### Security
- **A change with no word of where it came from is refused**
(origin-strict): a `POST` that carries neither `Sec-Fetch-Site` nor
`Origin` is answered 403, as a cross-origin one is. It was let through
before, as an accepted risk, which left a browser that sends neither open
to a request forged by any site. Every current browser sends `Origin` with
a form; a browser too old to (Firefox before 70) can no longer save
anything in the panel.
## [2.10.3] - 2026-10-16
The backup asks for your password. No migration; schema 12.
### Changed
- **A backup and a domain's export ask for your password** (backup-reauth):
both downloads take the signed-in user's account password, checked like a
password change and under its limit, so a stolen session or a panel left
open no longer hands over the server's every secret.
### Fixed
- **The file field of a domain's import is drawn as designed**: Bulma's
file button and the name of the file chosen, not the browser's own
"Browse…".
## [2.10.2] - 2026-10-16
The panel on a phone. No migration; schema 12.
### Changed
- **A narrow screen gets a Menu** (phone-desktop-mode): below 1024 px - a
phone, a phone in "desktop site" mode, a tablet, a narrow window - the
navigation was a sheet of every section and page, always open, filling
the first screen. It is now *Menu* at the right of the header, which opens
the sections as folds, the current one open, the account's entries last;
no script. The page no longer meets the screen's edge. From 1024 px
nothing changes.
## [2.10.1] - 2026-10-16
Postfix's queue ids no longer repeat. No migration; schema 12.
### Fixed
- **Queue ids no longer repeat**: Postfix makes long, non-repeating queue ids
(`enable_long_queue_ids=yes`). A short id - the microsecond of arrival and
the queue file's inode - came round once the inode did, and a later message
could then rewrite an older row of the Outbound log with the same id and
recipient, put its lines on that row's page, or reach the Inbound log's
row of an earlier message. New ids read like `3Pt2mN2VXxznjll`; mail
already in the queue keeps its short id; Outbound › Queue's Queue ID
column is wider.
## [2.10.0] - 2026-10-16
Inbound › Log says what became of accepted mail: the upstream's answer,
its last reply and the host that gave it. Migration `0012`; schema 12.
Nothing for an operator to do.
### Added
- **Inbound › Log shows what the upstream answered** (inbound-upstream-result,
[the plan](docs/plans/inbound-upstream-result.md)): an *Upstream* column
beside the decision - *queued*, *held*, *sent*, *deferred* (a greylisting, an
upstream that cannot be reached), *bounced* (refused, or expired in the
queue) - opening the last reply, the host that gave it and when. Read from
the delivery lines of `mail.log`, one update of the row per attempt; the
lines of port-25 mail are still not stored. Rows logged before have no
result. Migration `0012`, schema 12.
## [2.9.1] - 2026-10-16
Log panes read on a phone. No migration; schema 11.
### Fixed
- **Log panes wrap their lines instead of scrolling sideways**: a phone drew
two scroll bars over the Delivery log of a message, and the end of a
delivery line - `status=` and the reply - was out of sight. A long line
wraps, the rest indented under its start; copied text keeps one line per
line. The Delivery log, System log and the queue's listing.
## [2.9.0] - 2026-10-16
The panel's DNS records read faster: a record DNS already holds as shown
is one line, its values on request. Server › Settings puts its Save button
under its own fields and can re-check the records of the server's own
name. `/data/opendkim`, which 2.8.x kept for a rollback to 2.7.x, is
removed at the first start; an installation still on 2.7.x goes through
2.8.x first (guide, § Upgrading). No migration; schema 11.
### Changed
- **A DNS record DNS already holds as shown is folded** (dns-record-collapse):
on an outbound domain's page, an inbound domain's and in Server ›
Settings › Notification mail, a *published* record is one line - its
name, its tag and *Show values*, which opens the host, type and value to
copy them again. A record that is missing, weak, wrong or not checked
stays open as before, with *In DNS now* where it differs. A `<details>`
block, no script; *Re-check DNS* draws the page again, so a record that
has just turned right comes back folded.
- **Server › Settings: *Save settings* sits under the fields it saves**
(settings-save-row): the form is the left column - Log retention,
Notifications, the button - and the right column holds what is not a
field, Notification mail and the read-only Sending rate limits. Before,
the button stood under both columns, after a long gap whenever the
records of the server's own name were open.
### Added
- **Re-check DNS on Server › Settings** (notification-mail-recheck): the
records of the server's own name can be checked again where they are
shown, as a domain's can on its page; the Notification mail box says when
they were checked. Before, the only way was Re-check DNS on Server ›
Health, which nothing on Settings pointed to. `POST
/server/settings/dns-recheck`, global role.
### Removed
- **`/data/opendkim`, the key tree of 2.7.x** (panel-dkim-signing, as
planned for the release after 2.8.0): 2.8.x copied its keys to
`/data/dkim` and kept it for a rollback to 2.7.x; the first start of
this image removes it once `/data/dkim` holds a key, and says so. A
`/data` whose keys are under the old tree only - one that never ran
2.8.x - stops the start rather than give the server's own name a new
key: start 2.8.x once first (guide, § Upgrading). The copy and the
backup's exclusion of the tree went with it. A rollback from here goes
to 2.8.x; to 2.7.x only through a 2.7.x backup.
## [2.8.0] - 2026-10-16
The panel signs outgoing mail with DKIM itself, and OpenDKIM - a 2018 beta
with open 2026 CVEs - has left the image. Keys, selectors and DNS records do
not change; the first start copies the keys to `/data/dkim`. The sender
check holds a `Sender:` line too. Nothing for an operator to do; a rollback
to 2.7.x needs one command first (guide, § Upgrading). No migration;
schema 11.
### Added
- **The panel signs outgoing mail with DKIM itself** (panel-dkim-signing,
@@ -15,6 +233,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
once more, as OpenDKIM signed; strict. Before OpenDKIM left, both signed
every message on the e2e stand and agreed on `h=` and `bh=`; dkimpy - an
independent implementation - verifies the panel's signature on every run.
Beyond what OpenDKIM signed: `Sender` and `List-Unsubscribe-Post` (RFC 8058
wants it signed for one-click unsubscribe), and the shown headers a message
has - Subject, Date, To, Cc, Reply-To, Sender - named once more, so that
one added on the way breaks the signature. A header section over 1 MiB is
refused (`552 5.3.4`); a body line over 998 bytes, which Postfix breaks on
delivery, is logged with its domain.
### Removed
@@ -26,14 +250,22 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
### Changed
- **The DKIM keys moved to `/data/dkim`** and are the panel's alone (0600):
the first start copies them from `/data/opendkim`, which is left as it was
for a rollback to 2.7.x. Keys, selectors and DNS records do not change.
The KeyTable and SigningTable are gone; a selector table the signer reads
takes their place. `OPENDKIM_DIR` and `OPENDKIM_SOCKET` are gone.
the first start copies them from `/data/opendkim`, which is kept, the
panel's alone as well, for a rollback to 2.7.x, and left out of backups.
Keys, selectors and DNS records do not change. The KeyTable and
SigningTable are gone; a selector table the signer reads takes their
place, rewritten from the database at every start. `OPENDKIM_DIR` and
`OPENDKIM_SOCKET` are gone.
- **The sender check reads `From:` as a mail program does** and no longer as
OpenDKIM did: the line must be one address at a domain the application
may send as, and the 256-byte limit of OpenDKIM's buffer goes. The key is
the login's, so no `From:` line can choose another tenant's.
- **A `Sender:` line is held to the same rule as `From:`** when a message
has one - the address a mail program shows as "on behalf of". A message
with another domain's address there, or with two `Sender:` lines, is
refused with `550 5.7.1 Sender: …`, tagged `wrong Sender` in the
Outbound log. A system writing for people outside your domains puts its
own address in `From:` and theirs in `Reply-To:`.
### Fixed
@@ -41,6 +273,22 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
it as quoted-printable for a server that does not announce 8BITMIME, after
it was signed; `disable_mime_output_conversion=yes` now delivers it as
signed. OpenDKIM's signatures were exposed to this too.
- **A message of nothing but headers was held whole by every milter.** The
milter library (go-milter v0.4.1) kept each header of a message in a map
of its own that no milter of the panel read - up to the message size
limit, on port 25 from strangers too. The panel builds it patched not to
(`third_party/go-milter`).
- **A rotation of `mail.log` due at the container's start logged an error.**
It runs before Postfix does, and its `postfix reload` failed ("the Postfix
mail system is not running"); nothing was lost - the Postfix starting
next opens the new file - but logrotate reported a failure. It reloads
only a running Postfix now.
- **A start on a queue of an earlier image could stop at `postfix check`.**
The numbers the image's package accounts get depend on what else it
installs, and the queue under `/data` kept the old ones; `postsuper`, which
the check runs as the mail owner, could not read it. The entrypoint hands
the queue to Postfix's users by name before the check now. The first
rollout of this version to the test server met it and was rolled back.
## [2.7.0] - 2026-10-15
+8 -8
View File
@@ -6,9 +6,9 @@
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
configure domains, DKIM keys, and SASL applications once, then point your apps
at the SMTP endpoint.
Postfix and a small Go panel, which also signs outgoing mail with DKIM, run
together under `supervisord`; you configure domains, DKIM keys, and SASL
applications once, then point your apps at the SMTP endpoint.
SelfPost sends mail straight to the internet from **your own IP**, with
per-domain DKIM signing. No mailboxes or webmail. An optional inbound relay
@@ -27,11 +27,11 @@ send log and DNS checks in the panel, encrypted backups.
- Optional inbound relay on port 25 (backup-MX / forwarder; off by default)
- Optional DMARC aggregate report ingest on port 25 (off by default)
- In-panel Help: a page of topics, opened from the `?` in the head of a box
- Per-application client IP allow-list for SMTP AUTH (optional)
- Per-application client IP allow-list for submitting mail (optional; refused at MAIL FROM, not an authentication boundary)
- Web panel grouped by mail direction — Overview, Outbound (domains, log, queue, DMARC reports), Inbound, Server (health, system log, backup, users, settings)
- Multi-domain relay — each SASL application is bound to one sending domain
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
- Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides
- Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and per-application overrides
- Full-server backup and single-domain export/import (optional password encryption)
- Single Docker image; production data in a `./data` bind mount (the quick start below uses a named Docker volume instead)
@@ -112,8 +112,8 @@ outbound mail will not reach the real internet without DNS, PTR, and port 25.
> local image and pulls nothing:
>
> ```sh
> docker build -f build/Dockerfile --build-arg VERSION=2.7.0 \
> -t ghcr.io/mixeme/selfpost:2.7.0 .
> docker build -f build/Dockerfile --build-arg VERSION=2.13.1 \
> -t ghcr.io/mixeme/selfpost:2.13.1 .
> ```
```sh
@@ -122,7 +122,7 @@ docker run --rm -d --name selfpost-try \
-e SELFPOST_HOSTNAME=mail.local.test \
-e PANEL_COOKIE_SECURE=false \
-v selfpost-try-data:/data \
ghcr.io/mixeme/selfpost:2.7.0
ghcr.io/mixeme/selfpost:2.13.1
```
**Get the setup URL** (pick one):
+2 -1
View File
@@ -70,7 +70,8 @@ These are the operator's responsibility or accepted trade-offs, documented in
- Deliverability outcomes: mail rejected or filtered by a receiving provider is
a policy decision of that provider, not a defect
- Denial of service through sheer volume against a single-tenant relay
- Vulnerabilities in upstream Postfix, OpenDKIM, or the base image — report
- 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
exploitable when it otherwise would not be, that *is* in scope
+9 -1
View File
@@ -13,8 +13,11 @@ WORKDIR /src
# Version stamped into both binaries; MUST match the image tag.
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 third_party ./third_party
RUN go mod download
COPY cmd ./cmd
@@ -61,6 +64,10 @@ ENV DEBIAN_FRONTEND=noninteractive
# 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 \
&& echo "postfix postfix/main_mailer_type string Internet Site" | debconf-set-selections \
&& apt-get update \
@@ -75,6 +82,7 @@ RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \
openssl \
curl \
systemd-standalone-sysusers \
tzdata \
&& rm -rf /var/lib/apt/lists/*
# Debian's postfix runs its chroot sync on every "postfix check" and "postfix
+60 -40
View File
@@ -87,7 +87,7 @@ drop_links() {
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/opendkim/keys \
/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 \
@@ -96,39 +96,40 @@ drop_links /data/dkim /data/dkim.new /data/opendkim /data/sasl /data/postfix /da
# under /data; make sure they stay writable without disturbing anything that a
# later phase deliberately hands to another service. /data/log is exempt: it is
# deliberately owned by postfix (postlogd writes the delivery log there) and is
# normalised on its own below, as are the trees shared through group selfpost
# and the one OpenDKIM read until 2.8.0, which is left as it was (below).
# normalised on its own below, as are the trees shared through group selfpost.
# 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 opendkim ! -name sasl ! -name antispam \
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 keys and the selector table, /data/dkim: the panel's alone - it makes
# the keys, writes the table and signs (internal/domain, Keys; internal/milter,
# sign.go). Until 2.8.0 the keys were under /data/opendkim, shared with
# OpenDKIM through group selfpost. The first start without /data/dkim copies
# them over and leaves the old tree as it was, for an image of 2.7.x rolled
# back to (docs/plans/panel-dkim-signing.md, Q2). The copy is made as the
# panel user, which can read nothing there the panel could not, into a
# directory beside, renamed into place when it is whole: a start that stops
# midway copies again. A copy that fails stops the start (set -e) - the
# panel would otherwise make the server's own name a new key, and its
# published record would no longer match. The selector table is written by
# the panel before its signer serves (cmd/panel/signer.go).
# (util-linux 2.41, setpriv(1): --reuid, --regid, --clear-groups; cp(1)
# -P: never follow a symbolic link in the source.)
rm -rf /data/dkim.new
if [ ! -e /data/dkim ] && [ -d /data/opendkim/keys ]; then
setpriv --reuid=panel --regid=panel --clear-groups -- \
sh -c 'mkdir /data/dkim.new && cp -RP /data/opendkim/keys /data/dkim.new/keys'
mv /data/dkim.new /data/dkim
echo "entrypoint: the DKIM keys were copied from /data/opendkim to /data/dkim; /data/opendkim is left for a rollback" >&2
# sign.go). The selector table is written by the panel before its signer
# serves (cmd/panel/signer.go). Until 2.8.0 the keys were under
# /data/opendkim, shared with OpenDKIM; 2.8.x copied them to /data/dkim at its
# first start and kept the old tree for a rollback to 2.7.x until the next
# release, this one (docs/plans/panel-dkim-signing.md, Q2). It is removed
# once /data/dkim holds a key. A /data that never ran 2.8.x - keys under the
# old tree only - stops the start instead: the panel would make the server's
# own name a new key, and its published record would no longer match; 2.8.x
# copies them (guide § Upgrading). rm -rf removes a link, not what it points
# to, and follows none inside (GNU coreutils rm(1)); a link at the path is
# gone already (drop_links). (e2e TestStartsOnA28Data, TestRefusesA27Data.)
has_keys() { [ -d "$1" ] && [ -n "$(find "$1" -type f -name '*.private' | head -n 1)" ]; }
if [ -d /data/opendkim ]; then
if ! has_keys /data/dkim/keys && has_keys /data/opendkim/keys; then
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
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 {} +
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
@@ -219,34 +220,53 @@ chmod 2750 /run/selfpost
# (test(1), -S: true for any socket file, whether or not anything listens.)
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
# 5). Kept out of the image build so cert paths, rate limits, hostname and the
# optional 587 service are all driven by env at run time, and re-derived on every
# start the same way the /data normalisation above is.
/usr/local/bin/postfix-config.sh
# 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
# "post-install create-missing" (Postfix 3.10.13, conf/postfix-script:314;
# 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
# (assumptions C-P34).
chown -R postfix:postfix /data/postfix/queue
# The recursive chown above would leave public/ and maildrop/ in group postfix.
# Postfix keeps those two in its setgid group: postqueue and postdrop are setgid
# postdrop and reach the showq socket and the maildrop directory through that
# group alone. With the wrong group `postqueue -p` run by the panel answers
# "Connect to the Postfix showq service: Permission denied" and the panel cannot
# read the queue (the Queue card and the Outbound queue page).
chgrp postdrop /data/postfix/queue/public /data/postfix/queue/maildrop
# It would also leave the queue directory itself and pid/ owned by postfix.
# Postfix keeps those two owned by root (master, which runs as root, is the only
# writer) and logs "warning: not owned by root" for each at every start.
# (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
# [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
+8
View File
@@ -15,7 +15,15 @@
compress
delaycompress
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
/usr/sbin/postfix status >/dev/null 2>&1 || exit 0
/usr/sbin/postfix reload
endscript
}
+13
View File
@@ -440,6 +440,19 @@ postconf -e \
# 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
# address. The panel's milters judge every message by that address (an
# application's client-address list) and record it (the inbound journal), and
+11 -5
View File
@@ -14,15 +14,17 @@ import (
// are compared as they are, not as lists kept by hand beside them (audit
// 2026-10-12, stage 5 - the three lists this file held were compared with each
// other and with nothing else). Read: what the panel's code asks of the
// environment (cmd/panel and internal, tests aside) and what the start-up
// environment (cmd/panel, cmd/selfpost-backup and internal, tests aside) and what the start-up
// scripts take with a default (build/*.sh, "${VAR:-…}"). Documented: the
// operator's table in guide.md § Environment variables, with the paragraph
// under it, and the internal list in architecture.md § Configuration.
var (
goEnvRead = regexp.MustCompile(`(?:envDefault|envInt|envBool|envSeconds|envDuration|os\.Getenv|os\.LookupEnv)\("([A-Z][A-Z0-9_]+)"`)
goEnvRead = regexp.MustCompile(`(?:envDefault|envInt|envBool|envSeconds|envDuration|os\.Getenv|os\.LookupEnv)\("([A-Z][A-Z0-9_]+)"`)
// A name kept in a constant, as the backup CLI keeps its password's.
goEnvConst = regexp.MustCompile(`\w+Env\s*=\s*"([A-Z][A-Z0-9_]+)"`)
scriptEnvRead = regexp.MustCompile(`\$\{([A-Z][A-Z0-9_]+):[-?=]`)
docEnvName = regexp.MustCompile("`([A-Z][A-Z0-9_]{2,})`")
docEnvName = regexp.MustCompile("`([A-Z][A-Z0-9_]+)`")
)
func readFile(t *testing.T, path string) string {
@@ -38,12 +40,16 @@ func readFile(t *testing.T, path string) string {
func envRead(t *testing.T) map[string]bool {
t.Helper()
keys := map[string]bool{}
for _, root := range []string{".", filepath.Join("..", "..", "internal")} {
for _, root := range []string{".", filepath.Join("..", "selfpost-backup"), filepath.Join("..", "..", "internal")} {
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return err
}
for _, m := range goEnvRead.FindAllStringSubmatch(readFile(t, path), -1) {
text := readFile(t, path)
for _, m := range goEnvRead.FindAllStringSubmatch(text, -1) {
keys[m[1]] = true
}
for _, m := range goEnvConst.FindAllStringSubmatch(text, -1) {
keys[m[1]] = true
}
return nil
+2
View File
@@ -112,6 +112,8 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
TrustedProxyCIDRs: cfg.trustedProxies,
TLSCertFile: cfg.tlsCertFile,
TLSServedCertFile: cfg.tlsServedCertFile,
SMTPBanner: cfg.smtpBanner,
TimeZone: cfg.timeZone,
TLSReloadInterval: cfg.tlsReloadInterval,
SignSocket: cfg.signSocket,
SignLocalSocket: cfg.signLocalSocket,
+10 -2
View File
@@ -97,6 +97,12 @@ type config struct {
// how often build/postfix-cert-reload.sh looks for a renewal.
tlsServedCertFile string
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
dkimDir string
dkimSelectorDef string
@@ -147,6 +153,7 @@ func loadConfig() config {
manifestPath: filepath.Join(dataDir, backup.ManifestName),
setupTokenPath: envDefault("SELFPOST_SETUP_TOKEN_FILE", filepath.Join(dataDir, "setup-token")),
hostname: os.Getenv("SELFPOST_HOSTNAME"),
timeZone: os.Getenv("TZ"),
// Secure cookies by default (security.md); PANEL_COOKIE_SECURE=false is a
// development-only escape hatch for testing over plain HTTP.
cookieSecure: envDefault("PANEL_COOKIE_SECURE", "true") != "false",
@@ -323,8 +330,8 @@ func openData(cfg config, resync func(*store.Store) error) (st *store.Store, res
return st, restored, nil
}
// run starts the panel's three roles and blocks until a shutdown signal or the
// first fatal error from any role. A signal triggers a clean stop of all
// run starts the panel's roles (the list below) and blocks until a shutdown
// signal or the first fatal error from any role. A signal triggers a clean stop of all
// roles; a role error cancels the others and is returned so the process exits
// non-zero (letting supervisord/Docker see the failure — architecture.md §
// Image and processes).
@@ -333,6 +340,7 @@ func run() error {
// postfix-config.sh has written main.cf before supervisord started the
// panel (build/entrypoint.sh), and nothing rewrites it after.
cfg.tlsServedCertFile = health.ServedCertPath()
cfg.smtpBanner = health.SMTPBanner()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
+3 -2
View File
@@ -360,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
// 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 {
t.Helper()
form := url.Values{}
form := url.Values{"account_password": {restorePassword}}
if password != "" {
form.Set("encrypt", "1")
form.Set("password", password)
+7 -5
View File
@@ -104,19 +104,21 @@ func senderCheckDowntime(markAt, now time.Time, ask func() postfixState) (down t
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 {
day time.Time
down time.Duration
}
// byDay cuts the time from 'from' to 'to' at the UTC midnights between them,
// so that a night's outage is not all booked on the morning it ended.
// byDay cuts the time from 'from' to 'to' at the midnights of the container's
// 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 {
from, to = from.UTC(), to.UTC()
from, to = from.Local(), to.Local()
var parts []dayPart
for from.Before(to) {
next := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, 1)
next := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, time.Local).AddDate(0, 0, 1)
if next.After(to) {
next = to
}
+12
View File
@@ -62,3 +62,15 @@ func TestByDay(t *testing.T) {
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)
}
}
+31 -9
View File
@@ -5,12 +5,17 @@ import (
"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
@@ -20,16 +25,33 @@ import (
// 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 made to
// hold the server's own name - and written at all on the first start of
// 2.8.0, whose table replaced OpenDKIM's: Postfix waits for these sockets
// (build/postfix-wrapper.sh), so no message meets the table before it.
// 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 {
if rebuilt, err := newMailStack(cfg, st).Domains.EnsureServerSigned(); err != nil {
// Mail waits: what the signer cannot find a key for it defers.
log.Printf("dkim-signer: the selector table: %v", err)
} else if rebuilt {
log.Printf("dkim-signer: the selector table was rewritten from the registry")
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())
+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 })
}
+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
# 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
# supply X-Forwarded-For for login/setup rate-limiting. Leave unset unless
# you know the exact address of your reverse proxy — trusting the wrong
+1 -1
View File
@@ -1,6 +1,6 @@
# Example host Apache vhost for the SelfPost panel (default scenario). Install
# on the host running Apache — NOT inside the SelfPost container. Requires
# mod_ssl and mod_proxy/mod_proxy_http enabled (`a2enmod ssl proxy proxy_http`).
# mod_ssl, mod_proxy/mod_proxy_http and mod_rewrite enabled (`a2enmod ssl proxy proxy_http rewrite`).
#
# Certificates: obtain with the Apache certbot plugin against THIS vhost
# (`certbot --apache -d mail.example.com`). Certbot edits this file in place to
+4 -1
View File
@@ -23,7 +23,7 @@
services:
selfpost:
image: ghcr.io/mixeme/selfpost:2.7.0
image: ghcr.io/mixeme/selfpost:2.13.1
restart: unless-stopped
environment:
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
# resolver synthesises. Empty uses public defaults.
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
# rate-limiting. See docs/guide.md "Environment variables" — wrong value lets a
# client spoof its rate-limit key.
+242 -96
View File
@@ -26,15 +26,17 @@ Managed programs ([build/supervisord.conf](../build/supervisord.conf)):
| Program | User | Priority | Role |
|---|---|---|---|
| `syslog-relay` | panel | 50 | The container's `/dev/log`: passes what OpenDKIM logs through syslog(3) to the container's log (`panel -syslog-relay`) |
| `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; with the inbound relay on also the inbound journal milter |
| `postfix` | root (wrapper) | 300 | MTA — started only after the milter sockets exist (OpenDKIM's, the sender check's, the DKIM signer's two, the journal milter's and, with the inbound relay on, the inbound journal milter's) |
| `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 |
| `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-reload` | root | — | On-demand `postfix reload` (autostart off) |
| `cert-reload` | root | 400 | Daily: re-copies a renewed TLS certificate for Postfix and reloads it |
| `logrotate` | root | 400 | Periodic `mail.log` rotation |
Start order: syslog-relay → OpenDKIM → panel (opens the sender check's, the DKIM signer'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
`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
@@ -55,14 +57,15 @@ 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), `cert-reload`'s and
`syslog-relay`'s stay on Server › Health instead.
`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
waits for its exit status (`internal/supervisor`, `RunOnce`); the program
rests EXITED after a run.
**Liveness:** `GET /healthz` (unauthenticated) returns 200 when opendkim,
panel, and postfix are RUNNING; Docker `HEALTHCHECK` uses the same probe (its
**Liveness:** `GET /healthz` (unauthenticated) returns 200 when panel - whose
roles include the DKIM signer - and postfix are RUNNING
(`internal/health`, `Liveness`); Docker `HEALTHCHECK` uses the same probe (its
90-second start period covers postfix's STARTING).
---
@@ -73,7 +76,7 @@ panel, and postfix are RUNNING; Docker `HEALTHCHECK` uses the same probe (its
Client ──TLS+SASL──► Postfix (465 smtps, optional 587 submission)
│
├─► 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)
└─► outbound MX delivery (port 25 client)
@@ -96,7 +99,7 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
- **587/submission** — only when `SUBMISSION_ENABLE=true`; STARTTLS with
`smtpd_tls_security_level=encrypt`.
- **25/smtp inet** — when `INBOUND_RELAY_ENABLE=true` and/or
`DMARC_REPORTS_ENABLE=true`. No SASL, no OpenDKIM, no journal-milter —
`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
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
@@ -110,7 +113,12 @@ 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
`inbound_spam_log` row per recipient at an inbound domain - a DMARC report
address is not one - and never refuses anything
([internal/milter/inbound.go](../internal/milter/inbound.go)). The headers
([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
signature: a third header, `X-SelfPost-Filter`, the HMAC-SHA256 of the
message's queue id under the key in `/data/antispam/filter.key`
@@ -127,9 +135,10 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
Refusals come from `mail.log`: this listener logs as `postfix/inbound`
(`syslog_name`), and the log tailer turns a `reject:` line of
`postfix/inbound/smtpd` (Postfix's own policy) and a `milter-reject` /
`milter-hold` / `milter-discard` line of `cleanup` (the filter's verdict,
counted only for a queue id that listener opened) into a row, dated by the
line, when the recipient is at an inbound domain
`milter-discard` line of `cleanup` (the filter's verdict, counted only for
a queue id that listener opened) into a row, dated by the line, when the
recipient is at an inbound domain; a message the filter holds goes on to
the inbound journal milter, which writes its row as `held`
([internal/logtail/inbound.go](../internal/logtail/inbound.go)). A line
names one recipient (the last), so a message refused at end-of-message is
one row whatever its recipient count. The tailer saves its place every five seconds,
@@ -175,8 +184,9 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
an empty upstream host are omitted from the maps. Optional
`INBOUND_ANTISPAM_MILTER` only when inbound relay is enabled (not on
DMARC-only port 25); it is attached with its own `default_action`,
`INBOUND_ANTISPAM_MILTER_ACTION`, which is `accept` (fail-open) unless set
to `tempfail` (main.cf's `milter_default_action` is `tempfail`).
`INBOUND_ANTISPAM_MILTER_ACTION`, `accept` (fail-open, the default) or
`tempfail`, any other value stopping the start (main.cf's
`milter_default_action` is `tempfail`).
**DMARC ingest** (`DMARC_REPORTS_ENABLE=true`): allow-listed
report addresses via `check_recipient_access` on `postfix/dmarc_recipients`;
messages pipe to `dmarc-ingest` (symlink to `panel`). When both flags are
@@ -209,31 +219,110 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
and to smtpd's `client=` line in mail.log.
- **Chroot disabled** for all services (DNS/TLS inside container).
- **TLS certs** — read-only mount at `TLS_CERT_FILE` / `TLS_KEY_FILE`, copied
at start into `/etc/postfix/tls-internal`, owned by root and `0600` as
Postfix's TLS_README advises (Postfix itself reads the key as root and does
at start into `/etc/postfix/tls-internal`, owned by root, the key `0600`
(the certificate `0644`) as Postfix's TLS_README advises (Postfix itself reads the key as root and does
not check its mode), which the mount cannot give; Postfix reads the copy.
`cert-reload` compares the mount with the copy daily; when they differ and
the certificate and key belong together it renews the copy (each file
renamed into place) and reloads Postfix.
### OpenDKIM
### DKIM signing
Per-domain keys under `/data/opendkim/keys`; `KeyTable` / `SigningTable` maintained
by the panel. Socket `/run/opendkim/opendkim.sock`.
The panel signs outgoing mail itself, in a role of its own, `dkim-signer`
([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
([plans/panel-dkim-signing.md](plans/panel-dkim-signing.md)).
The server's own name (`SELFPOST_HOSTNAME`) has a key and a line in both
tables too, without being a row of the registry
(`domain.Service.SetServerName`): the panel's notifications are sent as
`postmaster@` that name and signed with it. No application can use it as an
envelope sender - the sender map is built from the registry - 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. At its start
the notifier makes sure the tables have the line and name the selector the
panel shows (`EnsureServerSigned`): tables are otherwise rebuilt only when a
domain is added or deleted.
- **Keys** — `/data/dkim/keys/<name>/<selector>.private`, PKCS#1 PEM, one
for each sending domain and one for the server's own name
(`internal/domain`, `Keys`; `DKIM_DIR`, default `/data/dkim`). A key is
made when a domain is added and an existing one is reused, never
overwritten: that would invalidate the record already published
(`EnsureKey`). A domain export carries the key and an import writes it. A
deleted domain's key directory is removed once the selector table no
longer names it; one a deletion had to leave, Reload configuration removes
(`Service.Resync`).
- **The selector table** — `/data/dkim/selectors`, a line `<name> <selector>`
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` and `List-Unsubscribe-Post`
besides (`signHeaders`), 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))
@@ -261,9 +350,8 @@ milter, § Postfix above, port 25):
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**. `relaxed/simple`, RFC 6376 § 5.4.1's headers and `From` once
more, go-msgauth/dkim; keys and selectors from OpenDKIM's KeyTable while
both sign (`internal/milter/sign.go`).
**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**
(`default_action=accept`) so milter failure does not stop mail. Domain
@@ -287,12 +375,13 @@ milter, § Postfix above, port 25):
7. **notifier** — writes to the panel's users when something needs them
(§ Notifications below).
Milter chain in Postfix: the sender check (tempfail), the panel's DKIM
signer (tempfail), OpenDKIM (tempfail), then the journal (accept on
failure); until OpenDKIM leaves the image both sign every message
(docs/plans/panel-dkim-signing.md, stage 1). Mail submitted locally with
`sendmail(1)` — the notifier's — passes the signer's own socket and OpenDKIM
(`non_smtpd_milters`): it is signed, it is the panel's own and so needs no
Milter chain in Postfix ([build/postfix-config.sh](../build/postfix-config.sh),
`smtpd_milters`): the sender check (tempfail), the DKIM signer (tempfail),
then the journal (accept on failure) - the check first, since the signature
is held to the `From:` domain and a message the check could not judge must
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.
@@ -302,10 +391,13 @@ generates pass no milter.
[internal/milter/sender.go](../internal/milter/sender.go) holds a message's
`From:` header against the application that sends it
([plans/from-header-binding.md](plans/from-header-binding.md)). Postfix holds
the envelope sender against the SASL login, and OpenDKIM chooses the signing
key by the header; until `2.4.0` nothing connected the two, and an
application could have mail signed as another domain of the server or as the
server itself.
the envelope sender against the SASL login, and the signer signs with the key
of the login's domain (§ DKIM signing): the signature's `d=` is that domain,
and a receiver holds it to the domain of `From:` (DMARC, RFC 7489 § 3.1.1).
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
`smtpd_sender_login_maps` and makes the same lookup: the address itself is
@@ -324,20 +416,24 @@ server itself.
`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.
- **How the header is read.** Exactly one `From:` field (none, or more than
one, is refused), read with OpenDKIM's own parser: libopendkim's
`dkim_mail_parse()`, ported line for line from Debian's opendkim
2.11.0~beta2 (`internal/milter/mailparse.go`) and tested against the C on
two hundred thousand lines. The check asks Postfix for header values with
their leading space, as OpenDKIM does (`SMFIP_HDR_LEADSPC`), so both read the
same bytes. The address it returns is the one judged - the one OpenDKIM
signs for - whatever a display name, a comment or a quoted local part
holds; a parse failure, no address, or a second one after it is refused,
and so is an address over 256 bytes, which OpenDKIM would match its
`SigningTable` against cut short (`dkimf_apply_signtable()`,
`MAXADDRESS`). Encoded words are not decoded, by either. The line must also
be one address in RFC 5322's grammar (`net/mail`), at the domain OpenDKIM
signs for, so that a mail program shows the address that is signed; that
address, its local part unquoted, is what the bindings are asked about.
one, is refused). The line, its folds joined, must be one address in RFC
5322's grammar as Go's `net/mail` reads it - a mail program's reading, the
address it shows; a display name may say anything (an encoded one in a
charset Go does not convert is passed through unread). A line a lenient
program could show as another address - `ceo@bank.com <me@mine.com>`,
`<b@evil.com, a@mine.com>` - is refused. That address, its local part
unquoted as Postfix holds the envelope's, is what the bindings are asked
about, folded to lower case in ASCII only: a Unicode fold would make the
Kelvin sign a `k` and let a look-alike pass. Since `2.8.0` a `Sender:`
line - the sending agent's, shown beside `From:` as "on behalf of" - is
held to the same rule when the message has one (at most one; the owner's
decision, 2026-10-16): the reply says `Sender:` instead of `From:` and the
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,
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
@@ -381,10 +477,10 @@ server itself.
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):
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
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
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
@@ -480,9 +576,12 @@ and `MAIL_LOG` in [cmd/panel/main.go](../cmd/panel/main.go). Backups exclude
Rotation uses rename + `postfix reload`
([build/logrotate-mail.conf](../build/logrotate-mail.conf)), not `copytruncate` —
the latter can drop `status=sent` lines and leave send-log rows stuck at
`queued`. After rename, logrotate runs `create 0640 postfix selfpost` (postlogd
recreates the file on its first line after the reload, as root and mode
`0600`, which the unprivileged panel user cannot read). `follow()` drains the
`queued`. After rename, logrotate runs `create 0640 postfix selfpost`, so the
file is there, readable by the panel through group `selfpost`, before postlogd
writes its first line after the reload; a file postlogd makes itself (one
removed while the container runs) is `0640` too, through
`maillog_file_permissions=0640` - Postfix's default is `0600`, which the
unprivileged panel user could not read. `follow()` drains the
old inode once more before switching descriptors, and keeps it open and reads
it for a minute after: postlogd keeps writing to the renamed file until the
reload makes it exit, seconds later (Postfix 3.10.13, src/postlogd/postlogd.c;
@@ -496,10 +595,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
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
tailer stores its position plus a fingerprint of the log - the hash of its first
512 bytes and the file's device and inode, so two logs that begin alike are two
files and a renamed file keeps its fingerprint - and on start resumes from it,
512 bytes and the file's inode (not the device, which can change across a
host's reboot), so two logs that begin alike are two files and a renamed file
keeps its fingerprint - and on start resumes from it,
parsing the tail written while the panel was down. If the fingerprint no longer
matches, the log was rotated in the meantime: the tailer first opens
`mail.log.1` (left plain by `delaycompress`), and if that is the file the saved
@@ -508,7 +641,7 @@ start - what Postfix wrote between the stop and the rotation is not lost. A
`mail.log.1` that is another file (rotated twice) is logged with the time from
which nothing was read. Re-parsing lines is harmless because `UpdateStatus`
writes the same status onto the same row. Only a first-ever start, with nothing
stored, begins at end-of-file, so installing the panel does not replay a
stored (or a stored place that cannot be read), begins at end-of-file, so installing the panel does not replay a
pre-existing log.
**A refusal's line, and a refusal the panel did not write.** A message refused
@@ -573,7 +706,11 @@ 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),
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
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
cannot fail it with `database is locked`.
Now that the log survives the container, reaching this path should mean the
@@ -581,7 +718,7 @@ lines are gone for good — rotated past fourteen files while the panel was down
or deleted — or that the tailer was not reading.
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
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
@@ -707,7 +844,7 @@ only reads (the delete confirmation at `GET …/delete` renders a form).
| Outbound › DMARC reports | `GET /outbound/dmarc` · `…/dmarc/domains/{id}` · `…/dmarc/reports/{id}` — registered only with `DMARC_REPORTS_ENABLE=true` |
| Inbound › Domains | `GET, POST /inbound/domains` · `/inbound/domains/{id}` · `POST …/dns-recheck`, `…/upstream`, `…/recipients` · `GET, POST …/delete` — registered only with `INBOUND_RELAY_ENABLE=true` |
| Inbound › Log, Filter lists | `GET /inbound/log` · `GET, POST /inbound/filter-lists` · `POST /inbound/filter-lists/{id}/delete` — registered only with `INBOUND_RELAY_ENABLE=true` |
| Server | `GET /server/health` (+ `/fragment`) · `POST /server/health/recheck`, `/server/health/reload` · `GET /server/log` (+ `/fragment`) · `GET, POST /server/backup` · `POST /server/backup/import` · `/server/users`, `…/new`, `…/{uid}`, `…/{uid}/delete` · `GET, POST /server/settings` · `GET /server/components` |
| 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` |
| User menu | `GET /help` · `POST /logout` (public mux) |
@@ -809,9 +946,8 @@ is one layout and one way to render.
package (`NewOutDomain`, `NewHelp`, …) that the handler fills. The shell is
derived from `Meta` and the feature flags (`SetInboundEnabled`,
`SetDMARCEnabled`) in one place, `Engine.shell`, so which entries the menu
has is decided once. No template reads a `map[string]any`; the one map left
in the handlers is the health sampler's, which is turned into a typed
`Health` before rendering.
has is decided once. No page's data is a map: the health sampler too
returns a typed `health.Machine`.
- **The component kit.** [components.html](../internal/web/view/templates/components.html)
holds one `{{define}}` per partial (page head, postmark, box and its head and
foot, flash, health card, DNS record, facts, side menu, timeline, log pane,
@@ -851,8 +987,8 @@ has the component skeleton of its mockup), `TestRedesignedPagesLoadOnlyTheKit`,
ratchet list `internal/web/view/legacy_pages.txt` is empty and can only shrink;
the CI step
[design-first](../.github/scripts/design-first.sh) rejects a commit that
changes a guard together with the code it judges, and a template that is a
byte-for-byte return of one from history. The design contract and the process
changes a guard together with the code it judges, and a template that is
the same as one from history, whitespace aside. The design contract and the process
are in [plans/panel-redesign.md](plans/panel-redesign.md) § Enforcement.
### Security headers, CSP and scripts
@@ -876,7 +1012,8 @@ 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 let through (an accepted risk). A refusal logs the `Sec-Fetch-Site`,
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).
@@ -925,19 +1062,19 @@ the cookie and the restored row's `expires_at` has not passed.
## Code layers
Multi-store writes that must land in more than one place (SQLite row,
`sasldb2` entry, Postfix map, OpenDKIM table) go through a service, which is
`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
`store` directly for single-table reads and simple writes (sessions, users,
send-log queries); the first-run setup-token file is read and written in
`web` itself. The adapters below the services are the only code that knows
about Postfix, OpenDKIM, DNS or the log file, which is what makes them
about Postfix, the milter protocol, DNS or the log file, which is what makes them
substitutable in tests — `milter.Store`, `app.SenderMaps` and
`logtail.StatusStore` are the seams the unit tests replace with fakes.
```mermaid
flowchart TB
subgraph cmd ["cmd — composition root"]
panel["panel: HTTP + journal-milter + log-tailer"]
panel["panel: HTTP + sender check + DKIM signer + journal-milter + log-tailer"]
backupcli["selfpost-backup CLI"]
end
subgraph web ["internal/web — HTTP surface"]
@@ -995,7 +1132,7 @@ flowchart TB
dmarcSvc --> postfix
```
The roles inside the `panel` process (HTTP server, sender check,
The roles inside the `panel` process (HTTP server, sender check, DKIM signer,
journal-milter, log-tailer, rate-limit recalc, notifier) share one binary and
one SQLite handle on purpose — see
[Panel binary](#panel-binary-cmdpanel) for why, and *Persistence* below for the
@@ -1009,7 +1146,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) |
| `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 |
| `postfix/sender_login_maps` | Login → From binding |
| `postfix/relay_domains` | Inbound domains accepted on port 25 |
@@ -1019,8 +1156,9 @@ single-connection trade-off that follows from it.
| `postfix/dmarc_recipients` | Allow-listed DMARC aggregate report addresses |
| `postfix/dmarc_transport` | Pipe transport for report ingest |
| `postfix/dmarc_relay_domains` | Domains accepted for DMARC report delivery |
| `postfix/queue/` | Postfix transit mail (deferred/active); survives container recreate |
| `postfix/queue/` | Postfix transit mail (deferred/active); survives container recreate; excluded from backups. Handed to `postfix` / `postdrop` by name at every start, before `postfix check` (`own_queue` in `build/entrypoint.sh`): the package accounts' numbers moved in `2.8.0` (postfix 101 → 100, postdrop 103 → 102) |
| `log/mail.log` | Postfix delivery log + rotated copies (excluded from backups) |
| `antispam/` | The filter lists (six `.map` files) and the filter's key `filter.key`, which the spam filter's container mounts (`ANTISPAM_DIR`) |
| `manifest.json` | Backup version stamp; removed by the first start after a restore once its Resync is done |
| `contents.json` | A backup's last entry: the size and SHA-256 of each file under `data/`, compared by the first start after a restore before it opens the database (renamed `contents.checked.json` once compared, removed with the manifest) |
@@ -1030,7 +1168,7 @@ full backups also archive the operator's `./certs` PEM files when present.
### Database
The schema is an embedded baseline, `internal/store/migrations/0001_init.sql`,
and the migrations after it (`0002` to `0011` today; the next is `0012`),
and the migrations after it (`0002` to `0012` today; the next is `0013`),
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
@@ -1071,7 +1209,7 @@ The tables that carry the panel's model:
`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 UTC day) and
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).
Two processes write: the panel, on one connection, and `dmarc-ingest`, which
@@ -1089,15 +1227,19 @@ the stored log lines follow;
`postfix reload` in `postrotate` — see § Log tailer above).
**Restore:** panel button or `selfpost-backup` CLI — self-contained archive:
`data/` (SQLite snapshot + tree minus `log/`, the setup token and any `tls/`
under `/data`), `docker-compose.yml`, `.env`, and `certs/` when present;
`data/` (SQLite snapshot + tree minus `log/`, the Postfix queue
`postfix/queue`, the copy `sasl/sasldb2.new` a password change is made on, the
setup token and any `tls/` under `/data`), `docker-compose.yml`, `.env`,
and `certs/` when present;
version check on restore. Requires the project directory mounted read-only at
`SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` in the default compose file). On the
first successful boot after restore, the panel runs one **Resync** — OpenDKIM's
tables, Postfix's sender map, inbound relay maps and the filter lists (when
`INBOUND_RELAY_ENABLE=true`), and DMARC maps (when `DMARC_REPORTS_ENABLE=true`)
are re-derived from SQLite and both daemons are reloaded, so drift between the
extracted archive and the database is healed before mail flows. Manual
first successful boot after restore, the panel runs one **Resync**
(`mailStack.Resync`, `cmd/panel/httpserver.go`) — the DKIM selector table
(and the keys of deleted domains removed), Postfix's sender map, inbound relay
maps and the filter lists (when `INBOUND_RELAY_ENABLE=true`), and DMARC maps
(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 ›
Health) writes the same files in the same order (`HandleReload`; before
`2.5.4` it left out the filter lists and the DMARC maps), and on a failure
@@ -1164,11 +1306,15 @@ unsupported rather than as a missing doc:
`POSTFIX_DMARC_RECIPIENTS` (`/data/postfix/dmarc_recipients`),
`POSTFIX_DMARC_TRANSPORT` (`/data/postfix/dmarc_transport`),
`POSTFIX_DMARC_RELAY_DOMAINS` (`/data/postfix/dmarc_relay_domains`),
`POSTFIX_QUEUE_DIR` (`/data/postfix/queue` — set in `build/postfix-config.sh`),
`POSTFIX_QUEUE_DIR` (`/data/postfix/queue` — read in `build/postfix-config.sh`
for `queue_directory`; `build/entrypoint.sh` hands `/data/postfix/queue` to
Postfix by that path, so overriding it desyncs the queue's ownership),
`SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` — operator project directory for
full backups; mount `.:/selfpost-deploy:ro` in compose), `ANTISPAM_DIR`
(`/data/antispam` — the filter lists and the filter's key, which the spam
filter's container mounts).
filter's container mounts). `SELFPOST_BACKUP_PASSWORD` is read by the
`selfpost-backup` CLI, not the panel: the encryption password, the other
way being `-password-file` (guide § Encrypting a backup).
- **Milter and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`),
`MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`),
`MILTER_WAIT_TIMEOUT` (`30` seconds).
@@ -12,8 +12,8 @@
<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-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="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></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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
@@ -108,7 +108,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
+5 -3
View File
@@ -12,8 +12,8 @@
<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-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="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></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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
@@ -86,6 +86,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>
<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>
</form>
</div>
@@ -109,7 +111,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
+27 -3
View File
@@ -52,15 +52,39 @@ HEAD = """<!DOCTYPE html>
</head>
"""
FOOT = ('<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a>'
FOOT = ('<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a>'
' · <a href="#">Source</a> · No warranty</footer>')
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):
out = ['<nav class="navbar is-primary" aria-label="main navigation">', ' <div class="container">',
' <div class="navbar-brand"><a class="navbar-item" href="overview.html">'
'<img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>',
' <div class="navbar-menu is-active">', ' <div class="navbar-start">']
'<img src="../../selfpost-wordmark.svg" alt="SelfPost"></a>' + touchnav(section, page) + '</div>',
' <div class="navbar-menu">', ' <div class="navbar-start">']
for key, label, href, _icon, pages in NAV:
cur = " sp-current" if key == section else ""
if not pages:
@@ -12,8 +12,8 @@
<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-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="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></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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
@@ -150,9 +150,9 @@
<div class="columns">
<div class="column is-7">
<div class="box">
<div class="sp-box-head"><span class="sp-no">05</span><h2>dns_record · copy_field</h2><div class="sp-end">One block per record</div></div>
<div class="sp-record">
<div class="sp-record-head"><h3>Record</h3><span class="tag is-success is-light">published</span><small>A one-line note</small></div>
<div class="sp-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>
<details class="sp-record">
<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="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>
@@ -160,7 +160,7 @@
</div>
<div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><textarea class="textarea" rows="2" readonly>copy_field — multi-line, for a DKIM key</textarea></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
</div>
</details>
<div class="sp-record">
<div class="sp-record-head"><h3>Record</h3><span class="tag is-danger is-light">mismatch</span><small>"In DNS now" appears only when it differs</small></div>
<div class="field"><label class="label">In DNS now</label>
@@ -172,7 +172,7 @@
<div class="box">
<div class="sp-box-head"><span class="sp-no">07</span><h2>Table conventions</h2><div class="sp-end">Always inside <code>table-container</code> · an address breaks after its @</div></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Time, UTC</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
<thead><tr><th>Time</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
<tbody>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td><a href="#"><strong>the row's link</strong></a></td><td class="sp-clip">A long subject is clipped with an ellipsis instead of wrapping the row</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="#">Details</a><form method="post" action="#"><button type="submit" class="has-text-danger">Delete</button></form></td></tr>
</tbody>
@@ -182,9 +182,10 @@
<div class="box">
<div class="sp-box-head"><span class="sp-no">09</span><h2>log_pane</h2><div class="sp-end">sp-d time · sp-w warning · sp-e error</div></div>
<pre class="sp-log"><span class="sp-d">13:58:07</span> an ordinary line
<span class="sp-d">13:58:09</span> <span class="sp-w">a deferred delivery — sp-w</span>
<span class="sp-d">13:57:41</span> <span class="sp-e">a reject or a bounce — sp-e</span></pre>
<pre class="sp-log"><span class="sp-ll"><span class="sp-d">13:58:07</span> an ordinary line
</span><span class="sp-ll"><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:57:41</span> <span class="sp-e">a reject or a bounce — sp-e</span>
</span></pre>
</div>
</div>
@@ -195,7 +196,9 @@
<dl class="sp-facts">
<div><dt>Label</dt><dd>Value</dd></div>
<div><dt>Mono value</dt><dd class="sp-mono">4XcB7k2Jm9z1</dd></div>
<div><dt>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 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>
</div>
</div>
@@ -204,9 +207,9 @@
<div class="sp-box-head"><span class="sp-no">08</span><h2>timeline</h2></div>
<div class="sp-box-body">
<ol class="sp-timeline">
<li><time>13:58:07 UTC</time><strong>Done</strong> — default</li>
<li class="sp-warn"><time>13:58:09 UTC</time><strong>Deferred</strong> — sp-warn</li>
<li class="sp-fail"><time>14:40:00 UTC</time><strong>Bounced</strong> — sp-fail</li>
<li><time>13:58:07</time><strong>Done</strong> — default</li>
<li class="sp-warn"><time>13:58:09</time><strong>Deferred</strong> — sp-warn</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>
</ol>
</div>
@@ -289,7 +292,7 @@
<div class="sp-box-foot">Bulma's <code>is-small</code> and <code>is-size-7</code> are not used: a control is never small, and <code>sp-small</code> replaces the helper. The postmark's fine print is an imprint, not text.</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -12,8 +12,8 @@
<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-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="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></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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current">
@@ -92,7 +92,7 @@
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Reports</h2></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Received, UTC</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
<thead><tr><th>Received</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
<tbody>
<tr><td class="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>
@@ -103,7 +103,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -12,8 +12,8 @@
<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-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="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></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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current">
@@ -68,7 +68,7 @@
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker"><a href="dmarc.html">DMARC reports</a> / <a href="dmarc-domain.html">shop.example.org</a> /</p>
<h1 class="title is-4">Outlook.com · 20 Sep 2026</h1>
<h1 class="title is-4">Outlook.com · 20 Sep</h1>
<p class="sp-route"><span>shop.example.org</span><span class="tag is-success is-light">1 903 pass</span><span class="tag is-warning is-light">7 fail</span></p>
</div>
</div>
@@ -81,8 +81,8 @@
<dl class="sp-facts">
<div><dt>Reporter</dt><dd>Outlook.com</dd></div>
<div><dt>Report id</dt><dd class="sp-mono sp-small">5f1c9a0e7b2d4e61a3</dd></div>
<div><dt>Window</dt><dd>20 Sep 00:00 – 21 Sep 00:00 UTC</dd></div>
<div><dt>Received</dt><dd>2026-09-21 04:40 UTC</dd></div>
<div><dt>Window</dt><dd>20 Sep 00:00 – 21 Sep 00:00</dd></div>
<div><dt>Received</dt><dd>2026-09-21 04:40</dd></div>
</dl>
</div>
</div>
@@ -112,7 +112,7 @@
</table></div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
+5 -5
View File
@@ -12,8 +12,8 @@
<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-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="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></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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current">
@@ -77,7 +77,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-body">
<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>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>
@@ -88,7 +88,7 @@
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Recent reports</h2></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Received, UTC</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
<thead><tr><th>Received</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
<tbody>
<tr><td class="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>
@@ -98,7 +98,7 @@
</table></div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
+25 -8
View File
@@ -12,8 +12,8 @@
<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-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="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></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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
@@ -69,7 +69,7 @@
<div class="sp-grow">
<p class="sp-kicker">Server</p>
<h1 class="title is-3">Health</h1>
<p class="sp-lead">Everything behind the six cards on Overview. Refreshes every 10 s.</p>
<p class="sp-lead">Everything behind the six cards on Overview. Refreshes automatically.</p>
</div>
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form>
</div>
@@ -108,6 +108,7 @@
<dl class="sp-facts">
<div><dt>Hostname</dt><dd class="sp-mono">mail.example.org</dd></div>
<div><dt>Lookup</dt><dd class="sp-mono">→ 203.0.113.25<br>← mail.example.org</dd></div>
<div class="sp-wide"><dt>Greeting</dt><dd class="sp-mono">220 mail.example.org ESMTP</dd></div>
</dl>
</div>
</div>
@@ -141,10 +142,10 @@
<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">
<dl class="sp-facts">
<div><dt>Expires</dt><dd class="sp-mono">2026-11-02 09:14 UTC</dd></div>
<div><dt>Names</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 mounted</dt><dd class="sp-mono">2026-10-15 03:12 UTC</dd></div>
<div><dt>Expires</dt><dd class="sp-mono">2026-11-02 09:14</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</dd></div>
<div><dt>Renewal mounted</dt><dd class="sp-mono">2026-10-15 03:12</dd></div>
</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>
</div>
@@ -165,14 +166,30 @@
<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-body">
<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>
</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>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
+17 -15
View File
@@ -12,8 +12,8 @@
<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-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="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></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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
@@ -80,6 +80,7 @@
<ul class="menu-list">
<li><a class="is-active" href="#checks"><i class="ti ti-activity-heartbeat"></i>Health checks</a></li>
<li><a href="#certificate"><i class="ti ti-certificate"></i>TLS certificate</a></li>
<li><a href="#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>
</ul>
<p class="menu-label">Outbound</p>
@@ -117,17 +118,18 @@
<div class="column is-9">
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Overview</h2></div>
<div class="sp-help-topic" id="checks"><h3>Health checks</h3><p>Six checks, and the worst of them is the postmark. <span class="tag is-success is-light">ok</span> needs nothing; <span class="tag is-warning is-light">warn</span> still delivers mail but will stop doing so if ignored; <span class="tag is-danger is-light">fail</span> means mail is being lost or refused now. <a href="health.html">Health</a> shows the readings behind them: CPU and memory are the container's own, not the host's spare capacity, and network is a short window, not a daily total.</p></div>
<div class="sp-help-topic" id="certificate"><h3>TLS certificate</h3><p>The certificate Postfix presents on 465 and 587. The reverse proxy or the image mounts it, the panel does not issue it. 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="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="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. 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="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 class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Outbound</h2></div>
<div class="sp-help-topic" id="dns"><h3>DNS records</h3><p>DKIM proves the message was signed by this relay, SPF lists the addresses allowed to send for the domain, DMARC tells receivers what to do when both fail and where to report. Publish all three exactly as shown; merge SPF into an existing record rather than adding a second one. A domain's status is the worst of its three records, and results are cached for a few minutes, so use Re-check DNS after publishing. The SPF check is shallow: it looks for this server's address in the record itself and does not follow <code>include:</code> or <code>redirect=</code>, so a record that relies on them is reported as a warning, not a failure. <code>p=none</code> does not affect delivery; tighten to <code>quarantine</code>, then <code>reject</code>, once the reports look clean. A domain's report address is set in its Domain settings.</p></div>
<div class="sp-help-topic" id="apps"><h3>Applications</h3><p>An application is one SASL login, unique across all domains. It may send as any address of its domain or only as listed addresses, and, with a list of client IPs set, only from those addresses. The password is shown once at creation or regeneration and is not stored in readable form.</p></div>
<div class="sp-help-topic" id="sender-check"><h3>Sender check</h3><p>An application sends only as itself, and that holds for both senders a message has. The envelope sender is held against the login by Postfix. The <code>From:</code> line, the one a mail program shows and the one 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. 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="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 the panel does not show it again; the server keeps it, so a domain export can carry it.</p></div>
<div class="sp-help-topic" id="sender-check"><h3>Sender check</h3><p>An application sends only as itself, and that holds for every sender a message names. The envelope sender is held against the login by Postfix. The <code>From:</code> line, the one a mail program shows and the one DMARC holds the DKIM signature to, is held against it by the panel, under the same rule: an application set to any address of its domain may write any address of that domain, and one with a list of addresses only those. A message with any other <code>From:</code> - another of your domains, the server's own name, an address somewhere else, or none at all - is refused for good with <code>550 5.7.1</code>. The line must hold one address, read as a mail program reads it: what a display name around it says is not judged, and a line holding more than one address is refused. A <code>Sender:</code> line, which a mail program shows beside <code>From:</code> as "on behalf of", is held to the same rule when the message has one. A system that writes for people outside your domains writes its own address in <code>From:</code> with the person's name, and the person's address in <code>Reply-To:</code>: their domain in <code>From:</code> would fail DMARC at the receiver anyway. It is a rejected row of the Outbound log, and its page says which address was refused. This is what keeps one application from sending, under this server's signature, as another domain or another application.<br><br>The check is strict. While the panel does not answer, Postfix does not accept mail unchecked: it replies with a temporary error and the application sends again later. Server › Health counts what that costs: for how long the panel did not answer while Postfix itself was running; how many connections Postfix turned away because of it - it refuses from the connection on, before a message is offered, so a client that tries three times is three and a monitor that only connects is one; and how many messages were refused with a temporary error in the middle, when the panel stopped answering after a message had begun. A time marked ≈ includes a start at which supervisord could not say since when Postfix was running: the time since the panel's last sign of life is counted, which may be more than Postfix was up. The card warns when a connection was turned away or a message refused in the last 24 hours. If the numbers stay at nothing, the check is where it should be; if they do not, it belongs in a process of its own.</p></div>
<div class="sp-help-topic" id="limits"><h3>Rate limits</h3><p>Level 1 is a per-IP ceiling set in <code>.env</code>. Level 2 is optional, per domain and per application, entered by hand or computed from the last 30 days of sending, and can never exceed level 1. An application's own limit replaces its domain's.</p></div>
<div class="sp-help-topic" id="log"><h3>Outbound log</h3><p>One row per message and recipient, with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out; or closed by the panel when Postfix no longer held the message and <code>mail.log</code> never said what became of it) and rejected (refused before queueing, so it never reached the queue: under a level-2 rate limit, from a client address the application does not allow, or for a <code>From:</code> line that is not the application's - the message's page says which). A message's page lists these steps and the lines Postfix wrote about it in <code>mail.log</code>, found by queue id - and for a message refused before it had one, Postfix's one line about the refusal. The lines are kept with the row: they stay on the page after that file has rotated, for as long as the row does. A message accepted before version 2.2.0 shows them only while they are still in the current file. Rows are deleted after the retention set in Server › Settings, 90 days by default, and their lines with them. A domain administrator sees only the rows of their own domains.</p></div>
<div class="sp-help-topic" id="log"><h3>Outbound log</h3><p>One row per message and recipient (a message refused for its From: or Sender: line is one row, however many recipients it had), with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out; or closed by the panel when Postfix no longer held the message and <code>mail.log</code> never said what became of it) and rejected (refused before queueing, so it never reached the queue: under a level-2 rate limit, from a client address the application does not allow, or for a <code>From:</code> or <code>Sender:</code> line that is not the application's - the message's page says which). A message's page lists these steps and the lines Postfix wrote about it in <code>mail.log</code>, found by queue id - and for a message refused before it had one, Postfix's one line about the refusal. The lines are kept with the row: they stay on the page after that file has rotated, for as long as the row does. A message accepted before version 2.2.0 shows them only while they are still in the current file. Rows are deleted after the retention set in Server › Settings, 90 days by default, and their lines with them. A domain administrator sees only the rows of their own domains.</p></div>
<div class="sp-help-topic" id="queue"><h3>Queue and retries</h3><p>The page lists, as <code>postqueue -p</code> prints it, the messages Postfix has accepted and not yet delivered: mail being sent right now and mail deferred for a later attempt. Only the global role can open it, and it only shows; nothing here deletes or forces a message. A deferred message is tried again after the first retry delay, then at gaps that double up to a cap, until the receiving server takes it or the queue lifetime runs out and it bounces; there is no attempt counter, and the box on the page shows this server's values. A queue that keeps growing means a receiver is refusing or not answering: open the message from the Outbound log to read the reason Postfix logged.</p></div>
<div class="sp-help-topic" id="dmarc"><h3>DMARC reports</h3><p>An aggregate report is the summary a receiving provider sends, usually daily, to the <code>rua=</code> address of your DMARC record: which servers sent mail as your domain and whether SPF or DKIM passed for it. A domain's report address is one of three choices, set in its Domain settings: no reports (the default for a new domain), the SelfPost hosted address (an address on this server whose reports are parsed and shown under DMARC reports; offered only while hosted reports are enabled on the server), or an address typed for the domain (an ordinary mailbox that receives the raw XML; a button fills in your own profile e-mail). When the typed address is on another domain than the one sending, that domain must publish a report-authorization record, or providers will not send; the domain's page shows it with the other DNS records and checks it. In the tables pass means SPF or DKIM passed and aligned, fail means neither did, and your own server shows as <em>this relay</em>. A fail from any other source is someone else sending as your domain — a forwarder, an application you forgot, or abuse — so do not tighten <code>p=</code> until that sender is fixed or removed. A domain administrator reads the reports of their own domains.</p></div>
</div>
@@ -137,15 +139,15 @@
<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="recipients"><h3>Valid recipients</h3><p>Choose whom this domain accepts. Listed addresses only: the relay accepts exactly the addresses you list, one per line or comma-separated, and refuses any other while the sender is still connected, so it never has to send a bounce for them. Any recipient: everything at the domain is accepted and passed on, and an address the upstream then rejects comes back as a bounce from this server to a sender who is often forged — backscatter. Prefer a list, and keep it in step with the mailboxes upstream.</p></div>
<div class="sp-help-topic" id="filter"><h3>Spam filter</h3><p>The spam filter belongs to the server, not to a domain. The server's administrator attaches it in the server's <code>.env</code>, and from then on every inbound domain is screened by it before mail is handed on; the box shows on or off and has nothing to switch. Off means mail goes to the upstream unfiltered. While the filter is down, mail is either accepted unfiltered (the default) or refused with a temporary error, so the sending server tries again later, as the server's setting says; the box states which.</p></div>
<div class="sp-help-topic" id="inbound-log"><h3>Inbound log</h3><p>One row per recipient of a message offered on port 25 for an inbound domain, with what was decided: <span class="tag is-success is-light">accept</span> — 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="filter-lists"><h3>Filter lists</h3><p>Two lists for the whole server that correct the spam filter by hand. An entry is a sender address, a sender domain — that domain, not its subdomains — or a client address or network in CIDR form. A sender on the Deny list is refused outright. A sender on the Allow list is given a weight no spam score outweighs, so the filter lets it through. The sender's address is whatever the sending server says it is - anyone can claim to be a sender you allow - so allow a client address or network where you can, and a sender address only for mail you would rather receive forged than lose. When entries of both lists match one message, <strong>Deny wins</strong>.<br><br>SelfPost does not apply the lists itself: it writes them as files under <code>/data/antispam/</code>, and rspamd rereads a file within seconds of a change. With the spam filter off the lists are kept and can be edited, but nothing reads them. The sender matched is the envelope sender — the one in the log — not the From line a mail program shows. Matched counts the messages in the Inbound log that the entry decided, so it reaches back as far as the log does; never means not once in that time, and such an entry is one to consider removing.</p></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div>
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys and application credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. The Postfix queue is not in it. The backup is taken while the server is running, and Postfix keeps moving messages through the queue, so a copy could hold a message twice or miss it; mail still waiting for delivery is therefore not carried over, and a restored server starts with an empty queue. To keep the queue, stop the container and archive the project directory instead. The private key in <code>certs/</code> has to be readable by the panel: give it to group 10998 on the host (<code>chgrp 10998</code>, <code>chmod 640</code>). Until then the panel refuses to make a backup and names the file, rather than hand over an archive without the key. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
<div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out of their other sessions.</p></div>
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. 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="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; every attempt, right or wrong, 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 10998 on the host (<code>chgrp 10998</code>, <code>chmod 640</code>). Until then the panel refuses to make a backup and names the file, rather than hand over an archive without the key. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
<div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out everywhere; set for yourself, it keeps the session you set it from.</p></div>
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. The time zone is the container's, <code>TZ</code> in <code>.env</code> (UTC when it is not set): every time the panel shows and every day it counts is that zone's, as are the stamps Postfix writes in <code>mail.log</code>, so the two always agree; changing it is a restart of the container. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. With the inbound relay on there is a second number, for the Inbound log, with the same range. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it. Notifications are turned on or off here for everyone.</p></div>
<div class="sp-help-topic" id="notifications"><h3>Notifications</h3><p>The panel writes to its users when something needs them, from <code>postmaster@</code> the server's hostname. Four things are reported. A sending domain's SPF, DKIM or DMARC record stops matching: sent once two hourly checks in a row have found it so, and once more when the record matches again. A level-2 rate limit refuses mail: sent at the first refusal, then at most once a day for the same 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 two DNS records of its own, as each of your domains does: a DKIM key, which the panel generates and signs notifications with, and an SPF record, <code>v=spf1 a -all</code>. Server › Settings gives both with the values to publish and whether they are in place, and its Re-check DNS checks them again once they are published; Server › Health shows them too. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains does not depend on either.</p></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">05</span><h2>Account</h2></div>
@@ -154,7 +156,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -12,8 +12,8 @@
<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-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="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></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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
@@ -94,7 +94,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
+10 -11
View File
@@ -12,8 +12,8 @@
<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-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="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></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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
@@ -101,8 +101,8 @@
<div class="column is-9">
<div class="box" id="dns">
<div class="sp-box-head"><span class="sp-no">01</span><h2>MX record</h2><div class="sp-end">Checked 1 min ago</div><a class="sp-help" href="help.html#inbound" title="Backup-MX and forwarding"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-record">
<div class="sp-record-head"><h3>MX</h3><span class="tag is-success is-light">published</span><small>Keep any existing primary MX if this is a backup-MX</small></div>
<details class="sp-record">
<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="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>
@@ -110,7 +110,7 @@
</div>
<div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="10 mail.example.org."></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
</div>
</details>
</div>
<div class="columns">
@@ -142,7 +142,7 @@
dev@lists.example.org
owner@lists.example.org
security@lists.example.org</textarea></div>
<p class="help">Unknown recipients are rejected at RCPT, so this relay generates no backscatter. Prefer a list unless the upstream itself rejects unknowns.</p></div>
<p class="help">One per line or comma-separated. Unknown recipients are rejected at RCPT, so this relay generates no backscatter. Prefer a list unless the upstream itself rejects unknowns.</p></div>
<button type="submit" class="button is-primary">Save recipients</button>
</form>
</div>
@@ -154,17 +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-body">
<dl class="sp-facts">
<div><dt>Engine</dt><dd>rspamd, for every inbound domain of this server</dd></div>
<div><dt>Suspicious mail</dt><dd>Held — <a href="in-quarantine.html">1 message in quarantine</a> for this domain</dd></div>
<div><dt>Spam</dt><dd>Refused at the door; see <em>reject</em> in the <a href="in-log.html">log</a></dd></div>
<div><dt>Allow and deny lists</dt><dd class="sp-muted">Kept by the global administrator, one pair for the whole server</dd></div>
<div><dt>Applies to</dt><dd>Every inbound domain of this server</dd></div>
<div><dt>Filter</dt><dd class="sp-mono">inet:antispam:11332</dd></div>
<div><dt>If the filter is down</dt><dd>Mail is accepted unfiltered</dd></div>
</dl>
</div>
</div>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -12,8 +12,8 @@
<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-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="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></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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
@@ -98,7 +98,7 @@
<div class="sp-box-foot">The MX tag is green when at least one MX of the domain points at this server.</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -12,8 +12,8 @@
<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-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="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></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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
@@ -132,13 +132,13 @@
<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>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>
</dl>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
+13 -12
View File
@@ -12,8 +12,8 @@
<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-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="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></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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
@@ -69,7 +69,7 @@
<div class="sp-grow">
<p class="sp-kicker">Inbound</p>
<h1 class="title is-3">Inbound log</h1>
<p class="sp-lead">What port 25 decided about each message, one row per recipient. 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>
@@ -86,20 +86,21 @@
<a class="sp-help" href="help.html#inbound-log" title="What the decisions mean"><i class="ti ti-help-circle"></i></a>
</div>
<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>
<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 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 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: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: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: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 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 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: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: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: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: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>
</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>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -12,8 +12,8 @@
<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-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="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></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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
@@ -81,7 +81,7 @@
<dl class="sp-facts">
<div><dt>Inbound domain</dt><dd><a href="in-domain.html">lists.example.org</a></dd></div>
<div><dt>Client</dt><dd class="sp-mono">203.0.113.140</dd></div>
<div><dt>Held</dt><dd>2026-09-21 13:59:02 UTC</dd></div>
<div><dt>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>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>
@@ -106,13 +106,14 @@
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Headers</h2><div class="sp-end">The body and attachments are never rendered in the panel</div></div>
<pre class="sp-log"><span class="sp-d">Received:</span> from mail.partner-billing.example (unknown [203.0.113.140]) by mail.example.org
<span class="sp-d">From:</span> "Partner Billing" &lt;invoice@partner-billing.example&gt;
<span class="sp-d">Reply-To:</span> <span class="sp-w">payments.dept@freemail.example</span>
<span class="sp-d">To:</span> owner@lists.example.org
<span class="sp-d">Subject:</span> Outstanding invoice — action required
<span class="sp-d">Date:</span> Mon, 21 Sep 2026 13:58:57 +0000
<span class="sp-d">Authentication-Results:</span> <span class="sp-e">spf=fail dkim=none dmarc=fail</span></pre>
<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><span class="sp-ll"><span class="sp-d">From:</span> "Partner Billing" &lt;invoice@partner-billing.example&gt;
</span><span class="sp-ll"><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">To:</span> owner@lists.example.org
</span><span class="sp-ll"><span class="sp-d">Subject:</span> Outstanding invoice — action required
</span><span class="sp-ll"><span class="sp-d">Date:</span> Mon, 21 Sep 2026 13:58:57 +0000
</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 class="columns">
@@ -142,7 +143,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -12,8 +12,8 @@
<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-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="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></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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
@@ -82,7 +82,7 @@
</form>
</div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Held, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Why</th><th>Size</th><th>Deleted in</th><th></th></tr></thead>
<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>
<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>
@@ -92,7 +92,7 @@
<div class="sp-box-foot"><span>3 messages · 3.0 MB of 500 MB</span><span class="sp-end">A domain administrator sees only their assigned inbound domains</span></div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -12,8 +12,8 @@
<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-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="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></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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
@@ -74,7 +74,7 @@
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Waiting</h2><div class="sp-end">Refreshes every 10 s</div></div>
<div class="sp-box-head"><span class="sp-no">01</span><h2>Waiting</h2><div class="sp-end">Refreshes on its own</div></div>
<div class="sp-empty"><i class="ti ti-stack-2"></i>Nothing is waiting. Every accepted message has reached its upstream.</div>
</div>
@@ -90,7 +90,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -23,7 +23,7 @@
<h1 class="title is-3">No administrator yet</h1>
<p class="sp-muted">Open the one-time setup link printed in the container log to create one.</p>
</div>
<p class="sp-foot">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
+1 -1
View File
@@ -31,7 +31,7 @@
</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>
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
@@ -12,8 +12,8 @@
<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-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="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></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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current">
@@ -100,7 +100,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -12,8 +12,8 @@
<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-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="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></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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current">
@@ -69,7 +69,7 @@
<div class="sp-grow">
<p class="sp-kicker"><a href="out-domains.html">Outbound domains</a> / <a href="out-domain.html">example.org</a> / Applications /</p>
<h1 class="title is-3 sp-mono">prod-server</h1>
<p class="sp-lead">1 102 messages in 30 days · peak 96 msg/h · average 1.5 msg/h. The same form, empty, adds a new application.</p>
<p class="sp-lead">1 102 messages in 30 days · peak 96 msg/h · average 1.5 msg/h.</p>
</div>
<div class="buttons">
<a class="button" href="out-app-created.html"><i class="ti ti-key"></i>New password</a>
@@ -85,7 +85,7 @@
<div class="sp-box-body">
<div class="field"><label class="label" for="login">Login</label>
<div class="control"><input class="input sp-mono" id="login" name="login" value="prod-server" autocomplete="off" spellcheck="false" required></div>
<p class="help">Unique across domains; letters, digits, <code>.</code> <code>-</code> <code>_</code>.</p></div>
<p class="help">Fixed: add a new application to use another login.</p></div>
<div class="field"><label class="label" for="amode">May send as</label>
<div class="control"><div class="select is-fullwidth"><select id="amode" name="mode"><option selected>Any address of the domain</option><option>Specific addresses (list)</option></select></div></div></div>
<div class="field"><label class="label" for="addresses">Addresses</label>
@@ -129,7 +129,7 @@
</div>
</form>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -12,8 +12,8 @@
<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-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="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></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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current">
@@ -103,7 +103,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -12,8 +12,8 @@
<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-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="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></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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current">
@@ -104,6 +104,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>
<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>
</form>
</div>
@@ -117,9 +119,9 @@
<form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="mode">Limit mode</label>
<div class="control"><div class="select is-fullwidth"><select id="mode" name="mode"><option>Manual</option><option selected>Auto (from statistics)</option></select></div></div></div>
<div class="field"><label class="label" for="mult">Multiplier (1.5–10, default 3)</label>
<div class="field"><label class="label" for="mult">Multiplier (1.5–5, default 2.5)</label>
<div class="control"><input class="input" id="mult" name="auto_multiplier" type="number" min="1.5" max="10" step="0.1" value="3"></div>
<p class="help">Computed limit: <strong>288</strong> messages / 3600 s. Peak was 96 msg/h. Last recalculated yesterday 03:00 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">
<button type="submit" class="button is-primary">Save limit</button>
<button type="submit" class="button" formaction="#recalc">Recalculate now</button>
@@ -140,7 +142,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -12,8 +12,8 @@
<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-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="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></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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current">
@@ -106,8 +106,8 @@
<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-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>
<details class="sp-record">
<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="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>
@@ -115,7 +115,7 @@
</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"><i class="ti ti-copy"></i>Copy</button></div></div></div>
</div>
</details>
<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>
@@ -131,8 +131,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>
</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: <a href="out-domain-settings.html">example.org@dmarc.mail.example.org</a></small></div>
<details class="sp-record">
<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="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>
@@ -141,7 +141,7 @@
<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>
<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 class="box" id="apps">
@@ -172,7 +172,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -12,8 +12,8 @@
<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-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="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></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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current">
@@ -100,7 +100,7 @@
<div class="sp-box-foot">Each tag is one check. Green — published and correct; amber — published but weak; red — missing or wrong.</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -12,8 +12,8 @@
<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-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="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></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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current">
@@ -87,7 +87,7 @@
<a class="sp-help" href="help.html#log" title="What the statuses mean"><i class="ti ti-help-circle"></i></a>
</div>
<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>
<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>
@@ -101,7 +101,7 @@
<div class="sp-box-foot"><span>Page 1 of 412</span><a href="#">Older →</a><span class="sp-end">New rows appear on their own</span></div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -12,8 +12,8 @@
<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-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="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></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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current">
@@ -81,8 +81,8 @@
<dl class="sp-facts">
<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>Accepted</dt><dd>2026-09-21 13:58:07 UTC</dd></div>
<div><dt>Status reported</dt><dd>2026-09-21 13:58:09 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</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>
</dl>
@@ -94,9 +94,9 @@
<div class="sp-box-head"><span class="sp-no">02</span><h2>History</h2><a class="sp-help" href="help.html#log" title="What the statuses mean"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body">
<ol class="sp-timeline">
<li><time>13:58:07 UTC</time><strong>Accepted</strong> from <span class="sp-mono">alerts</span> at 198.51.100.7</li>
<li class="sp-warn"><time>13:58:09 UTC</time><strong>Deferred</strong> — the receiving server asked to retry later</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><time>2026-09-21 13:58:07</time><strong>Accepted and queued</strong> — Postfix accepted the message over an authenticated submission and the journal-milter recorded it.</li>
<li class="sp-warn"><time>2026-09-21 13:58:09</time><strong>Deferred, will be retried</strong> — the receiving server could not take the message yet. Postfix retries: first after 5 minutes, then with increasing gaps up to 1 hour 7 minutes, for up to 5 days.</li>
<li class="sp-pending"><time>not yet</time>Still in the queue; the next attempt is Postfix's own, and after 5 days it is bounced.</li>
</ol>
</div>
</div>
@@ -105,13 +105,14 @@
<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>
<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
<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> 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>
<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><span class="sp-ll"><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/qmgr[2061]: 4XcB7k2Jm9z1: from=&lt;alerts@example.org&gt;, size=2214, nrcpt=1 (queue active)
</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>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -12,8 +12,8 @@
<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-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="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></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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current">
@@ -69,14 +69,14 @@
<div class="sp-grow">
<p class="sp-kicker">Outbound</p>
<h1 class="title is-3">Outbound queue</h1>
<p class="sp-lead">Messages accepted from applications and still waiting for the receiving server. Refreshes every 10 s.</p>
<p class="sp-lead">Messages accepted from applications and still waiting for the receiving server. Refreshes on its own.</p>
</div>
</div>
<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="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>
<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>
@@ -95,7 +95,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
+15 -13
View File
@@ -90,7 +90,8 @@
"box[Milter sockets]:table",
"box[TLS certificate]:body",
"box[Notification mail]:table+body",
"box[Configuration]:body"
"box[Configuration]:body",
"box[Test message]:body"
]
}
]
@@ -107,7 +108,7 @@
},
{
"is-9": [
"box[Overview]:help×3",
"box[Overview]:help×4",
"box[Outbound]:help×7",
"box[Inbound]:help×6",
"box[Server]:help×4",
@@ -385,26 +386,27 @@
"box[Send a test e-mail]:body"
],
"settings": [
"head",
"head+actions",
{
"form": [
"columns": [
{
"columns": [
"is-6": [
{
"is-6": [
"form": [
"box[Log retention]:body",
"box[Sending rate limits]:body"
]
},
{
"is-6": [
"box[Notifications]:body",
"box[Notification mail]:record×2"
"submit_row"
]
}
]
},
"submit_row"
{
"is-6": [
"box[Notification mail]:record×2",
"box[Sending rate limits]:body",
"box[Time zone]:body"
]
}
]
}
],
@@ -12,8 +12,8 @@
<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-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="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></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">
<div class="navbar-start">
<a class="navbar-item sp-current" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
@@ -72,7 +72,7 @@
<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>
</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 class="box">
@@ -115,7 +115,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
@@ -12,8 +12,8 @@
<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-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="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></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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
@@ -76,7 +76,7 @@
</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="sp-box-head"><span class="sp-no">01</span><h2>Checks</h2><div class="sp-end">Ran 14:02 · 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>
@@ -108,7 +108,7 @@
</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>Last test</dt><dd>14:03 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>
@@ -117,7 +117,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
+55 -42
View File
@@ -12,8 +12,8 @@
<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-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="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></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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
@@ -71,11 +71,14 @@
<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>
</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>
<form class="sp-form" method="post" action="#">
<div class="columns">
<div class="column is-6">
<div class="columns">
<div class="column is-6">
<form class="sp-form" method="post" action="#">
<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-body">
@@ -88,58 +91,68 @@
</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="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="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>
</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="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="column is-6">
<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">
<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.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"><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 class="sp-record">
<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="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"><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>
</details>
<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-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="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=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 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-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="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=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="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>.</p>
</div>
</div>
</div>
<div class="buttons"><button type="submit" class="button is-primary">Save settings</button></div>
</form>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
+1 -1
View File
@@ -36,7 +36,7 @@
</div>
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Create administrator</button></div>
</form>
<p class="sp-foot">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
@@ -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>
<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>
</form>
</div>
@@ -84,9 +84,9 @@
<div class="columns">
<div class="column is-7">
<div class="box">
<div class="sp-box-head"><span class="sp-no">05</span><h2>dns_record · copy_field</h2><div class="sp-end">One block per record</div></div>
<div class="sp-record">
<div class="sp-record-head"><h3>Record</h3><span class="tag is-success is-light">published</span><small>A one-line note</small></div>
<div class="sp-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>
<details class="sp-record">
<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="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>
@@ -94,7 +94,7 @@
</div>
<div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><textarea class="textarea" rows="2" readonly>copy_field — multi-line, for a DKIM key</textarea></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
</div>
</details>
<div class="sp-record">
<div class="sp-record-head"><h3>Record</h3><span class="tag is-danger is-light">mismatch</span><small>"In DNS now" appears only when it differs</small></div>
<div class="field"><label class="label">In DNS now</label>
@@ -106,7 +106,7 @@
<div class="box">
<div class="sp-box-head"><span class="sp-no">07</span><h2>Table conventions</h2><div class="sp-end">Always inside <code>table-container</code> · an address breaks after its @</div></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Time, UTC</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
<thead><tr><th>Time</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
<tbody>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td><a href="#"><strong>the row's link</strong></a></td><td class="sp-clip">A long subject is clipped with an ellipsis instead of wrapping the row</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="#">Details</a><form method="post" action="#"><button type="submit" class="has-text-danger">Delete</button></form></td></tr>
</tbody>
@@ -116,9 +116,10 @@
<div class="box">
<div class="sp-box-head"><span class="sp-no">09</span><h2>log_pane</h2><div class="sp-end">sp-d time · sp-w warning · sp-e error</div></div>
<pre class="sp-log"><span class="sp-d">13:58:07</span> an ordinary line
<span class="sp-d">13:58:09</span> <span class="sp-w">a deferred delivery — sp-w</span>
<span class="sp-d">13:57:41</span> <span class="sp-e">a reject or a bounce — sp-e</span></pre>
<pre class="sp-log"><span class="sp-ll"><span class="sp-d">13:58:07</span> an ordinary line
</span><span class="sp-ll"><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:57:41</span> <span class="sp-e">a reject or a bounce — sp-e</span>
</span></pre>
</div>
</div>
@@ -129,7 +130,9 @@
<dl class="sp-facts">
<div><dt>Label</dt><dd>Value</dd></div>
<div><dt>Mono value</dt><dd class="sp-mono">4XcB7k2Jm9z1</dd></div>
<div><dt>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 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>
</div>
</div>
@@ -138,9 +141,9 @@
<div class="sp-box-head"><span class="sp-no">08</span><h2>timeline</h2></div>
<div class="sp-box-body">
<ol class="sp-timeline">
<li><time>13:58:07 UTC</time><strong>Done</strong> — default</li>
<li class="sp-warn"><time>13:58:09 UTC</time><strong>Deferred</strong> — sp-warn</li>
<li class="sp-fail"><time>14:40:00 UTC</time><strong>Bounced</strong> — sp-fail</li>
<li><time>13:58:07</time><strong>Done</strong> — default</li>
<li class="sp-warn"><time>13:58:09</time><strong>Deferred</strong> — sp-warn</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>
</ol>
</div>
@@ -26,7 +26,7 @@
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Reports</h2></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Received, UTC</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
<thead><tr><th>Received</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
<tbody>
<tr><td class="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>
@@ -2,7 +2,7 @@
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker"><a href="dmarc.html">DMARC reports</a> / <a href="dmarc-domain.html">shop.example.org</a> /</p>
<h1 class="title is-4">Outlook.com · 20 Sep 2026</h1>
<h1 class="title is-4">Outlook.com · 20 Sep</h1>
<p class="sp-route"><span>shop.example.org</span><span class="tag is-success is-light">1 903 pass</span><span class="tag is-warning is-light">7 fail</span></p>
</div>
</div>
@@ -15,8 +15,8 @@
<dl class="sp-facts">
<div><dt>Reporter</dt><dd>Outlook.com</dd></div>
<div><dt>Report id</dt><dd class="sp-mono sp-small">5f1c9a0e7b2d4e61a3</dd></div>
<div><dt>Window</dt><dd>20 Sep 00:00 – 21 Sep 00:00 UTC</dd></div>
<div><dt>Received</dt><dd>2026-09-21 04:40 UTC</dd></div>
<div><dt>Window</dt><dd>20 Sep 00:00 – 21 Sep 00:00</dd></div>
<div><dt>Received</dt><dd>2026-09-21 04:40</dd></div>
</dl>
</div>
</div>
@@ -11,7 +11,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-body">
<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>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>
@@ -22,7 +22,7 @@
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Recent reports</h2></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Received, UTC</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
<thead><tr><th>Received</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
<tbody>
<tr><td class="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>
@@ -3,7 +3,7 @@
<div class="sp-grow">
<p class="sp-kicker">Server</p>
<h1 class="title is-3">Health</h1>
<p class="sp-lead">Everything behind the six cards on Overview. Refreshes every 10 s.</p>
<p class="sp-lead">Everything behind the six cards on Overview. Refreshes automatically.</p>
</div>
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form>
</div>
@@ -42,6 +42,7 @@
<dl class="sp-facts">
<div><dt>Hostname</dt><dd class="sp-mono">mail.example.org</dd></div>
<div><dt>Lookup</dt><dd class="sp-mono">→ 203.0.113.25<br>← mail.example.org</dd></div>
<div class="sp-wide"><dt>Greeting</dt><dd class="sp-mono">220 mail.example.org ESMTP</dd></div>
</dl>
</div>
</div>
@@ -75,10 +76,10 @@
<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">
<dl class="sp-facts">
<div><dt>Expires</dt><dd class="sp-mono">2026-11-02 09:14 UTC</dd></div>
<div><dt>Names</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 mounted</dt><dd class="sp-mono">2026-10-15 03:12 UTC</dd></div>
<div><dt>Expires</dt><dd class="sp-mono">2026-11-02 09:14</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</dd></div>
<div><dt>Renewal mounted</dt><dd class="sp-mono">2026-10-15 03:12</dd></div>
</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>
</div>
@@ -99,9 +100,25 @@
<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-body">
<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>
</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>
+14 -12
View File
@@ -14,6 +14,7 @@
<ul class="menu-list">
<li><a class="is-active" href="#checks"><i class="ti ti-activity-heartbeat"></i>Health checks</a></li>
<li><a href="#certificate"><i class="ti ti-certificate"></i>TLS certificate</a></li>
<li><a href="#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>
</ul>
<p class="menu-label">Outbound</p>
@@ -51,17 +52,18 @@
<div class="column is-9">
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Overview</h2></div>
<div class="sp-help-topic" id="checks"><h3>Health checks</h3><p>Six checks, and the worst of them is the postmark. <span class="tag is-success is-light">ok</span> needs nothing; <span class="tag is-warning is-light">warn</span> still delivers mail but will stop doing so if ignored; <span class="tag is-danger is-light">fail</span> means mail is being lost or refused now. <a href="health.html">Health</a> shows the readings behind them: CPU and memory are the container's own, not the host's spare capacity, and network is a short window, not a daily total.</p></div>
<div class="sp-help-topic" id="certificate"><h3>TLS certificate</h3><p>The certificate Postfix presents on 465 and 587. The reverse proxy or the image mounts it, the panel does not issue it. 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="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="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. 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="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 class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Outbound</h2></div>
<div class="sp-help-topic" id="dns"><h3>DNS records</h3><p>DKIM proves the message was signed by this relay, SPF lists the addresses allowed to send for the domain, DMARC tells receivers what to do when both fail and where to report. Publish all three exactly as shown; merge SPF into an existing record rather than adding a second one. A domain's status is the worst of its three records, and results are cached for a few minutes, so use Re-check DNS after publishing. The SPF check is shallow: it looks for this server's address in the record itself and does not follow <code>include:</code> or <code>redirect=</code>, so a record that relies on them is reported as a warning, not a failure. <code>p=none</code> does not affect delivery; tighten to <code>quarantine</code>, then <code>reject</code>, once the reports look clean. A domain's report address is set in its Domain settings.</p></div>
<div class="sp-help-topic" id="apps"><h3>Applications</h3><p>An application is one SASL login, unique across all domains. It may send as any address of its domain or only as listed addresses, and, with a list of client IPs set, only from those addresses. The password is shown once at creation or regeneration and is not stored in readable form.</p></div>
<div class="sp-help-topic" id="sender-check"><h3>Sender check</h3><p>An application sends only as itself, and that holds for both senders a message has. The envelope sender is held against the login by Postfix. The <code>From:</code> line, the one a mail program shows and the one 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. 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="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 the panel does not show it again; the server keeps it, so a domain export can carry it.</p></div>
<div class="sp-help-topic" id="sender-check"><h3>Sender check</h3><p>An application sends only as itself, and that holds for every sender a message names. The envelope sender is held against the login by Postfix. The <code>From:</code> line, the one a mail program shows and the one DMARC holds the DKIM signature to, is held against it by the panel, under the same rule: an application set to any address of its domain may write any address of that domain, and one with a list of addresses only those. A message with any other <code>From:</code> - another of your domains, the server's own name, an address somewhere else, or none at all - is refused for good with <code>550 5.7.1</code>. The line must hold one address, read as a mail program reads it: what a display name around it says is not judged, and a line holding more than one address is refused. A <code>Sender:</code> line, which a mail program shows beside <code>From:</code> as "on behalf of", is held to the same rule when the message has one. A system that writes for people outside your domains writes its own address in <code>From:</code> with the person's name, and the person's address in <code>Reply-To:</code>: their domain in <code>From:</code> would fail DMARC at the receiver anyway. It is a rejected row of the Outbound log, and its page says which address was refused. This is what keeps one application from sending, under this server's signature, as another domain or another application.<br><br>The check is strict. While the panel does not answer, Postfix does not accept mail unchecked: it replies with a temporary error and the application sends again later. Server › Health counts what that costs: for how long the panel did not answer while Postfix itself was running; how many connections Postfix turned away because of it - it refuses from the connection on, before a message is offered, so a client that tries three times is three and a monitor that only connects is one; and how many messages were refused with a temporary error in the middle, when the panel stopped answering after a message had begun. A time marked ≈ includes a start at which supervisord could not say since when Postfix was running: the time since the panel's last sign of life is counted, which may be more than Postfix was up. The card warns when a connection was turned away or a message refused in the last 24 hours. If the numbers stay at nothing, the check is where it should be; if they do not, it belongs in a process of its own.</p></div>
<div class="sp-help-topic" id="limits"><h3>Rate limits</h3><p>Level 1 is a per-IP ceiling set in <code>.env</code>. Level 2 is optional, per domain and per application, entered by hand or computed from the last 30 days of sending, and can never exceed level 1. An application's own limit replaces its domain's.</p></div>
<div class="sp-help-topic" id="log"><h3>Outbound log</h3><p>One row per message and recipient, with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out; or closed by the panel when Postfix no longer held the message and <code>mail.log</code> never said what became of it) and rejected (refused before queueing, so it never reached the queue: under a level-2 rate limit, from a client address the application does not allow, or for a <code>From:</code> line that is not the application's - the message's page says which). A message's page lists these steps and the lines Postfix wrote about it in <code>mail.log</code>, found by queue id - and for a message refused before it had one, Postfix's one line about the refusal. The lines are kept with the row: they stay on the page after that file has rotated, for as long as the row does. A message accepted before version 2.2.0 shows them only while they are still in the current file. Rows are deleted after the retention set in Server › Settings, 90 days by default, and their lines with them. A domain administrator sees only the rows of their own domains.</p></div>
<div class="sp-help-topic" id="log"><h3>Outbound log</h3><p>One row per message and recipient (a message refused for its From: or Sender: line is one row, however many recipients it had), with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out; or closed by the panel when Postfix no longer held the message and <code>mail.log</code> never said what became of it) and rejected (refused before queueing, so it never reached the queue: under a level-2 rate limit, from a client address the application does not allow, or for a <code>From:</code> or <code>Sender:</code> line that is not the application's - the message's page says which). A message's page lists these steps and the lines Postfix wrote about it in <code>mail.log</code>, found by queue id - and for a message refused before it had one, Postfix's one line about the refusal. The lines are kept with the row: they stay on the page after that file has rotated, for as long as the row does. A message accepted before version 2.2.0 shows them only while they are still in the current file. Rows are deleted after the retention set in Server › Settings, 90 days by default, and their lines with them. A domain administrator sees only the rows of their own domains.</p></div>
<div class="sp-help-topic" id="queue"><h3>Queue and retries</h3><p>The page lists, as <code>postqueue -p</code> prints it, the messages Postfix has accepted and not yet delivered: mail being sent right now and mail deferred for a later attempt. Only the global role can open it, and it only shows; nothing here deletes or forces a message. A deferred message is tried again after the first retry delay, then at gaps that double up to a cap, until the receiving server takes it or the queue lifetime runs out and it bounces; there is no attempt counter, and the box on the page shows this server's values. A queue that keeps growing means a receiver is refusing or not answering: open the message from the Outbound log to read the reason Postfix logged.</p></div>
<div class="sp-help-topic" id="dmarc"><h3>DMARC reports</h3><p>An aggregate report is the summary a receiving provider sends, usually daily, to the <code>rua=</code> address of your DMARC record: which servers sent mail as your domain and whether SPF or DKIM passed for it. A domain's report address is one of three choices, set in its Domain settings: no reports (the default for a new domain), the SelfPost hosted address (an address on this server whose reports are parsed and shown under DMARC reports; offered only while hosted reports are enabled on the server), or an address typed for the domain (an ordinary mailbox that receives the raw XML; a button fills in your own profile e-mail). When the typed address is on another domain than the one sending, that domain must publish a report-authorization record, or providers will not send; the domain's page shows it with the other DNS records and checks it. In the tables pass means SPF or DKIM passed and aligned, fail means neither did, and your own server shows as <em>this relay</em>. A fail from any other source is someone else sending as your domain — a forwarder, an application you forgot, or abuse — so do not tighten <code>p=</code> until that sender is fixed or removed. A domain administrator reads the reports of their own domains.</p></div>
</div>
@@ -71,15 +73,15 @@
<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="recipients"><h3>Valid recipients</h3><p>Choose whom this domain accepts. Listed addresses only: the relay accepts exactly the addresses you list, one per line or comma-separated, and refuses any other while the sender is still connected, so it never has to send a bounce for them. Any recipient: everything at the domain is accepted and passed on, and an address the upstream then rejects comes back as a bounce from this server to a sender who is often forged — backscatter. Prefer a list, and keep it in step with the mailboxes upstream.</p></div>
<div class="sp-help-topic" id="filter"><h3>Spam filter</h3><p>The spam filter belongs to the server, not to a domain. The server's administrator attaches it in the server's <code>.env</code>, and from then on every inbound domain is screened by it before mail is handed on; the box shows on or off and has nothing to switch. Off means mail goes to the upstream unfiltered. While the filter is down, mail is either accepted unfiltered (the default) or refused with a temporary error, so the sending server tries again later, as the server's setting says; the box states which.</p></div>
<div class="sp-help-topic" id="inbound-log"><h3>Inbound log</h3><p>One row per recipient of a message offered on port 25 for an inbound domain, with what was decided: <span class="tag is-success is-light">accept</span> — 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="filter-lists"><h3>Filter lists</h3><p>Two lists for the whole server that correct the spam filter by hand. An entry is a sender address, a sender domain — that domain, not its subdomains — or a client address or network in CIDR form. A sender on the Deny list is refused outright. A sender on the Allow list is given a weight no spam score outweighs, so the filter lets it through. The sender's address is whatever the sending server says it is - anyone can claim to be a sender you allow - so allow a client address or network where you can, and a sender address only for mail you would rather receive forged than lose. When entries of both lists match one message, <strong>Deny wins</strong>.<br><br>SelfPost does not apply the lists itself: it writes them as files under <code>/data/antispam/</code>, and rspamd rereads a file within seconds of a change. With the spam filter off the lists are kept and can be edited, but nothing reads them. The sender matched is the envelope sender — the one in the log — not the From line a mail program shows. Matched counts the messages in the Inbound log that the entry decided, so it reaches back as far as the log does; never means not once in that time, and such an entry is one to consider removing.</p></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div>
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys and application credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. The Postfix queue is not in it. The backup is taken while the server is running, and Postfix keeps moving messages through the queue, so a copy could hold a message twice or miss it; mail still waiting for delivery is therefore not carried over, and a restored server starts with an empty queue. To keep the queue, stop the container and archive the project directory instead. The private key in <code>certs/</code> has to be readable by the panel: give it to group 10998 on the host (<code>chgrp 10998</code>, <code>chmod 640</code>). Until then the panel refuses to make a backup and names the file, rather than hand over an archive without the key. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
<div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out of their other sessions.</p></div>
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. 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="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; every attempt, right or wrong, 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 10998 on the host (<code>chgrp 10998</code>, <code>chmod 640</code>). Until then the panel refuses to make a backup and names the file, rather than hand over an archive without the key. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
<div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out everywhere; set for yourself, it keeps the session you set it from.</p></div>
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. The time zone is the container's, <code>TZ</code> in <code>.env</code> (UTC when it is not set): every time the panel shows and every day it counts is that zone's, as are the stamps Postfix writes in <code>mail.log</code>, so the two always agree; changing it is a restart of the container. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. With the inbound relay on there is a second number, for the Inbound log, with the same range. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it. Notifications are turned on or off here for everyone.</p></div>
<div class="sp-help-topic" id="notifications"><h3>Notifications</h3><p>The panel writes to its users when something needs them, from <code>postmaster@</code> the server's hostname. Four things are reported. A sending domain's SPF, DKIM or DMARC record stops matching: sent once two hourly checks in a row have found it so, and once more when the record matches again. A level-2 rate limit refuses mail: sent at the first refusal, then at most once a day for the same 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 two DNS records of its own, as each of your domains does: a DKIM key, which the panel generates and signs notifications with, and an SPF record, <code>v=spf1 a -all</code>. Server › Settings gives both with the values to publish and whether they are in place, and its Re-check DNS checks them again once they are published; Server › Health shows them too. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains does not depend on either.</p></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">05</span><h2>Account</h2></div>
@@ -35,8 +35,8 @@
<div class="column is-9">
<div class="box" id="dns">
<div class="sp-box-head"><span class="sp-no">01</span><h2>MX record</h2><div class="sp-end">Checked 1 min ago</div><a class="sp-help" href="help.html#inbound" title="Backup-MX and forwarding"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-record">
<div class="sp-record-head"><h3>MX</h3><span class="tag is-success is-light">published</span><small>Keep any existing primary MX if this is a backup-MX</small></div>
<details class="sp-record">
<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="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>
@@ -44,7 +44,7 @@
</div>
<div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="10 mail.example.org."></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
</div>
</details>
</div>
<div class="columns">
@@ -76,7 +76,7 @@
dev@lists.example.org
owner@lists.example.org
security@lists.example.org</textarea></div>
<p class="help">Unknown recipients are rejected at RCPT, so this relay generates no backscatter. Prefer a list unless the upstream itself rejects unknowns.</p></div>
<p class="help">One per line or comma-separated. Unknown recipients are rejected at RCPT, so this relay generates no backscatter. Prefer a list unless the upstream itself rejects unknowns.</p></div>
<button type="submit" class="button is-primary">Save recipients</button>
</form>
</div>
@@ -88,10 +88,9 @@ security@lists.example.org</textarea></div>
<div class="sp-box-head"><span class="sp-no">04</span><h2>Spam filter</h2><div class="sp-end"><span class="tag is-success is-light">on</span></div><a class="sp-help" href="help.html#filter" title="A server setting"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Engine</dt><dd>rspamd, for every inbound domain of this server</dd></div>
<div><dt>Suspicious mail</dt><dd>Held — <a href="in-quarantine.html">1 message in quarantine</a> for this domain</dd></div>
<div><dt>Spam</dt><dd>Refused at the door; see <em>reject</em> in the <a href="in-log.html">log</a></dd></div>
<div><dt>Allow and deny lists</dt><dd class="sp-muted">Kept by the global administrator, one pair for the whole server</dd></div>
<div><dt>Applies to</dt><dd>Every inbound domain of this server</dd></div>
<div><dt>Filter</dt><dd class="sp-mono">inet:antispam:11332</dd></div>
<div><dt>If the filter is down</dt><dd>Mail is accepted unfiltered</dd></div>
</dl>
</div>
</div>
@@ -66,7 +66,7 @@
<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>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>
</dl>
</div>
@@ -3,7 +3,7 @@
<div class="sp-grow">
<p class="sp-kicker">Inbound</p>
<h1 class="title is-3">Inbound log</h1>
<p class="sp-lead">What port 25 decided about each message, one row per recipient. 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>
@@ -20,15 +20,16 @@
<a class="sp-help" href="help.html#inbound-log" title="What the decisions mean"><i class="ti ti-help-circle"></i></a>
</div>
<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>
<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 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 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: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: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: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 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 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: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: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: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: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>
</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>
@@ -15,7 +15,7 @@
<dl class="sp-facts">
<div><dt>Inbound domain</dt><dd><a href="in-domain.html">lists.example.org</a></dd></div>
<div><dt>Client</dt><dd class="sp-mono">203.0.113.140</dd></div>
<div><dt>Held</dt><dd>2026-09-21 13:59:02 UTC</dd></div>
<div><dt>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>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>
@@ -40,13 +40,14 @@
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Headers</h2><div class="sp-end">The body and attachments are never rendered in the panel</div></div>
<pre class="sp-log"><span class="sp-d">Received:</span> from mail.partner-billing.example (unknown [203.0.113.140]) by mail.example.org
<span class="sp-d">From:</span> "Partner Billing" &lt;invoice@partner-billing.example&gt;
<span class="sp-d">Reply-To:</span> <span class="sp-w">payments.dept@freemail.example</span>
<span class="sp-d">To:</span> owner@lists.example.org
<span class="sp-d">Subject:</span> Outstanding invoice — action required
<span class="sp-d">Date:</span> Mon, 21 Sep 2026 13:58:57 +0000
<span class="sp-d">Authentication-Results:</span> <span class="sp-e">spf=fail dkim=none dmarc=fail</span></pre>
<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><span class="sp-ll"><span class="sp-d">From:</span> "Partner Billing" &lt;invoice@partner-billing.example&gt;
</span><span class="sp-ll"><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">To:</span> owner@lists.example.org
</span><span class="sp-ll"><span class="sp-d">Subject:</span> Outstanding invoice — action required
</span><span class="sp-ll"><span class="sp-d">Date:</span> Mon, 21 Sep 2026 13:58:57 +0000
</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 class="columns">
@@ -16,7 +16,7 @@
</form>
</div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Held, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Why</th><th>Size</th><th>Deleted in</th><th></th></tr></thead>
<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>
<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>
@@ -8,7 +8,7 @@
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Waiting</h2><div class="sp-end">Refreshes every 10 s</div></div>
<div class="sp-box-head"><span class="sp-no">01</span><h2>Waiting</h2><div class="sp-end">Refreshes on its own</div></div>
<div class="sp-empty"><i class="ti ti-stack-2"></i>Nothing is waiting. Every accepted message has reached its upstream.</div>
</div>
@@ -12,6 +12,6 @@
<h1 class="title is-3">No administrator yet</h1>
<p class="sp-muted">Open the one-time setup link printed in the container log to create one.</p>
</div>
<p class="sp-foot">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
@@ -20,6 +20,6 @@
</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>
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
@@ -3,7 +3,7 @@
<div class="sp-grow">
<p class="sp-kicker"><a href="out-domains.html">Outbound domains</a> / <a href="out-domain.html">example.org</a> / Applications /</p>
<h1 class="title is-3 sp-mono">prod-server</h1>
<p class="sp-lead">1 102 messages in 30 days · peak 96 msg/h · average 1.5 msg/h. The same form, empty, adds a new application.</p>
<p class="sp-lead">1 102 messages in 30 days · peak 96 msg/h · average 1.5 msg/h.</p>
</div>
<div class="buttons">
<a class="button" href="out-app-created.html"><i class="ti ti-key"></i>New password</a>
@@ -19,7 +19,7 @@
<div class="sp-box-body">
<div class="field"><label class="label" for="login">Login</label>
<div class="control"><input class="input sp-mono" id="login" name="login" value="prod-server" autocomplete="off" spellcheck="false" required></div>
<p class="help">Unique across domains; letters, digits, <code>.</code> <code>-</code> <code>_</code>.</p></div>
<p class="help">Fixed: add a new application to use another login.</p></div>
<div class="field"><label class="label" for="amode">May send as</label>
<div class="control"><div class="select is-fullwidth"><select id="amode" name="mode"><option selected>Any address of the domain</option><option>Specific addresses (list)</option></select></div></div></div>
<div class="field"><label class="label" for="addresses">Addresses</label>
@@ -38,6 +38,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>
<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>
</form>
</div>
@@ -51,9 +53,9 @@
<form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="mode">Limit mode</label>
<div class="control"><div class="select is-fullwidth"><select id="mode" name="mode"><option>Manual</option><option selected>Auto (from statistics)</option></select></div></div></div>
<div class="field"><label class="label" for="mult">Multiplier (1.5–10, default 3)</label>
<div class="field"><label class="label" for="mult">Multiplier (1.5–5, default 2.5)</label>
<div class="control"><input class="input" id="mult" name="auto_multiplier" type="number" min="1.5" max="10" step="0.1" value="3"></div>
<p class="help">Computed limit: <strong>288</strong> messages / 3600 s. Peak was 96 msg/h. Last recalculated yesterday 03:00 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">
<button type="submit" class="button is-primary">Save limit</button>
<button type="submit" class="button" formaction="#recalc">Recalculate now</button>
@@ -40,8 +40,8 @@
<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-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>
<details class="sp-record">
<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="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>
@@ -49,7 +49,7 @@
</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"><i class="ti ti-copy"></i>Copy</button></div></div></div>
</div>
</details>
<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>
@@ -65,8 +65,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>
</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: <a href="out-domain-settings.html">example.org@dmarc.mail.example.org</a></small></div>
<details class="sp-record">
<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="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>
@@ -75,7 +75,7 @@
<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>
<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 class="box" id="apps">
@@ -21,7 +21,7 @@
<a class="sp-help" href="help.html#log" title="What the statuses mean"><i class="ti ti-help-circle"></i></a>
</div>
<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>
<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>
@@ -15,8 +15,8 @@
<dl class="sp-facts">
<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>Accepted</dt><dd>2026-09-21 13:58:07 UTC</dd></div>
<div><dt>Status reported</dt><dd>2026-09-21 13:58:09 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</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>
</dl>
@@ -28,9 +28,9 @@
<div class="sp-box-head"><span class="sp-no">02</span><h2>History</h2><a class="sp-help" href="help.html#log" title="What the statuses mean"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body">
<ol class="sp-timeline">
<li><time>13:58:07 UTC</time><strong>Accepted</strong> from <span class="sp-mono">alerts</span> at 198.51.100.7</li>
<li class="sp-warn"><time>13:58:09 UTC</time><strong>Deferred</strong> — the receiving server asked to retry later</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><time>2026-09-21 13:58:07</time><strong>Accepted and queued</strong> — Postfix accepted the message over an authenticated submission and the journal-milter recorded it.</li>
<li class="sp-warn"><time>2026-09-21 13:58:09</time><strong>Deferred, will be retried</strong> — the receiving server could not take the message yet. Postfix retries: first after 5 minutes, then with increasing gaps up to 1 hour 7 minutes, for up to 5 days.</li>
<li class="sp-pending"><time>not yet</time>Still in the queue; the next attempt is Postfix's own, and after 5 days it is bounced.</li>
</ol>
</div>
</div>
@@ -39,8 +39,9 @@
<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>
<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
<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> 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>
<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><span class="sp-ll"><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/qmgr[2061]: 4XcB7k2Jm9z1: from=&lt;alerts@example.org&gt;, size=2214, nrcpt=1 (queue active)
</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>
@@ -3,14 +3,14 @@
<div class="sp-grow">
<p class="sp-kicker">Outbound</p>
<h1 class="title is-3">Outbound queue</h1>
<p class="sp-lead">Messages accepted from applications and still waiting for the receiving server. Refreshes every 10 s.</p>
<p class="sp-lead">Messages accepted from applications and still waiting for the receiving server. Refreshes on its own.</p>
</div>
</div>
<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="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>
<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>
@@ -6,7 +6,7 @@
<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>
</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 class="box">
@@ -10,7 +10,7 @@
</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="sp-box-head"><span class="sp-no">01</span><h2>Checks</h2><div class="sp-end">Ran 14:02 · 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>
@@ -42,7 +42,7 @@
</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>Last test</dt><dd>14:03 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>
@@ -5,11 +5,14 @@
<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>
</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>
<form class="sp-form" method="post" action="#">
<div class="columns">
<div class="column is-6">
<div class="columns">
<div class="column is-6">
<form class="sp-form" method="post" action="#">
<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-body">
@@ -22,53 +25,63 @@
</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="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="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>
</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="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="column is-6">
<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">
<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.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"><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 class="sp-record">
<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="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"><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>
</details>
<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-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="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=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 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-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="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=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="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>.</p>
</div>
</div>
</div>
<div class="buttons"><button type="submit" class="button is-primary">Save settings</button></div>
</form>
</div>
@@ -25,6 +25,6 @@
</div>
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Create administrator</button></div>
</form>
<p class="sp-foot">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
@@ -3,19 +3,20 @@
<div class="sp-grow">
<p class="sp-kicker">Server</p>
<h1 class="title is-3">System log</h1>
<p class="sp-lead">The tail of <code>mail.log</code>, the log Postfix writes. For one message, open it from a log page instead.</p>
<p class="sp-lead">The tail of <code>mail.log</code>, the log Postfix writes. For one message, open it from the <a href="out-log.html">Outbound log</a> instead.</p>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Recent lines</h2><div class="sp-end">Last 200 · refreshes every 10 s</div></div>
<pre class="sp-log"><span class="sp-d">Sep 21 14:01:52</span> postfix/smtp[2131]: 4XcB9q0Lw2z1: to=&lt;m.keller@gmx.de&gt;, relay=mx00.gmx.net[212.227.15.9]:25, delay=0.8, status=sent (250 Requested mail action okay)
<span class="sp-d">Sep 21 14:01:52</span> postfix/qmgr[58]: 4XcB9q0Lw2z1: removed
<span class="sp-d">Sep 21 14:00:18</span> postfix/smtpd[2129]: connect from mail.uni.example[192.0.2.77]
<span class="sp-d">Sep 21 14:00:18</span> postfix/smtp[2130]: 4XcB8z5Tn1z1: to=&lt;dev@lists.example.org&gt;, relay=mx.internal[10.0.4.2]:25, status=sent (250 2.0.0 Ok: queued)
<span class="sp-d">Sep 21 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, status=deferred (451 4.7.1 Greylisted, try again in 5 minutes)</span>
<span class="sp-d">Sep 21 13:57:41</span> <span class="sp-e">postfix/smtpd[2119]: NOQUEUE: reject: RCPT from bulk.example[198.51.100.90]: 550 5.1.1 &lt;sales@lists.example.org&gt;: Recipient address rejected: unknown recipient</span>
<span class="sp-d">Sep 21 13:44:02</span> <span class="sp-w">postfix/smtp[2101]: 4XcB2m7Hd4z1: to=&lt;jobs@acme.io&gt;, relay=none, status=deferred (connect to 10.0.4.12[10.0.4.12]:2525: Connection timed out)</span>
<span class="sp-d">Sep 21 13:41:19</span> <span class="sp-e">postfix/smtp[2098]: 4XcB1c3Rk8z1: to=&lt;j.doe@nonexistent.example&gt;, status=bounced (Host or domain name not found)</span>
<span class="sp-d">Sep 21 13:40:02</span> <span class="sp-w">journal-milter[40]: rate limit: app=prod-server 201/200 in 3600s — tempfail at MAIL FROM</span></pre>
<div class="sp-box-head"><span class="sp-no">01</span><h2>Recent lines</h2><div class="sp-end">Last 200 lines · refreshes on its own</div></div>
<pre class="sp-log"><span class="sp-ll"><span class="sp-d">Sep 21 14:01:52</span> postfix/smtp[2131]: 4XcB9q0Lw2z1: to=&lt;m.keller@gmx.de&gt;, relay=mx00.gmx.net[212.227.15.9]:25, delay=0.8, status=sent (250 Requested mail action okay)
</span><span class="sp-ll"><span class="sp-d">Sep 21 14:01:52</span> postfix/qmgr[58]: 4XcB9q0Lw2z1: removed
</span><span class="sp-ll"><span class="sp-d">Sep 21 14:00:18</span> postfix/smtpd[2129]: connect from mail.uni.example[192.0.2.77]
</span><span class="sp-ll"><span class="sp-d">Sep 21 14:00:18</span> postfix/smtp[2130]: 4XcB8z5Tn1z1: to=&lt;dev@lists.example.org&gt;, relay=mx.internal[10.0.4.2]:25, status=sent (250 2.0.0 Ok: queued)
</span><span class="sp-ll"><span class="sp-d">Sep 21 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, status=deferred (451 4.7.1 Greylisted, try again in 5 minutes)</span>
</span><span class="sp-ll"><span class="sp-d">Sep 21 13:57:41</span> <span class="sp-e">postfix/smtpd[2119]: NOQUEUE: reject: RCPT from bulk.example[198.51.100.90]: 550 5.1.1 &lt;sales@lists.example.org&gt;: Recipient address rejected: unknown recipient</span>
</span><span class="sp-ll"><span class="sp-d">Sep 21 13:44:02</span> <span class="sp-w">postfix/smtp[2101]: 4XcB2m7Hd4z1: to=&lt;jobs@acme.io&gt;, relay=none, status=deferred (connect to 10.0.4.12[10.0.4.12]:2525: Connection timed out)</span>
</span><span class="sp-ll"><span class="sp-d">Sep 21 13:41:19</span> <span class="sp-e">postfix/smtp[2098]: 4XcB1c3Rk8z1: to=&lt;j.doe@nonexistent.example&gt;, status=bounced (Host or domain name not found)</span>
</span><span class="sp-ll"><span class="sp-d">Sep 21 13:40:02</span> <span class="sp-w">journal-milter[40]: rate limit: app=prod-server 201/200 in 3600s — tempfail at MAIL FROM</span>
</span></pre>
</div>
@@ -14,9 +14,9 @@
<p class="mb-3">Deleting the panel user <strong>acme-ops</strong> will:</p>
<ul class="sp-list-check">
<li><i class="ti ti-x"></i>end any signed-in session of this user at once;</li>
<li><i class="ti ti-x"></i>remove the assignments to <strong>all outbound domains</strong> and to <strong>acme.io</strong> (inbound).</li>
<li><i class="ti ti-x"></i>remove the assignments to <strong>all outbound domains</strong> (outbound) and to <strong>acme.io</strong> (inbound).</li>
</ul>
<p class="sp-muted mb-4">The domains, their applications and passwords, upstreams and recipients are not affected.</p>
<p class="sp-muted mb-4">Applications and their passwords, upstreams and recipients are not affected.</p>
<form method="post" action="users.html">
<div class="buttons">
<button type="submit" class="button is-danger"><i class="ti ti-trash"></i>Delete acme-ops</button>
@@ -16,7 +16,7 @@
<div class="sp-box-body">
<div class="field"><label class="label" for="username">Username</label><div class="control"><input class="input" id="username" name="username" value="acme-ops" autocomplete="username" spellcheck="false" required></div></div>
<div class="field"><label class="label" for="email">E-mail</label><div class="control"><input class="input" id="email" name="email" type="email" value="ops@acme.io" autocomplete="off"></div>
<p class="help">For notifications and a password-reset link. The user can change it under Account.</p></div>
<p class="help">The user's own address. They can change it under Account.</p></div>
<div class="field"><label class="label" for="password">Password</label><div class="control"><input class="input" id="password" name="password" type="password" autocomplete="new-password"></div>
<p class="help">Leave empty to keep the current one.</p></div>
<div class="field"><label class="label" for="role">Role</label>
@@ -33,7 +33,7 @@
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids" checked disabled> example.org</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids" checked disabled> shop.example.org</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids" checked disabled> notify.acme.io</label></div>
<p class="help">With <em>All</em> ticked the list below it is ignored — and greyed out where scripts run.</p>
<p class="help">With <em>All</em> ticked the single domains are ignored.</p>
</div>
</div>
<div class="box">
@@ -12,8 +12,8 @@
<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-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="preflight.html"><i class="ti ti-checklist"></i>Preflight</a><a href="system-log.html" aria-current="page"><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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
@@ -69,24 +69,25 @@
<div class="sp-grow">
<p class="sp-kicker">Server</p>
<h1 class="title is-3">System log</h1>
<p class="sp-lead">The tail of <code>mail.log</code>, the log Postfix writes. For one message, open it from a log page instead.</p>
<p class="sp-lead">The tail of <code>mail.log</code>, the log Postfix writes. For one message, open it from the <a href="out-log.html">Outbound log</a> instead.</p>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Recent lines</h2><div class="sp-end">Last 200 · refreshes every 10 s</div></div>
<pre class="sp-log"><span class="sp-d">Sep 21 14:01:52</span> postfix/smtp[2131]: 4XcB9q0Lw2z1: to=&lt;m.keller@gmx.de&gt;, relay=mx00.gmx.net[212.227.15.9]:25, delay=0.8, status=sent (250 Requested mail action okay)
<span class="sp-d">Sep 21 14:01:52</span> postfix/qmgr[58]: 4XcB9q0Lw2z1: removed
<span class="sp-d">Sep 21 14:00:18</span> postfix/smtpd[2129]: connect from mail.uni.example[192.0.2.77]
<span class="sp-d">Sep 21 14:00:18</span> postfix/smtp[2130]: 4XcB8z5Tn1z1: to=&lt;dev@lists.example.org&gt;, relay=mx.internal[10.0.4.2]:25, status=sent (250 2.0.0 Ok: queued)
<span class="sp-d">Sep 21 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, status=deferred (451 4.7.1 Greylisted, try again in 5 minutes)</span>
<span class="sp-d">Sep 21 13:57:41</span> <span class="sp-e">postfix/smtpd[2119]: NOQUEUE: reject: RCPT from bulk.example[198.51.100.90]: 550 5.1.1 &lt;sales@lists.example.org&gt;: Recipient address rejected: unknown recipient</span>
<span class="sp-d">Sep 21 13:44:02</span> <span class="sp-w">postfix/smtp[2101]: 4XcB2m7Hd4z1: to=&lt;jobs@acme.io&gt;, relay=none, status=deferred (connect to 10.0.4.12[10.0.4.12]:2525: Connection timed out)</span>
<span class="sp-d">Sep 21 13:41:19</span> <span class="sp-e">postfix/smtp[2098]: 4XcB1c3Rk8z1: to=&lt;j.doe@nonexistent.example&gt;, status=bounced (Host or domain name not found)</span>
<span class="sp-d">Sep 21 13:40:02</span> <span class="sp-w">journal-milter[40]: rate limit: app=prod-server 201/200 in 3600s — tempfail at MAIL FROM</span></pre>
<div class="sp-box-head"><span class="sp-no">01</span><h2>Recent lines</h2><div class="sp-end">Last 200 lines · refreshes on its own</div></div>
<pre class="sp-log"><span class="sp-ll"><span class="sp-d">Sep 21 14:01:52</span> postfix/smtp[2131]: 4XcB9q0Lw2z1: to=&lt;m.keller@gmx.de&gt;, relay=mx00.gmx.net[212.227.15.9]:25, delay=0.8, status=sent (250 Requested mail action okay)
</span><span class="sp-ll"><span class="sp-d">Sep 21 14:01:52</span> postfix/qmgr[58]: 4XcB9q0Lw2z1: removed
</span><span class="sp-ll"><span class="sp-d">Sep 21 14:00:18</span> postfix/smtpd[2129]: connect from mail.uni.example[192.0.2.77]
</span><span class="sp-ll"><span class="sp-d">Sep 21 14:00:18</span> postfix/smtp[2130]: 4XcB8z5Tn1z1: to=&lt;dev@lists.example.org&gt;, relay=mx.internal[10.0.4.2]:25, status=sent (250 2.0.0 Ok: queued)
</span><span class="sp-ll"><span class="sp-d">Sep 21 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, status=deferred (451 4.7.1 Greylisted, try again in 5 minutes)</span>
</span><span class="sp-ll"><span class="sp-d">Sep 21 13:57:41</span> <span class="sp-e">postfix/smtpd[2119]: NOQUEUE: reject: RCPT from bulk.example[198.51.100.90]: 550 5.1.1 &lt;sales@lists.example.org&gt;: Recipient address rejected: unknown recipient</span>
</span><span class="sp-ll"><span class="sp-d">Sep 21 13:44:02</span> <span class="sp-w">postfix/smtp[2101]: 4XcB2m7Hd4z1: to=&lt;jobs@acme.io&gt;, relay=none, status=deferred (connect to 10.0.4.12[10.0.4.12]:2525: Connection timed out)</span>
</span><span class="sp-ll"><span class="sp-d">Sep 21 13:41:19</span> <span class="sp-e">postfix/smtp[2098]: 4XcB1c3Rk8z1: to=&lt;j.doe@nonexistent.example&gt;, status=bounced (Host or domain name not found)</span>
</span><span class="sp-ll"><span class="sp-d">Sep 21 13:40:02</span> <span class="sp-w">journal-milter[40]: rate limit: app=prod-server 201/200 in 3600s — tempfail at MAIL FROM</span>
</span></pre>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
+51 -3
View File
@@ -73,6 +73,27 @@ code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; pad
.navbar-dropdown button.navbar-item:hover, .navbar-dropdown button.navbar-item:focus { --bulma-navbar-item-background-l-delta: var(--bulma-navbar-item-hover-background-l-delta); }
.navbar-dropdown button.navbar-item:active { --bulma-navbar-item-background-l-delta: var(--bulma-navbar-item-active-background-l-delta); }
.navbar.is-primary .sp-current > .navbar-link, .navbar.is-primary a.navbar-item.sp-current { background: rgb(0 0 0 / .18); }
/* Below 1024 px Bulma hides the bar's menu (navbar-menu without is-active)
and the touch navigation takes its place: Menu at the right of the brand
opens a panel over the page, its sections folded but the current one.
<details> all through, so it needs no script (owner, 2026-10-16: the menu
had been a sheet of every section open, filling the first screen). */
.sp-touchnav { display: none; }
.sp-touchnav summary { list-style: none; cursor: pointer; }
.sp-touchnav summary::-webkit-details-marker { display: none; }
.sp-touchnav summary::after { content: "›"; display: inline-block; margin-left: .5rem; font-size: 1.3rem; line-height: 1; transform: rotate(90deg); transition: transform .15s; }
.sp-touchnav[open] > summary::after, .sp-touchnav-group[open] > summary::after { transform: rotate(-90deg); }
.sp-touchnav > summary { display: flex; align-items: center; padding: 0 1rem; color: var(--sp-on-brick); font-weight: 500; }
.sp-touchnav-panel { position: absolute; top: 100%; left: 0; right: 0; z-index: 30; background: var(--sp-brick); box-shadow: 0 12px 28px rgb(18 22 28 / .22); padding-bottom: .4rem; }
.sp-touchnav-panel > a, .sp-touchnav-group > summary { display: flex; align-items: center; gap: .45rem; padding: .75rem 1rem; color: var(--sp-on-brick); font-weight: 500; }
.sp-touchnav-group > summary::after { margin-left: auto; }
.sp-touchnav-panel > a[aria-current], .sp-touchnav-group[open] > summary { background: rgb(0 0 0 / .18); }
.sp-touchnav-group a, .sp-touchnav-group button { display: flex; align-items: center; gap: .6rem; padding: .65rem 1rem .65rem 2rem; background: #fff; color: var(--sp-ink); border: 0; font: inherit; text-align: left; cursor: pointer; }
.sp-touchnav-group form { display: flex; }
.sp-touchnav-group button { flex: 1; }
.sp-touchnav-group .ti { color: var(--sp-muted); }
.sp-touchnav-group a[aria-current] { color: var(--sp-brick); font-weight: 600; background: var(--sp-brick-tint); }
.sp-touchnav-group a[aria-current] .ti { color: var(--sp-brick); }
/* The header is built like the mark: a brick field, then the stamp's paper
margin, and the perforation runs along the outer edge of that margin — round
holes, flat teeth, the mark's ink hairline (perf-edge.svg is one tooth and
@@ -200,6 +221,11 @@ code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; pad
.sp-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: 1rem 1.5rem; margin: 0; }
.sp-facts dt { font-family: var(--sp-mono); font-size: var(--sp-fs-xs); letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); }
.sp-facts dd { margin: 0; overflow-wrap: anywhere; }
/* A label may carry a tag, the verdict on its value (the certificate's
names against the hostname); it keeps the tag's own type. */
.sp-facts dt .tag { margin-left: .4rem; text-transform: none; letter-spacing: 0; vertical-align: 1px; }
/* A fact that takes the whole row: a line a server wrote, read as one. */
.sp-facts .sp-wide { grid-column: 1 / -1; }
.sp-facts dd.sp-big { font-size: 1.5rem; font-weight: 600; line-height: 1.2; }
.sp-side { position: sticky; top: 1.5rem; }
.menu-label { font-family: var(--sp-mono); letter-spacing: .1em; }
@@ -212,6 +238,17 @@ code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; pad
.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); text-align: right; }
/* A record DNS already holds as shown is folded to its head: a <details>
whose summary is the head, opened without script. */
details.sp-record > summary { list-style: none; cursor: pointer; }
details.sp-record > summary::-webkit-details-marker { display: none; }
details.sp-record:not([open]) > summary { margin-bottom: 0; }
details.sp-record:not([open]) > summary small { display: none; }
details.sp-record[open] .sp-record-head small + .sp-record-toggle { margin-left: 0; }
.sp-record-toggle { margin-left: auto; display: inline-flex; align-items: center; gap: .3rem; color: var(--sp-brick); font-size: var(--sp-fs-s); white-space: nowrap; }
.sp-record-toggle span + span { display: none; }
details.sp-record[open] .sp-record-toggle span { display: none; }
details.sp-record[open] .sp-record-toggle span + span { display: inline; }
.sp-record .label, .sp-form .label { font-weight: 500; color: var(--sp-ink-2); margin-bottom: .25rem; }
.sp-record .label { font-weight: 400; color: var(--sp-muted); }
.input[readonly], .textarea[readonly] { font-family: var(--sp-mono); font-size: var(--sp-fs-s); background: var(--sp-well); box-shadow: none; }
@@ -227,8 +264,12 @@ code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; pad
/* ---- logs, timeline, help */
.sp-log {
margin: 0; padding: 1rem 1.25rem; background: #191c21; color: #d9d5cc; border-radius: 0;
font-family: var(--sp-mono); font-size: var(--sp-fs-s); line-height: 1.6; overflow-x: auto; white-space: pre;
font-family: var(--sp-mono); font-size: var(--sp-fs-s); line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere;
}
/* One line of a log: long ones wrap inside the pane, the rest of the line
indented under its start, so nothing scrolls sideways - a phone drew the
scroll bars over the text (owner, 2026-10-16). */
.sp-log .sp-ll { display: block; padding-left: 4ch; text-indent: -4ch; }
.sp-log .sp-w { color: #e0b15c; } .sp-log .sp-e { color: #ef8f82; } .sp-log .sp-d { color: #7f8894; }
.sp-timeline { list-style: none; margin: 0; padding: 0; }
.sp-timeline li { position: relative; padding: 0 0 1.1rem 1.6rem; border-left: 2px solid var(--sp-rule); margin-left: .4rem; }
@@ -262,12 +303,19 @@ code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; pad
.sp-signin-form .sp-foot { text-align: center; margin-top: 2.5rem; }
@media (max-width: 1407px) { .sp-health { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 1023px) { .sp-box-head form.sp-end { flex-wrap: wrap; } }
@media (max-width: 1023px) {
.sp-box-head form.sp-end { flex-wrap: wrap; }
.sp-touchnav { display: flex; margin-left: auto; }
/* Bulma gives a container side margins only from 1024 px: below it the
page met the screen's edge. */
.sp-subnav .container, .container.sp-page { padding-inline: 1rem; }
}
@media (max-width: 768px) {
.sp-health { grid-template-columns: repeat(2, 1fr); }
.sp-row, .sp-pair { grid-template-columns: 1fr; }
.sp-record-head { flex-wrap: wrap; }
.sp-record-head small { margin-left: 0; text-align: left; flex-basis: 100%; }
.sp-record-head small { margin-left: 0; text-align: left; flex-basis: 100%; order: 1; }
details.sp-record[open] .sp-record-head small + .sp-record-toggle { margin-left: auto; }
.sp-postmark { display: none; }
.sp-cards { display: flex; flex-direction: column; padding-inline: .75rem; }
.sp-cards > .column { display: contents; }
@@ -12,8 +12,8 @@
<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-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="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" aria-current="page"><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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
@@ -80,9 +80,9 @@
<p class="mb-3">Deleting the panel user <strong>acme-ops</strong> will:</p>
<ul class="sp-list-check">
<li><i class="ti ti-x"></i>end any signed-in session of this user at once;</li>
<li><i class="ti ti-x"></i>remove the assignments to <strong>all outbound domains</strong> and to <strong>acme.io</strong> (inbound).</li>
<li><i class="ti ti-x"></i>remove the assignments to <strong>all outbound domains</strong> (outbound) and to <strong>acme.io</strong> (inbound).</li>
</ul>
<p class="sp-muted mb-4">The domains, their applications and passwords, upstreams and recipients are not affected.</p>
<p class="sp-muted mb-4">Applications and their passwords, upstreams and recipients are not affected.</p>
<form method="post" action="users.html">
<div class="buttons">
<button type="submit" class="button is-danger"><i class="ti ti-trash"></i>Delete acme-ops</button>
@@ -94,7 +94,7 @@
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
+5 -5
View File
@@ -12,8 +12,8 @@
<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-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="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" aria-current="page"><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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
@@ -82,7 +82,7 @@
<div class="sp-box-body">
<div class="field"><label class="label" for="username">Username</label><div class="control"><input class="input" id="username" name="username" value="acme-ops" autocomplete="username" spellcheck="false" required></div></div>
<div class="field"><label class="label" for="email">E-mail</label><div class="control"><input class="input" id="email" name="email" type="email" value="ops@acme.io" autocomplete="off"></div>
<p class="help">For notifications and a password-reset link. The user can change it under Account.</p></div>
<p class="help">The user's own address. They can change it under Account.</p></div>
<div class="field"><label class="label" for="password">Password</label><div class="control"><input class="input" id="password" name="password" type="password" autocomplete="new-password"></div>
<p class="help">Leave empty to keep the current one.</p></div>
<div class="field"><label class="label" for="role">Role</label>
@@ -99,7 +99,7 @@
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids" checked disabled> example.org</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids" checked disabled> shop.example.org</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids" checked disabled> notify.acme.io</label></div>
<p class="help">With <em>All</em> ticked the list below it is ignored — and greyed out where scripts run.</p>
<p class="help">With <em>All</em> ticked the single domains are ignored.</p>
</div>
</div>
<div class="box">
@@ -119,7 +119,7 @@
</div>
</form>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
+3 -3
View File
@@ -12,8 +12,8 @@
<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-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="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" aria-current="page"><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">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
@@ -88,7 +88,7 @@
</table></div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
+8
View File
@@ -46,6 +46,14 @@ the trixie sources. This register is left as it was made: its line numbers
and versions are those of the bookworm sources above, which are now kept
under `dev/upstream-bookworm/` (where it says `dev/upstream/`).
With panel-dkim-signing (`2.8.0`) OpenDKIM left the image: the panel signs
outgoing mail itself (`internal/milter/sign.go`), and `build/opendkim.conf`,
the syslog relay (`internal/syslogrelay`, `/dev/log`), the KeyTable and
SigningTable, the USR1 reload through supervisord and the port of
OpenDKIM's From: parser (`internal/milter/mailparse.go`) went with it. The
rows about OpenDKIM, the syslog relay and those pieces describe code that
is gone; they are left as they were, like the rest of this register.
Counts at the inventory, by the justification the tree had:
| Part | Entries | CITED | MEASURED | GUESSED | WRONG? |
+65 -26
View File
@@ -107,7 +107,7 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see
[§ Release image](#release-image)).
**Planned version cuts** (from pin `2.7.0`; adjust if semver changes mid-track):
**Planned version cuts** (from pin `2.13.1`; adjust if semver changes mid-track):
| Stage | ID | Cut |
|---|---|---|
@@ -140,6 +140,17 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
| 27 | port25-bare-newline | `2.6.1` (PATCH) |
| 28 | base-trixie | `2.7.0` |
| 29 | panel-dkim-signing | `2.8.0` |
| 30 | dns-record-collapse, settings-save-row, notification-mail-recheck; `/data/opendkim` removed | `2.9.0` |
| 31 | log panes wrap on a phone | `2.9.1` (PATCH) |
| 32 | inbound-upstream-result | `2.10.0` |
| 33 | long, non-repeating queue ids | `2.10.1` (PATCH) |
| 34 | phone-desktop-mode | `2.10.2` (PATCH) |
| 35 | backup-reauth | `2.10.3` (PATCH) |
| 36 | origin-strict | `2.10.4` (PATCH) |
| 37 | health-setup-checks | `2.11.0` |
| 38 | test-email | `2.12.0` |
| 39 | local-time | `2.13.0` |
| 40 | docs-audit | `2.13.1` |
Docs-only prep (checklists in plans, no product code) uses the same per-step
commit rule but **no** version cut until the next product stage ships.
@@ -155,16 +166,16 @@ commit rule but **no** version cut until the next product stage ships.
| **Build** | [Makefile](../Makefile): primary targets `vet`, `test`, `build`, `e2e` (also `all`, `clean`) |
| **Container** | Docker + Compose v2 on the dev host and in CI |
| **Image (build stage)** | `golang:1.26-trixie` (bookworm until `2.7.0`) — [build/Dockerfile](../build/Dockerfile). The tag floats to the newest 1.26 patch release, and most standard-library security fixes (`net/http`, `html/template`, …) arrive only that way: rebuild the image after each Go patch release, no edit needed. Dependencies are raised by hand (`go get …@latest`, `go mod tidy`), at least at each audit |
| **Image (runtime)** | `debian:trixie-slim` (bookworm until `2.7.0`) + Postfix, OpenDKIM, supervisord, SASL, logrotate |
| **Image (runtime)** | `debian:trixie-slim` (bookworm until `2.7.0`) + Postfix, supervisord, SASL, logrotate (OpenDKIM until `2.8.0`; the panel signs since) |
| **CI** | GitHub Actions — [.github/workflows/](../.github/workflows/) |
| **Image registry** | `ghcr.io/mixeme/selfpost` |
**Repository layout** (brief; process details in [architecture.md](architecture.md)):
- `cmd/panel` — HTTP panel + journal-milter + log-tailer
- `cmd/panel` — HTTP panel + milters (sender check, DKIM signer, journal) + log-tailer + notifier
- `cmd/selfpost-backup` — backup CLI (`docker exec … selfpost-backup`)
- `internal/` — domain logic, store, web, health
- `build/` — Dockerfile, supervisord, Postfix/OpenDKIM, entrypoint
- `build/` — Dockerfile, supervisord, Postfix, entrypoint
- `deploy/` — `docker-compose.yml`, proxy examples, `.env.example`
- `test/e2e/` — **separate Go module**; container integration tests
@@ -182,9 +193,10 @@ dependencies must be permissive or GPL-family (see
| Package | Version | Repository | License |
|---|---|---|---|
| `github.com/emersion/go-milter` | v0.4.1 | <https://github.com/emersion/go-milter> | BSD-2-Clause |
| `golang.org/x/crypto` | v0.54.0 | <https://github.com/golang/crypto> | BSD-3-Clause |
| `modernc.org/sqlite` | v1.53.0 | <https://gitlab.com/cznic/sqlite> (mirror: <https://github.com/modernc-org/sqlite>) | BSD-3-Clause |
| `github.com/emersion/go-milter` | v0.4.1, patched in `third_party/go-milter` (its `PATCHES.md`; `replace` in `go.mod`) | <https://github.com/emersion/go-milter> | BSD-2-Clause |
| `github.com/emersion/go-msgauth` | v0.7.0 (`dkim`, the signer) | <https://github.com/emersion/go-msgauth> | MIT |
| `golang.org/x/crypto` | v0.58.0 | <https://github.com/golang/crypto> | BSD-3-Clause |
| `modernc.org/sqlite` | v1.60.1 | <https://gitlab.com/cznic/sqlite> (mirror: <https://github.com/modernc-org/sqlite>) | BSD-3-Clause |
Transitive dependencies — `go mod graph` / `go.sum`; all indirect packages in
the tree are AGPL-3.0-compatible.
@@ -203,14 +215,16 @@ the tree are AGPL-3.0-compatible.
| Package | Version | Repository | License |
|---|---|---|---|
| `github.com/emersion/go-msgauth` | v0.6.8 | <https://github.com/emersion/go-msgauth> | BSD-2-Clause |
| `github.com/emersion/go-msgauth` | v0.6.8 | <https://github.com/emersion/go-msgauth> | MIT |
The test module is not part of the main `go build` graph and is not shipped in
the image.
the image. The e2e sink image also installs dkimpy (`python3-dkim`, Debian
trixie) as the verifier that is not the signer's library
([test/e2e/sink/Dockerfile](../test/e2e/sink/Dockerfile)).
### Debian packages in the runtime image
Postfix, OpenDKIM, `supervisord`, `sasl2-bin`, `logrotate`, and others come
Postfix, `supervisord`, `sasl2-bin`, `logrotate`, and others come
from Debian trixie repositories; licenses are in each package's `copyright`
file on <https://packages.debian.org/trixie/>.
The image also ships [LICENSE](../LICENSE), [NOTICE](../NOTICE), and the IBM
@@ -222,9 +236,9 @@ the OFL text at `/static/OFL.txt`.
The owner's rule (2026-10-14): what another program does is read in its
documentation or its source - never guessed. SelfPost reads Postfix's log
lines and reply codes, feeds OpenDKIM's tables, parses `postqueue`,
`postconf`, `supervisorctl` and `db_dump` output, talks the milter protocol
through go-milter, relies on SQLite's locking, on logrotate's and Docker's
lines and reply codes, parses `postqueue`, `postconf`, `supervisorctl` and
`db_dump` output, talks the milter protocol through go-milter, signs with
go-msgauth over the headers Postfix hands a milter, relies on SQLite's locking, on logrotate's and Docker's
behaviour. Every block of code that does so carries a comment that says
what it relies on and where that is written, for the version the image runs
(the Debian trixie package, not upstream's head):
@@ -236,11 +250,14 @@ what it relies on and where that is written, for the version the image runs
```
The Debian sources of the image's versions are fetched with `apt-get source`
inside a `debian:trixie` container (`postfix`, `opendkim`, `cyrus-sasl2`,
`supervisor`, `logrotate`, and `sendmail` for libmilter); `dev/upstream/`
(not in git) is where they are kept for reading, and `dev/upstream-bookworm/`
holds the bookworm ones the citations named before `2.7.0`. Go modules are
read in the module cache at the version `go.mod` pins.
inside a `debian:trixie` container (`postfix`, `cyrus-sasl2`, `supervisor`,
`logrotate`); `dev/upstream/` (not in git) is where they are kept for
reading, and `dev/upstream-bookworm/` holds the bookworm ones the citations
named before `2.7.0`. `opendkim` and `sendmail` (for libmilter) are no longer
the image's since `2.8.0`; they stay in `dev/upstream/` only as what the
removed code and some historical comments cite (the signer's header list,
`internal/milter/sign.go`, names OpenDKIM's). Go modules are read in the
module cache at the version `go.mod` pins.
The image takes `debian:trixie-slim` and installs without pinning (pinned
Debian versions leave the mirrors), so a rebuild after a point or security
@@ -467,7 +484,8 @@ make e2e
**Stack:** [deploy/docker-compose.yml](../deploy/docker-compose.yml) +
[test/e2e/compose.override.yml](../test/e2e/compose.override.yml) — same
`cap_drop`/`cap_add`/`no-new-privileges` as production. Override: high ports
(`20465`/`20587`/`20080`), test hostname, self-signed TLS,
(`20465`/`20587`/`20080`, port 25 at `20025`, the stand's CoreDNS at
`20053/udp`), test hostname, self-signed TLS,
`PANEL_COOKIE_SECURE=false`, isolated compose project, and the image's
`VERSION` from `SELFPOST_E2E_VERSION` (unset locally: `e2e`). Mail is hermetic:
CoreDNS (fake zone) + Postfix `smtp-sink` as sink-MX; DKIM TXT is scraped from
@@ -480,9 +498,30 @@ L1/L2 limits, the panel down or hung mid-message (Postfix defers the mail
rather than letting it through: `panel_down_defers_mail`,
`panel_hung_mid_message_is_counted`), bad `SELFPOST_HOSTNAME`, session
survives `docker restart`); startup checks that supervisord actually brought up
OpenDKIM, the panel, and Postfix (`checkSupervisorProcesses`), plus logrotate
config-mode and forced-rotation checks (`checkLogrotateConfigMode`,
`checkLogrotateRotation` — [test/e2e/logrotate_check.go](../test/e2e/logrotate_check.go)).
the panel, Postfix, cert-reload and logrotate (`checkSupervisorProcesses`),
that `/data/dkim` is the panel's alone, directories 0700 and files 0600
(`checkDKIMTree`), and that the image's Debian packages are the cited ones
(`checkCitedPackageVersions`), plus logrotate config-mode and forced-rotation
checks (`checkLogrotateConfigMode`, `checkLogrotateRotation` —
[test/e2e/logrotate_check.go](../test/e2e/logrotate_check.go)).
DKIM has an oracle that is not the signer's library: on every positive send
and on a notification, `checkPanelSignature`
([test/e2e/mail_helpers.go](../test/e2e/mail_helpers.go)) requires exactly
one signature, the panel's (`relaxed/simple`, `rsa-sha256`, `From` signed and
over-signed), and has dkimpy (`dkimverify` in the sink) verify it against the
stand's CoreDNS; go-msgauth's verifier (`verifyDKIM`) checks the published
record as a receiver would. `signature_over_awkward_headers`
([test/e2e/dkim_shapes_test.go](../test/e2e/dkim_shapes_test.go)) sends the
header shapes Postfix hands a milter in its own way - folded, tab-led, no
space after the colon, two `To:` - and an 8-bit body, and dkimpy verifies
what was delivered. `TestStartsOnA28Data`
([test/e2e/upgrade_test.go](../test/e2e/upgrade_test.go)) starts the image on
a `/data` a 2.8.x image left: the old key tree `/data/opendkim` is removed,
the key under `/data/dkim` is kept unchanged and the selector table names
it, and a queue owned by other uids is handed to Postfix's users by name;
`TestRefusesA27Data` has the start stop on a `/data` whose keys are under
the old tree only, which is left as it was.
Waits poll with a timeout (`waitFor`); a fixed sleep is the exception and says
why where it stands (`sender_check_test.go`: a downtime worth a second or two,
and 35 s to show a row is not written again).
@@ -499,10 +538,10 @@ Workflows in [.github/workflows/](../.github/workflows/). What each job runs —
### `test.yml` — every push and PR to `main`
`design-first` (needs the full history) → `gofmt -l` → `go vet ./...` →
`go test ./...` (main module, no e2e; includes the panel guard tests, see
Staticcheck 2026.1 → `go test ./...` (main module, no e2e; includes the panel guard tests, see
[§ Panel pages](#panel-pages-the-component-kit-and-its-guards)) → the e2e
module compiled, not run (`go vet ./...` and `go test -c -o /dev/null .` in
`test/e2e`).
module compiled, not run (`go vet ./...`, Staticcheck and
`go test -c -o /dev/null .` in `test/e2e`).
### `release.yml` — published GitHub Release, or `workflow_dispatch` with SemVer
@@ -528,7 +567,7 @@ prepare (version from release tag or workflow_dispatch input; checkout vX.Y.Z)
→ GitHub Packages API → drop untagged versions no tagged manifest references
```
Native per-arch matrix (no QEMU): running the full Postfix/OpenDKIM stack under
Native per-arch matrix (no QEMU): running the full Postfix stack under
emulation for e2e is impractical. E2e first, then push — the registry receives
the bytes that passed the gate. Each release leaves three tags in GHCR:
`X.Y.Z` (the multi-arch manifest operators pin) and `X.Y.Z-amd64` /
+221 -64
View File
@@ -84,8 +84,9 @@ Since `2.7.0` a message sent on 465 or 587 does not tell its recipients
where the application runs: the `Received:` header SelfPost adds reads
`Received: by mail.example.com (Postfix) id 4AbC12xyz for <...>; <date>`,
without the application's address, host name or login. To trace a message
someone complains about, take that queue id to *Outbound › Log*; mail.log
keeps the client address on smtpd's `client=` line for the same id.
someone complains about, find its row in *Outbound › Log* (by domain,
application and day; the message's page shows its queue id); mail.log keeps
the client address on smtpd's `client=` line for the same id.
### Local trial
@@ -204,14 +205,14 @@ cat ./data/setup-token
#### Fixed image tag
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
deliberately never `:latest`. The current pin is `2.7.0`. **No 2.x image is
deliberately never `:latest`. The current pin is `2.13.1`. **No 2.x image is
published on ghcr.io yet** (the newest there is `1.3.0`); until one is, build
it from the checkout under the pinned name, and Compose uses the local image
instead of pulling:
```sh
docker build -f build/Dockerfile --build-arg VERSION=2.7.0 \
-t ghcr.io/mixeme/selfpost:2.7.0 .
docker build -f build/Dockerfile --build-arg VERSION=2.13.1 \
-t ghcr.io/mixeme/selfpost:2.13.1 .
```
`VERSION` must equal the tag: it is the version the backup check compares
@@ -249,7 +250,8 @@ expected to set; defaults match the code exactly.
| `SEND_LOG_RETENTION_DAYS` | Initial default for how many days of send-log history are kept before the background sweep deletes rows, and with them the `mail.log` lines kept for each message (1–2 KB a message) — the main driver of `/data` growth over time and of the size of a full backup. After the first panel start, change retention under **Server › Settings** (global role); the env value is only used to seed SQLite when the setting has never been saved. | `90` | `.env` |
| `PANEL_SESSION_IDLE_DAYS` | Sliding idle timeout for the panel login session, in days. There is no absolute cap: a user who keeps coming back stays signed in indefinitely. | `7` | `.env` |
| `SELFPOST_DNS_RESOLVERS` | Comma-separated recursive resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly (so they report what the internet sees, not what this host's stub resolver synthesises). Each is given 2 seconds to answer before the next is asked, and the one that answered last is asked first; a lookup none of them answered shows as not known, never as a wrong record. | `1.1.1.1:53`, `8.8.8.8:53`, `9.9.9.9:53` when unset | `.env` |
| `TRUSTED_PROXY_CIDR` | Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to supply `X-Forwarded-For` for login, setup, and account-change rate-limiting. **Leave unset unless you know the exact address of your reverse proxy.** A wrong value lets a client spoof its rate-limit key by sending a forged `X-Forwarded-For` header — the panel trusts the last hop only when the TCP peer matches one of these CIDRs. With Apache on the host (the default), the panel sees the proxy's connections come from the Docker network's gateway, e.g. `172.18.0.1` (`docker network inspect` shows it; measured 2026-10-15: the panel's `127.0.0.1:8080` is published through Docker's proxy, and a connection to such a port from the host arrives from the gateway). | *(empty — XFF ignored)* | `.env` |
| `TZ` | Time zone of the container, an IANA name such as `Europe/Moscow`: every time the panel shows, every day it counts (today on Health, the days of the statistics, the days of the log filters) and the stamps Postfix writes in `mail.log`, so the two agree. Stored times stay UTC, so changing it is only a restart. *Server › Settings* shows the zone in use, and says so when the image does not know the name (the times are then UTC). | UTC | `.env` |
| `TRUSTED_PROXY_CIDR` | Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to supply `X-Forwarded-For` for sign-in and setup rate-limiting (account-change attempts are counted per user, whatever the address). **Leave unset unless you know the exact address of your reverse proxy.** A wrong value lets a client spoof its rate-limit key by sending a forged `X-Forwarded-For` header — the panel trusts the last hop only when the TCP peer matches one of these CIDRs. With Apache on the host (the default), the panel sees the proxy's connections come from the Docker network's gateway, e.g. `172.18.0.1` (`docker network inspect` shows it; measured 2026-10-15: the panel's `127.0.0.1:8080` is published through Docker's proxy, and a connection to such a port from the host arrives from the gateway). | *(empty — XFF ignored)* | `.env` |
TLS certificate paths (`TLS_CERT_FILE`, `TLS_KEY_FILE`) are fixed in
[deploy/docker-compose.yml](../deploy/docker-compose.yml) to match the `./certs`
@@ -295,6 +297,9 @@ browser that does not send `Sec-Fetch-Site` (Safari before 16.4, or any
browser on a panel served over plain HTTP at a name): there the panel compares
`Origin` with `Host` and refuses every change as cross-origin - the log says
so explicitly, printing the `Sec-Fetch-Site`, `Origin` and `Host` it compared.
A request that carries neither header - a browser too old to send `Origin`,
or a script posting to the panel without one - is refused the same way
since 2.10.4.
In every case the proxy terminates HTTPS for the panel; the resulting
certificate must end up under `./certs` as `fullchain.pem` and `privkey.pem` —
@@ -418,7 +423,7 @@ changes.
|---|---|---|
| Overview | Overview | `/overview` |
| Outbound | Domains · Log · Queue · DMARC reports | `/outbound/domains`, `/outbound/log`, `/outbound/queue`, `/outbound/dmarc` |
| Inbound | Domains | `/inbound/domains` |
| Inbound | Domains · Log · Filter lists (global role) | `/inbound/domains`, `/inbound/log`, `/inbound/filter-lists` |
| Server | Health · System log · Backup · Users · Settings | `/server/health`, `/server/log`, `/server/backup`, `/server/users`, `/server/settings` |
| User menu (your name) | Account · Help · Sign out | `/account`, `/help`, `POST /logout` |
@@ -451,22 +456,19 @@ assumptions stay here in `docs/guide.md`.
**Overview** (`/overview`, global role) shows the stamp — the worst of its
checks — and one card per check: machine (processor and memory), supervised
processes (Postfix, OpenDKIM, panel), TLS certificate, queue, milter sockets
and reverse DNS. A check the panel could not run shows as a warning, never as
fine. Below the cards are the outbound domain list and, with the inbound relay
processes (Postfix, the panel - whose roles include the DKIM signer - and
their helpers), TLS certificate, queue, milter sockets and reverse DNS. A
check the panel could not run shows as a warning, never as fine. Below the cards are the outbound domain list and, with the inbound relay
on, the inbound one. Each card leads to its table on **Server › Health**
(`/server/health`) — the queue card to the Outbound queue — and Health has the
readings, in the order of what stops mail first:
readings, numbered in the order of what stops mail first:
- **Machine** — processor (core and thread counts), memory and swap of the
host (a memory limit set on the container is not reflected), and
per-interface network throughput and totals of the container's own network
interfaces, read from the kernel's counters; CPU and throughput are measured between refreshes, so they appear
one refresh after the page opens. A fully busy processor or a machine out of
memory is a warning, because both delay or kill the mail path; throughput is
only reported.
- **Processes**, **TLS certificate** (validity and expiry) and **Milter
sockets** (OpenDKIM, the sender check and the journal-milter).
- **Processes** and **Milter sockets**: the DKIM signer's two - `dkim-signer · applications` for what
applications send on 465/587, `dkim-signer · notifications` for the
panel's own mail - the sender check, the send log (the journal-milter)
and, with one configured, the spam filter. Postfix attaches the signer
strictly, so either of its rows down means the mail that goes through it
waits.
- **Sender check** — what the strict check of the `From:` line has cost
today, in the last 30 days and in the last 12 months: for how long the
panel did not answer while Postfix was running, how many connections
@@ -478,23 +480,52 @@ readings, in the order of what stops mail first:
connects during an outage is counted too; a message refused in the middle
is one the panel stopped answering for after it had begun. A time marked ≈
is at most what it shows: at that start supervisord could not say since
when Postfix ran. Days are UTC days. The card warns when
when Postfix ran. Days are those of the container's zone (`TZ`). The card warns when
a connection was turned away or a message refused in the last 24 hours. If these numbers do not stay
at nothing, the panel is in the way of your mail more than it should be -
report it.
- **TLS certificate** — validity and expiry, and whether its names cover
`SELFPOST_HOSTNAME` (the tag beside *Names*); a certificate for another
name fails, as a client that checks the name refuses it.
- **Hostname and reverse DNS** — compares `SELFPOST_HOSTNAME` against the PTR
record the internet publishes for this server's IP (forward-confirmed
reverse DNS), see [Server-level DNS](#server-level-dns-ptrrdns); use
*Re-check DNS* after changing DNS.
*Re-check DNS* after changing DNS. *Greeting* is the `220` line Postfix
opens every connection with; it must name the hostname, and another name
there is a warning.
- **Notification mail** — whether the DNS records the panel's own mail is
signed and authorised with are in place (see [Notifications](#notifications)).
- **Machine** — processor (core and thread counts), memory and swap of the
host (a memory limit set on the container is not reflected), and
per-interface network throughput and totals of the container's own network
interfaces, read from the kernel's counters; CPU and throughput are measured between refreshes, so they appear
one refresh after the page opens. A fully busy processor or a machine out of
memory is a warning, because both delay or kill the mail path; throughput is
only reported.
- **Your address** (in the Configuration card) — the address the panel sees
your browser at, and whether it took it from `X-Forwarded-For`. Behind a
reverse proxy that the panel does not trust it is the proxy's own: every
visitor then shares one sign-in limit, and the card says what to set in
`TRUSTED_PROXY_CIDR`.
- **Configuration** — the **Reload configuration** button writes again, from
the database, every file the panel keeps for the mail path - OpenDKIM's
tables, the Postfix sender map, the inbound relay maps and the filter lists
(with `INBOUND_RELAY_ENABLE=true`), the DMARC report maps (with
`DMARC_REPORTS_ENABLE=true`) - and has OpenDKIM and Postfix re-read them:
the database, every file the panel keeps for the mail path - the DKIM
selector table (`/data/dkim/selectors`), the Postfix sender map, the
inbound relay maps and the filter lists (with `INBOUND_RELAY_ENABLE=true`),
the DMARC report maps (with `DMARC_REPORTS_ENABLE=true`) - removes the keys
of deleted domains, and has Postfix re-read them; the signer needs no
reload, it reads the table for every message. This is
what the first start after a restore does (see [Restore](#restore)). Use it
if the daemons drifted from what the panel shows after manual edits under
`/data`. A step that fails is named on the page, and the panel's log says
why.
- **Test message** — sends one message to an address you type, from
`postmaster@` the hostname, the way notifications go (through Postfix,
signed with the key of the server's own name). The page that opens is its
row in **Outbound › Log**, which follows it to the receiving server: sent,
put off or refused, with the lines of `mail.log`. It tests the server's
own path - TLS, its address, the records of its name - not a domain's,
which only an application's mail does; and whether it reached the inbox
only the mailbox can say. Ten an hour, global role.
### Queue and System log
@@ -513,6 +544,16 @@ readings, in the order of what stops mail first:
six hours. It lives in the data volume, so it survives a container recreate
along with the rest of the state — `./data/log/` on the host — but it is
*not* included in backups: it is diagnostics, not state.
- **The container log** (`docker compose logs selfpost`) — what the panel
itself says, its milters among it; none of it is in `mail.log`. A message
the DKIM signer cannot sign is not sent unsigned, it waits: Postfix
answers an application with a temporary error and the application sends
it again later. Why is a line of the container log that begins `dkim-signer:` and
ends `- deferring`, e.g. `dkim-signer: no signing key for example.com -
deferring` (the selector table has no line for the domain: *Reload
configuration* on Server › Health writes it again from the database) or
`dkim-signer: no sending domain for login "shop" - deferring`. The sender
check's lines begin `sender-check:`.
### Account
@@ -534,7 +575,21 @@ prune cycle without a container restart), and, read-only, the level-1 rate
limit that comes from `.env` ([Rate limiting — level 1](#rate-limiting--level-1-ip-backstop)).
There is no panel-wide DMARC report address: each domain has its own. The page
also has the switch for [notifications](#notifications) and the two DNS records
that make them arrive.
that make them arrive, with when they were last checked; **Re-check DNS** at
the top of the page checks them again once they are published (results are
otherwise kept a few minutes).
The last box, **Time zone**, names the zone the panel is in: `TZ` in `.env`
([Environment variables](#environment-variables)), UTC when it is not set,
with its offset from UTC. Every time the panel shows is in that zone, and no
time on the screens says which - this box is where it is said. Every day it
counts is that zone's too: *today* on Health, the days of the statistics,
the days of the log filters. Postfix runs in the same zone, so the stamps of
the `mail.log` lines on Server › System log and on a message's page read the
same clock as the rest of the page. The database keeps its times in UTC, so
changing the zone is a restart and nothing more; the days counted before it
stay days of the old zone, a shift of a few hours in the statistics. A name
the image does not know leaves everything in UTC, and the box says so.
### Notifications
@@ -554,7 +609,9 @@ assigned to them. A message goes to the e-mail on the user's
[Account](#account) page and only while *Send me notifications* is ticked
there; a user without an e-mail gets none. **Server › Settings** has one switch
that turns notifications off for everyone. With it off nothing is checked, so
what is wrong when it is turned on again is reported then.
what is wrong when it is turned on again is reported then. The first check runs
three minutes after the panel starts, then every hour; the panel sends at
most 60 notifications an hour, a ceiling nothing should come near.
Notifications are the panel's own mail. They are sent from
`postmaster@<SELFPOST_HOSTNAME>`, one message per recipient, are not rows of
@@ -610,7 +667,8 @@ What the *domain* role reaches inside what is assigned:
| Overview, Server, the Outbound queue | yes | no (404) |
| Outbound: domain page, applications, Domain settings, log | every domain | assigned domains |
| Outbound › DMARC reports | every report, with the ingest statistics | reports of assigned domains, without the ingest box |
| Inbound: domain page, upstream, recipients | every domain | assigned inbound domains |
| Inbound: domain page, upstream, recipients, log | every domain | assigned inbound domains |
| Inbound › Filter lists | yes | no (404) |
| Add or delete a domain, either direction | yes | no |
| Export a domain (Domain settings) | yes | assigned domains |
| Import a domain (Server › Backup) | yes | no |
@@ -650,11 +708,62 @@ published, then `docker compose up -d`. The backup version check requires the ru
to match the version that created a full backup — see [Fixed image
tag](#fixed-image-tag).
**To 2.8.0** there is nothing to do. From 2.8.0 the panel signs outgoing
mail itself, in a milter of its own, and OpenDKIM is no longer in the image;
the keys, the selectors and the DNS records stay as they are. The keys move
from `data/opendkim/keys` to `data/dkim/keys`: on the first start the
entrypoint copies them, as the panel's user, into `data/dkim.new`, renamed
into place when the copy is whole, and says so in the container log
(`entrypoint: the DKIM keys were copied from /data/opendkim to /data/dkim;
/data/opendkim is left for a rollback`). A copy that fails stops the start -
the panel would otherwise give the server's own name a new key, and its
published record would no longer match. `data/dkim` is the panel's alone
(directories 0700, files 0600); so is `data/opendkim`, which is left as it
was, for a rollback, until the next release, and which a full backup leaves
out. The selector table, `data/dkim/selectors`, is written from the database
at every start, before the signer serves.
**To 2.9.0** from 2.8.x there is nothing to do: the first start removes
`data/opendkim`, the key tree 2.7.x read, which 2.8.x kept for a rollback,
and says so (`entrypoint: removed /data/opendkim, the key tree of 2.7.x;
the keys are under /data/dkim`); the keys under `data/dkim` stay as they
are. From 2.7.x, start 2.8.x once first: an image of 2.9.0 on a `data`
whose keys are under `data/opendkim` only stops at the start and says so,
rather than give the server's own name a new key. Rolling back from 2.9.x
to 2.8.x needs nothing; to 2.7.x, the old tree is gone - only a full backup
of 2.7.x brings it back.
**Rolling back to 2.7.x** from 2.8.x (only): the 2.7.x image signs with OpenDKIM
from `data/opendkim`, which holds the keys as they were at the upgrade. A
domain added under 2.8.x has no key there, and 2.7.x sends its mail
unsigned; one deleted and added again under 2.8.x has its old key there,
which no longer matches its published record. Postfix's queue under
`data/postfix/queue` belongs to Postfix's users, which have other numbers
in the two images (postfix 100 in 2.8.0, 101 in 2.7.x); a 2.7.x start
reads the queue before it hands it over and stops at `postfix check`
("scan_dir_push: open directory defer: Permission denied"). With the
instance stopped, hand the queue over in the 2.7.x image itself, by name,
before its first start (the image is the one you roll back to):
```bash
docker run --rm -v ./data:/data --entrypoint sh ghcr.io/mixeme/selfpost:2.7.0 -c 'chown -R postfix:postfix /data/postfix/queue && chgrp postdrop /data/postfix/queue/public /data/postfix/queue/maildrop && chown root /data/postfix/queue /data/postfix/queue/pid'
```
(Measured on the test server, 2026-10-16, on a copy of its queue: 2.7.x's
`postsuper` failed before and passed after.) Going forward again needs
nothing for the queue - 2.8.0 hands it over itself. A domain added under
2.7.x has its key only in `data/opendkim/keys/<domain>/`, and the
entrypoint copies only while `data/dkim` holds no key at all: with the
instance stopped, copy that directory into `data/dkim/keys/` (the start
makes it the panel's). Removing `data/dkim` instead has the old tree copied
again whole and loses the keys of domains added under 2.8.x.
### Container health
The image declares a Docker `HEALTHCHECK` that probes `GET /healthz` on port
8080 (unauthenticated). It returns `200 ok` when OpenDKIM, the panel, and
Postfix are all `RUNNING` under supervisord; otherwise `503 unhealthy`. This
8080 (unauthenticated). It returns `200 ok` when the panel - whose roles
include the DKIM signer - and Postfix are both `RUNNING` under supervisord;
otherwise `503 unhealthy`. This
catches a dead mail path that would still leave the HTTP server up, but it
does **not** verify TLS certificates, DNS records, or end-to-end delivery —
use the authenticated [Overview and Health](#overview-and-health) pages for
@@ -707,11 +816,17 @@ as themselves, the host's IPv6 address and `127.0.0.1` as `172.18.0.1`.
### Full backup and restore
Both downloads - the full backup on **Server › Backup** and a domain's
export on its settings - ask for your account password, the one you sign in
with, every time: a session left open is not enough to take them. Wrong
guesses count against the same limit as a password change (ten in fifteen
minutes).
**Full backup** is a self-contained project archive: `data/` (SQLite, all
domains' DKIM keys, all applications' SASL credentials, `manifest.json` with
the version that created it), plus `docker-compose.yml`, `.env`, and `certs/`
from the operator directory next to `./data`. Delivery logs under `data/log/`
are excluded, and so is the Postfix queue: the archive is taken while the
domains' DKIM keys under `data/dkim/`, all applications' SASL credentials,
`manifest.json` with the version that created it), plus `docker-compose.yml`,
`.env`, and `certs/` from the operator directory next to `./data`. Delivery
logs under `data/log/` are excluded, and so is the Postfix queue: the archive is taken while the
instance runs, and a copy of a queue Postfix is working on can hold a message
twice or not at all. Mail still waiting for delivery is therefore not carried
over, and a restored instance starts with an empty queue; to keep the queue,
@@ -828,10 +943,10 @@ container of the **exact same image version** that created the backup — SelfPo
refuses to start otherwise and tells you which tag to use. On the first
successful start after restore, `data/manifest.json` from the archive is
**deleted** — it guards only that one boot, so a later in-place upgrade is not
blocked. On that same first boot the panel also runs one **Resync** — OpenDKIM's
tables and Postfix's sender map are re-derived from SQLite (inbound relay maps
blocked. On that same first boot the panel also runs one **Resync** — the DKIM
selector table and Postfix's sender map are re-derived from SQLite (inbound relay maps
when `INBOUND_RELAY_ENABLE=true`; DMARC maps when `DMARC_REPORTS_ENABLE=true`)
and both daemons are reloaded, healing any drift between the extracted files and
and Postfix is reloaded, healing any drift between the extracted files and
the database. The manifest is deleted only after that Resync is done: a first
start that fails on the way leaves it, and the next start is a restore again
and runs the Resync. The *Reload configuration* button on Server › Health runs the same Resync.
@@ -874,7 +989,7 @@ A version mismatch at step 5 refuses to start and leaves `/data` untouched —
the panel exits with a message naming the tag to use, e.g.:
```
backup: this backup was created by SelfPost 2.6.1 but this image is 2.7.0 — restore into the matching image (selfpost:2.6.1)
backup: this backup was created by SelfPost 2.13.0 but this image is 2.13.1 — restore into the matching image (selfpost:2.13.0)
```
Fix the tag in `docker-compose.yml` (build that tag as under [Fixed image
@@ -1118,7 +1233,12 @@ anyone on the host; they hold addresses, not secrets.
In the panel the lists are **Inbound › Filter lists** (global role), and what
port 25 decided about each message is **Inbound › Log**: accepted mail with the
filter's verdict, refused mail with the reply it was given. A row of the log
filter's verdict, refused mail with the reply it was given. For accepted mail
its *Upstream* column says what the upstream made of it - *queued* (not tried
yet), *held* (in the queue by the filter's verdict, until released), *sent*,
*deferred* (a greylisting, an upstream that cannot be reached: Postfix tries
again) or *bounced* (refused for good, or expired in the queue; the sender
was sent a bounce) - and opens the last reply and the host that gave it. Rows logged before 2.10.0 have no upstream result. A row of the log
leads to the lists with its sender filled in. Beside each entry of a list is
how often it decided a message, counted from the log - so over the days the
log keeps - and *never* marks an entry that may have outlived its use. The
@@ -1142,7 +1262,8 @@ inbound mail is being refused with a temporary error (`tempfail`) or going throu
(`accept`).
**Inbound mail is not in the Outbound log.** Port 25 runs its own milter chain:
the optional anti-spam milter, and nothing else. OpenDKIM does not sign relayed
the optional anti-spam milter and the panel's inbound journal (Inbound › Log,
fail-open), not the signer, the sender check or the outbound journal. The DKIM signer does not sign relayed
mail (and a signing outage therefore cannot hold it up), and the journal-milter
does not file it — the [Outbound log](#outbound-log) stays a record of what
*you* sent.
@@ -1259,7 +1380,7 @@ listed for it. Since `2.4.0` that rule covers both senders a message has:
- the **envelope sender** (`MAIL FROM`), which Postfix checks;
- the **`From:` line** of the message - the one a mail program shows and the
one the DKIM signature is chosen by - which the panel checks.
one DMARC holds the DKIM signature's domain to - which the panel checks.
An address with an extension counts as the address: an application listed as
`invoices@…` may also send as `invoices+march@…`, in the envelope and in
@@ -1272,17 +1393,30 @@ Postfix asks who owns the address before it asks who owns the domain.
A message whose `From:` is anything else is refused with `550 5.7.1` and a
text naming the line: another of your domains, the server's own name, an
address at a domain that is not on this server, a line with two addresses,
or no `From:` at all. The line is read the way the signer, OpenDKIM, reads
it - the panel runs OpenDKIM's own parser - so only its address counts: what a
display name or a comment around it says is not judged, and a line with more
than one address is refused. A display name with a comma or brackets has to
be quoted (`"Shop, Inc." <orders@…>`), as the mail standard asks and mail
libraries do; unquoted, OpenDKIM may take a word of the name for the
address. The refusal
or no `From:` at all. The line must be one address as the mail standard
(RFC 5322) writes it - the address a mail program shows - and only that
address counts: what a display name or a comment around it says is not
judged, and a line with more than one address is refused. A display name
with a comma or brackets has to be quoted (`"Shop, Inc." <orders@…>`), as
the standard asks and mail libraries do; unquoted, the line does not read
as one address and is refused. The address is held to the application's
list the way Postfix holds the envelope sender, a quoted local part as the
same address unquoted. The refusal
is permanent, so the application does not send the message again; it is a
row of the [Outbound log](#outbound-log) tagged `wrong From`, and the row's
page shows what the line said.
Since `2.8.0` a **`Sender:` line** - the one a mail program shows beside
`From:` as "on behalf of" - is held to the same rule when the message has
one: one address, one the application may send as, and no more than one
such line. Otherwise the message is refused the same way, `550 5.7.1
Sender: …`, tagged `wrong Sender`. Most mail libraries write no `Sender:`;
one that writes the application's own address passes. A system that writes
for people outside your domains - a ticket system, a CRM - puts its own
address in `From:` with the person's name (`"Jane via Support"
<support@…>`) and the person's address in `Reply-To:`: their domain in
`From:` would fail DMARC at the receiver anyway.
What this stops working when you upgrade from an earlier version:
- an application that sends with the `From:` of another of your domains -
@@ -1291,7 +1425,10 @@ What this stops working when you upgrade from an earlier version:
differ - add the second address to the list;
- a contact form that puts its visitor's address into `From:` - put it into
`Reply-To:` and send from an address of your own, which is also what
DMARC requires.
DMARC requires;
- (`2.8.0`) a message with a `Sender:` of another domain, or of an address
the application is not listed for - drop the line or write the
application's own address there.
The check is strict: while the panel does not answer, Postfix answers with a
temporary error (`451`, and `454` to the login): an application that queues
@@ -1299,6 +1436,23 @@ its mail sends it again later, one that sends directly gets an error to retry;
nothing is accepted unchecked. **Server › Health** counts how often
that happens.
Since `2.8.0` the check also guards the signature: the panel signs an
application's message with the key of the domain its login belongs to, and
DMARC counts that signature only when its domain aligns with the domain of
`From:`. Without the check an application could send under this server's
signature as another of its domains, or as the server itself.
**Lines of more than 998 bytes.** The signer signs the message as the
application handed it over. Postfix's SMTP client breaks a body line longer
than 998 bytes when it delivers the message (`smtp_line_length_limit`),
after the signature was made, and the receiver's check of the signature
then fails. Such a message is still sent; the container log names its
domain and login (`dkim-signer: example.com (login "shop"): a body line
longer than 998 bytes - ...`). Mail libraries keep lines short with
quoted-printable or base64; an application that writes its own body has to
as well. A message whose header section is over 1 MiB is refused,
`552 5.3.4`.
*Export domain* writes a single-domain file; *Import a domain* on *Server ›
Backup* reads one back in (**global role only**) — see [Exporting and importing
a single domain](#exporting-and-importing-a-single-domain).
@@ -1374,9 +1528,10 @@ gateway, not its own address (see [Rate limiting — level
1](#rate-limiting--level-1-ip-backstop)).
It is **not an authentication boundary.** The SASL login itself still succeeds;
the check runs in the journal-milter, on the same fail-open path as level 2
(below), so if that milter does not answer the restriction is skipped rather
than enforced. Use it to narrow where an application may send from, not to contain a
the check runs in the journal-milter and is fail-open like level 2 (below):
if that milter does not answer, or its lookup hits a store error, the
restriction is skipped rather than enforced. A message whose client address
Postfix did not pass is refused, not let through. Use it to narrow where an application may send from, not to contain a
leaked password — for that, regenerate the password.
**Level 2 — application** — optional override of the domain limit for one
@@ -1397,8 +1552,9 @@ hours and on demand via **Recalculate now**.
**Level 2 is best-effort, not a guarantee.** It runs inside the
journal-milter and is deliberately fail-open: if the rate-limit lookup hits
a store error, or the connecting client's IP is not available to the
milter, level 2 is skipped and the message is accepted rather than held up.
a store error, level 2 is skipped and the message is accepted rather than
held up. The client address plays no part: a ceiling is on a domain or an
application, wherever its mail comes from.
The same applies if the journal-milter itself does not answer — Postfix is
configured to accept rather than refuse it when it cannot reach it. (Since
`2.4.0` a panel that is down altogether makes Postfix refuse mail with a
@@ -1409,9 +1565,9 @@ the only one of the two that does not depend on the panel process.
### Outbound log
**Outbound › Log** (`/outbound/log`) is a searchable send log with server-side
filters by domain, application, status and day (from and to, UTC days, as on
the Inbound log); the status *rejected* is the three kinds of refusal
**Outbound › Log** (`/outbound/log`) is the send log, with server-side
filters by domain, application, status and day (from and to, days of the container's zone, as on
the Inbound log); the status *rejected* is the four kinds of refusal
together, which the table tells apart by their tags. A user with the *domain*
role sees only the rows of their domains. The filters are kept by the pages,
the refresh and the way back from a message, and the log refreshes its rows
@@ -1428,19 +1584,20 @@ the row as a failure rather than show a delivery it cannot evidence, and
counts that it did. A
refused row says why in its tag: `rate-limited` (a [level-2 rate
limit](#rate-limiting--level-2-domain-and-application)), `wrong client` (the
application's client IP list) or `wrong From` (see [Who an application may
send as](#who-an-application-may-send-as)); a row refused before `2.4.0`
application's client IP list), `wrong From` or `wrong Sender` (see [Who an
application may send as](#who-an-application-may-send-as)); a row refused before `2.4.0`
says `rejected`. A refused message's page shows what Postfix wrote about the
refusal: for `wrong From` the lines of the queue id the message had, for the
other two the one `NOQUEUE` line; a row refused before `2.5.1` says the line
refusal: for `wrong From` and `wrong Sender` the lines of the queue id the
message had, for the other two the one `NOQUEUE` line; a row refused before `2.5.1` says the line
was not kept, and one the panel wrote from `mail.log` after a restart - it
had stopped before writing the refusal itself - says so;
*Details* opens that row's own page (`/outbound/log/{id}`). That page carries
the sending domain, the application it was submitted under, the Postfix
queue id and the journal id, beside the message's history — when it was
accepted and what Postfix later reported for the recipient. A `deferred`
or `bounced` row includes this Postfix's retry intervals (first delay,
backoff cap, queue lifetime), the same numbers the Outbound queue shows; users
row includes this Postfix's retry intervals (first delay, backoff cap, queue
lifetime), a `bounced` one the queue lifetime, the same numbers the Outbound
queue shows; users
with the *domain* role see them here because they cannot open the queue. Under
both sit the `mail.log` lines for its queue id: the connection to the
receiving server, the server's reply, and the status that reply was filed
+1
View File
@@ -68,6 +68,7 @@ to the days the row's own message could still be in the queue; this is the
bound the roadmap item asked for. The search of the current file that the
page did before had the same exposure for a day. `enable_long_queue_ids`
would end it and is a change to the mail path, not part of this item.
*It was made in `2.10.1` (owner, 2026-10-16).*
### Retention
+100
View File
@@ -0,0 +1,100 @@
# inbound-upstream-result
**Status:** shipped `2.10.0` (2026-10-16).
## Problem
The owner, 2026-10-16: "Нам не хватает в журнале для Inbound ответа
upstream. Не понятна судьба письма: принято, отклонено, хост не доступен и
пр."
Inbound › Log shows what port 25 decided - accept, reject, tempfail - and
nothing after it. Its help says so ("Accept says what this server did, not
what the upstream did with the message afterwards"). The answer is in
`mail.log`: Postfix's `smtp` client logs every attempt to hand a message to
its upstream as `<id>: to=<…>, relay=<host>[ip]:port, …, dsn=…, status=sent
(250 …)` - or `deferred (451 4.7.1 Greylisted …)`, `deferred (connect to …:
Connection refused)` with `relay=none`, `bounced (… said: 550 …)` (Postfix
3.10.13, `src/global/log_adhoc.c`; `relay=none` when there was no session,
`src/smtp/smtp_trouble.c:273, 421`). The tailer reads those lines for the
outbound log and, finding no send-log row for a port-25 queue id, drops
them; the journal row does not even hold the queue id.
## Decision
The owner, asked whether the result goes in the row or on a page of its own
per message: "Пока только результат. Отдельная страница в план в случае
появления потребности." So: the row carries the result and the last reply;
no stored `mail.log` lines for port-25 mail (delivery-log-storage left them
out on purpose: a stranger sets the rate). A page per inbound message, with
every line, is for later, if the result is not enough.
## Design
- **Schema, migration 0012:** `inbound_spam_log` gains `queue_id`,
`upstream_status` (`''`, `queued`, `sent`, `deferred`, `bounced`),
`upstream_relay`, `upstream_reply` and `upstream_at`; an index on
`queue_id` for rows that have one. Rows written before have none and show
"—".
- **The milter** writes an accepted row with the queue id it already reads
at end-of-message (`{i}`, for the filter's signature) and `queued`. The
row is in the database before the message is queued, so before any
delivery line.
- **The tailer:** a delivery line whose queue id has no send-log row is
offered to the journal: the rows of that queue id and recipient take the
status, the relay (empty for `none`), the reply (printable, at most 255
bytes) and the line's time. One update per attempt, no new row; the
journal's caps stand. qmgr's `status=expired, returned to sender` makes a
queued or deferred row `bounced`, its reply saying it expired after the
last one.
- **The page:** an Upstream column after Decision (mockup `5348330`): a
plain `queued` tag, or a tag opening an "Upstream" sheet - host, reply,
when, and what follows for deferred and bounced. Refused rows "—".
- **Docs:** help (word for word with the mockup), guide § Inbound log,
architecture (journal and tailer), security (what is stored from the
upstream and at what rate), CHANGELOG.
## Reviews (2026-10-16)
A technical and a security review of the tree before the commit; what each
finding became:
- *A quoted RCPT TO* (both): the milter kept `"owner"@x` as given, every line
says `owner@x` - the row never matched. Now `recipientAsLogged`, as the
outbound milter does.
- *A forged line through a quoted local part* (security, medium): `to=<…>`
was read up to its first `>`; a local part in quotes may hold `>`, `,`,
`status=`. Port 25 refuses the one that would reach another row (an `@` in
the local part is a routed address: `src/trivial-rewrite/resolve.c:467`,
`src/smtpd/smtpd_check.c:1724`, `allow_untrusted_routing=no`), and the
pattern now reads addresses by Postfix's quoting all the same
(`loggedAddress`).
- *Held mail* (technical): a message the filter had held showed `queued`,
"not tried yet", for as long as it stayed held. Now `held`, a status of its
own (the CHECK of 0012, not shipped yet).
- *Rows that never close, and queue ids that repeat* (both): before writing
a message's rows the milter detaches older rows of its queue id; cleanup's
`removed (canceled)` detaches rows of a message never queued; an empty
`{i}` writes no result.
- *A bounce of null-sender mail* (technical): "The sender was sent a
bounce" is wrong there - Postfix does not bounce a bounce. The sheet says
so.
- Accepted, said in security.md: a row whose insert waited past the milter's
30 s timeout can miss its first line and stay `queued`. Not of this row:
an old send-log row of a reused queue id and the same recipient takes the
line first (sendlog.go, as before).
## Steps
- [x] Migration 0012, store: write the queue id, update by queue id and
recipient, read the result back.
- [x] Milter: the queue id and `queued` on accepted rows.
- [x] Tailer: delivery and expiry lines of port-25 mail to the journal.
- [x] View, template, help; tests (store, milter, tailer, view).
- [x] e2e: a relayed message's row shows `sent` with the sink's `250`; an
upstream that refuses connections shows `deferred` with the reason.
- [x] Docs. *Done (2026-10-16), with the reviews' findings above.*
- [x] Roll out, read the server's journal; cut `2.10.0`. *Done (2026-10-16): on the
test server as 2.9.1-post.d515493, a message to selfpost@dev.mixdep.ru through
port 25 - its row sent, mc-ent.mixfed.ru's "250 2.0.0 Ok: queued as
04F0D1023EF" on it.*
+42 -8
View File
@@ -1,6 +1,6 @@
# Plan: panel-dkim-signing
**Status:** agreed, 2026-10-15 - the row is agreed (owner, 2026-10-15, the
**Status:** shipped `2.8.0` (2026-10-16); agreed 2026-10-15 - the row is agreed (owner, 2026-10-15, the
order port25-bare-newline -> base-trixie -> panel-dkim-signing); three
questions below were settled one at a time the same day.
**Date:** 2026-10-15
@@ -171,6 +171,9 @@ dropped connection (a one-hour watchdog closes the signer).
- [x] Keys: `/data/opendkim/keys` -> `/data/dkim/keys` (Q2): the entrypoint
copies the tree once and leaves the old one for a rollback to 2.7.x until
the next release; KeyTable and SigningTable go with the new layout.
*2.9.0:* the entrypoint removes the old tree once `/data/dkim` holds a
key, and stops on a `/data` with keys under the old tree only; the copy
and the backup exclusion are gone.
- [x] Tests: `test/upstream/*.sh`, e2e's OpenDKIM log and process checks,
the 256-byte test rewritten for the new rule.
@@ -199,16 +202,38 @@ libmilter1.0.1.
### Stage 3 - documents, the canary, the cut
- [ ] architecture § OpenDKIM -> § DKIM signing; security.md (the
- [x] architecture § OpenDKIM -> § DKIM signing; security.md (the
mail-path bullets, the accepted risks that were OpenDKIM's); guide;
development's cited-package table (opendkim and libopendkim11 leave);
the assumptions register's header notes what left; help (design commit
first) and Health's label (Q3, mockup first).
- [ ] Reviews: technical and security.
- [ ] On the test server: a day of the owner's mail; one message from an
- [x] Reviews: technical and security.
*Done (2026-10-16):* help and Health went with stage 2 (design `005922b`);
the reviews ran on stages 1-2 and their findings are fixed in `c2f63d2`,
with the first rollout's incident (the queue's owner numbers); the
documents in the commit after it. `Sender:` is held to the login like
`From:` (security review F1; the owner's decision, 2026-10-16). go-milter
v0.4.1 kept every header in a map of its own per session, on 465/587 and on
port 25 alike: the panel builds it patched not to
(`third_party/go-milter/PATCHES.md`; the owner's decision, 2026-10-16).
- [x] On the test server: a day of the owner's mail; one message from an
application through 465 and one notification to a real mailbox, the
headers read (dkim=pass with the panel's `d=`).
- [ ] Cut `2.8.0`.
- [x] Cut `2.8.0`.
*Done (2026-10-16):* the owner's message through 465 (`dev/send-check.ps1`)
arrived at Gmail with `dkim=pass header.i=@carrel.mixfed.ru
header.s=selfpost`, `spf=pass`, `dmarc=pass`, and at Yandex with
`dkim=pass` and `X-Yandex-Spam: 1`; its `h=` over-signs the shown headers.
His `dev/from-check.ps1` sent thirteen `From:`/`Sender:` cases: four
accepted and delivered, nine refused as expected, each a send-log row of
reason `from` or `sender`. The notification to a real mailbox was not
sent - nothing raised one; its signature is verified by dkimpy on every e2e
run, and the server name's key, unchanged by the move (digests compared),
matched its published record on 2026-10-15. The day of mail was cut short
at the owner's word ("Выпускай", 2026-10-16), the test server having run
the stage's code since 2026-10-15 22:48 UTC. Cut as `2.8.0`, no tag.
## Risks
@@ -218,6 +243,15 @@ libmilter1.0.1.
- **go-msgauth is small and released slowly.** It can be vendored if it
ever matters; the signing path is about 300 lines of it.
- **Rollback:** until stage 2 ships, the chain change is one line; after
the cut, the previous image (2.7.x) still has OpenDKIM and reads the same
keys - under Q2's rename, from the old path, which the move leaves as a
copy until the next release.
the cut, the previous image (2.7.x) still has OpenDKIM and reads the keys
from the old path, which the move leaves as a copy until the next release
- the keys as they were at the upgrade: a domain added under 2.8.x is not
in that tree, and 2.7.x sends its mail unsigned; one re-added under 2.8.x
has another key there. 2.7.x reads the queue as its own postfix (uid
101) before it hands it over, and 2.8.0 left it to uid 100: the guide
(§ Upgrading) gives the hand-over by name to run in the 2.7.x image first,
measured on a copy of the test server's queue. Going forward again after
a rollback, a domain added under 2.7.x has its key only in the old tree:
copy its directory into `/data/dkim/keys` (removing `/data/dkim` instead
copies the old tree again and loses the keys of domains added under
2.8.x).
+1 -1
View File
@@ -182,7 +182,7 @@ list, an action under the thing it changes.
| system-log | `GET /server/log` | `/system-log` |
| backup | `GET, POST /server/backup` · `POST /server/backup/import` | `/backup` · `POST /domains/import` |
| users · user · user-delete | `/server/users` · `…/new` · `…/{uid}` · `…/{uid}/delete` | `/users/…` |
| settings | `GET, POST /server/settings` | the instance half of `/settings`: log retention, rate limits |
| settings | `GET, POST /server/settings` · `POST …/dns-recheck` | the instance half of `/settings`: log retention, rate limits |
| account | `GET /account` · `POST /account/profile`, `/password` | `/settings`: credentials and the DMARC default (today `/account` redirects there) |
| help · components | `GET /help` · `GET /server/components` | `/help` · — |
+5 -4
View File
@@ -41,8 +41,8 @@ decision:
are not a current target but not ruled out forever.
2. **Send from your own IP (DIY)** — no intermediate relay provider. Requires
outbound port 25, static IP, and configurable PTR/rDNS from the operator.
3. **Single container** — Postfix, OpenDKIM, and the panel run under one
`supervisord` inside one image.
3. **Single container** — Postfix and the panel, which signs outgoing mail
with DKIM itself, run under one `supervisord` inside one image.
4. **Panel is internet-facing** — mandatory security requirements in
[security.md](security.md).
@@ -62,8 +62,9 @@ Explicitly excluded to prevent scope creep:
- Organisations / tenancy beyond the `global` and `domain` roles; managing
**multiple sending domains** under one global administrator is in scope (see
below)
- Inbound antispam/antivirus engines (rspamd, ClamAV, etc.) — SelfPost may
expose a milter hook; it does not ship or start a filter
- Inbound antispam/antivirus engines (rspamd, ClamAV, etc.) — the image
ships no filter, only a milter hook; `deploy/antispam` offers an optional
rspamd sidecar that the panel's filter lists feed
- A custom MTA — Postfix is used as-is
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only
+147 -11
View File
@@ -28,24 +28,26 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| ID | Topic | Status | Progress | Plan |
|---|---|---|---|---|
| health-setup-checks | Server › Health also checks the certificate's name, Postfix's greeting and the trusted proxy (was preflight) | agreed (2026-10-13) | — | — |
| test-email | Send a test message from the panel (was preflight) | agreed (2026-10-13) | — | — |
| inbound-upstream-result | Inbound › Log shows what the upstream answered: sent, deferred, bounced, with its last reply | shipped `2.10.0` | 7/7 | [plans/inbound-upstream-result.md](plans/inbound-upstream-result.md) |
| health-setup-checks | Server › Health also checks the certificate's name, Postfix's greeting and the trusted proxy (was preflight) | shipped `2.11.0` | — | — |
| test-email | Send a test message from the panel (was preflight) | shipped `2.12.0` | — | — |
| inbound-antispam-panel | Inbound antispam journal + allow/deny lists | shipped `2.1.0` | 12/12 | [plans/inbound-antispam-panel.md](plans/inbound-antispam-panel.md) |
| origin-strict | Refuse a state-changing request that carries neither `Sec-Fetch-Site` nor `Origin` (was csrf-tokens) | agreed (2026-10-13) | — | — |
| backup-reauth | Ask for the account password before a backup or a domain export is downloaded | agreed (2026-10-13) | — | — |
| origin-strict | Refuse a state-changing request that carries neither `Sec-Fetch-Site` nor `Origin` (was csrf-tokens) | shipped `2.10.4` | — | — |
| backup-reauth | Ask for the account password before a backup or a domain export is downloaded | shipped `2.10.3` | — | — |
| review-2026-08-followups | Remaining findings of the 2026-08-19 code review | closed 2026-10-14 (rows placed in [audit-2026-10-12](audit-2026-10-12.md)) | — | — |
| panel-notifications | E-mail notifications about important events | shipped `2.3.0` | 10/10 | [plans/panel-notifications.md](plans/panel-notifications.md) |
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | shipped `2.2.0` | 8/8 | [plans/delivery-log-storage.md](plans/delivery-log-storage.md) |
| password-reset | Password reset by e-mail | agreed (2026-10-13) | — | — |
| local-time | The panel shows time, and counts its days, in a time zone other than UTC | agreed (2026-10-13; design questions open) | — | — |
| notification-mail-recheck | The Notification mail records on Server › Settings can be checked again from where they are shown | agreed (2026-10-15) | — | — |
| settings-save-row | Server › Settings: the Save button no longer stands alone under a long right column | agreed (2026-10-15) | — | — |
| dns-record-collapse | A DNS record that is right is shown folded: its name and its tag, the values on request | agreed (2026-10-15) | — | — |
| local-time | The panel shows time, and counts its days, in a time zone other than UTC | shipped `2.13.0` | — | — |
| notification-mail-recheck | The Notification mail records on Server › Settings can be checked again from where they are shown | shipped `2.9.0` | — | — |
| settings-save-row | Server › Settings: the Save button no longer stands alone under a long right column | shipped `2.9.0` | — | — |
| dns-record-collapse | A DNS record that is right is shown folded: its name and its tag, the values on request | shipped `2.9.0` | — | — |
| go-milter-upstream | Prepare an issue and a PR for go-milter: its header map outlives a message and is unbounded | prepared 2026-10-16, the owner posts | — | [third_party/go-milter/PATCHES.md](../third_party/go-milter/PATCHES.md) |
| own-mta-builds | Analysis: build Postfix and OpenDKIM ourselves, patched, instead of Debian's packages | analysed 2026-10-15: no own builds; three rows proposed instead | — | [own-mta-builds-2026-10-15.md](own-mta-builds-2026-10-15.md) |
| port25-bare-newline | The inbound relay's port 25 refuses bare newlines in DATA (`smtpd_forbid_bare_newline=normalize`) | shipped `2.6.1` | — | — |
| base-trixie | The image's base from Debian bookworm (LTS since 2026-07-12) to trixie: Postfix 3.10 with the 2026 security batches | shipped `2.7.0` | — | — |
| panel-dkim-signing | DKIM signing in the panel's own milter; OpenDKIM, the syslog relay and the parser port removed | agreed (2026-10-15; stages 1-2 done) | 10/14 | [plans/panel-dkim-signing.md](plans/panel-dkim-signing.md) |
| phone-desktop-mode | A phone browser in "desktop site" mode gets the phone navigation | agreed (2026-10-13: after stages 1 and 2 of audit-2026-10-12) | — | — |
| panel-dkim-signing | DKIM signing in the panel's own milter; OpenDKIM, the syslog relay and the parser port removed | shipped `2.8.0` | 14/14 | [plans/panel-dkim-signing.md](plans/panel-dkim-signing.md) |
| phone-desktop-mode | A phone browser in "desktop site" mode gets the phone navigation | shipped `2.10.2` | — | — |
| from-header-binding | Hold a message's `From:` header against the application that sends it | shipped `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) |
| panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
@@ -63,7 +65,7 @@ stages 1 and 2 as `2.5.0` the same day, stage 2b as `2.5.1`, stage 2c as
`2.6.0` the same day; stage 6 (documents and tests, no cut) closed the
audit. Of the three rows from [own-mta-builds](#own-mta-builds),
**port25-bare-newline** shipped as `2.6.1` the same day and
**base-trixie** as `2.7.0`; panel-dkim-signing follows. What is agreed after it is marked so in the index; every other
**base-trixie** as `2.7.0`, and **panel-dkim-signing** as `2.8.0` (2026-10-16). **dns-record-collapse**, **settings-save-row** and **notification-mail-recheck** shipped together as `2.9.0` the same day, with `/data/opendkim` removed as panel-dkim-signing planned, then `2.9.1` (log panes on a phone) and **inbound-upstream-result** as `2.10.0`. What is agreed after it is marked so in the index; every other
remaining item is a candidate. panel-docs shipped in
[CHANGELOG.md](../CHANGELOG.md) `[1.8.0]`; dmarc-reports in `[1.7.0]`
(its ingest path first ran in `2.0.0` and was reviewed on 2026-10-11, see
@@ -174,6 +176,18 @@ mockup `preflight` stays drawn and unbuilt.
wrong certificate name, a wrong greeting and an untrusted proxy, and right on
the test server.
**Done (2026-10-16):** mockups `b2dcefc`, `cc33890`, `0feaa55` (the owner:
the greeting on a row of its own, the name check a tag beside *Names*).
The certificate: `health.Certificate.ForHost` - DNS names as a TLS client
matches them, the CN only without any, as OpenSSL and so a sending Postfix
still does - fails a certificate for another name. The greeting:
`postconf -x -h smtpd_banner`, read once at the start (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. The
proxy: `auth.Module.Seen` - an `X-Forwarded-For` left unread because the
peer is not trusted warns, with the `/32` (or `/128`) to set. Wrong cases in
unit tests, right ones in e2e (`testFormPostWithTheBrowsersOrigin`).
**Version:** `2.x` MINOR; `agreed`.
---
@@ -197,6 +211,13 @@ the mockup; mockup-first.
rate-limited and refuses a recipient list, and it passes a security review -
a form that sends mail is a new surface.
**Done (2026-10-17):** from `postmaster@` the hostname (the owner,
2026-10-16: "Да, адрес годится"), card 09 on Health in the static stack
(mockups `e92769f`, `e3b2501`, `8f0ccf9`). The row is written before the
hand-over and learns its queue id from cleanup's Message-ID line
(`logtail.AwaitMessageID`); the browser lands on it. One address
(`validate.AccountEmail`), ten an hour from the send log, global role.
**Version:** `2.x` MINOR; `agreed`.
---
@@ -222,6 +243,12 @@ panel without a browser (the e2e stand, scripts) and make it send `Origin`.
it, the e2e stand passes, the accepted risk about header-less requests is
struck from [security.md](security.md) and its ADR states the strict rule.
**Done (2026-10-16):** `originAllowed` returns false in the "neither
header" branch; the unit test's case is refused, e2e posts with neither
header and gets 403, and the e2e suite's client sends `Origin` on every
form it posts. The accepted risk is struck from security.md and the ADR's
decision says refused; architecture and the guide say so.
**Dependencies / risks:** a browser that sends neither header can no longer
save anything in the panel - accepted, the panel's users pick their own
browsers. Tokens stay a recorded alternative, not a task: the conditions under
@@ -256,6 +283,13 @@ container.
with a test that proves it, attempts are limited, and
[security.md](security.md) § Backup says so.
**Done (2026-10-16):** "Your account password" in both forms (mockup
`1888dce`), checked by `reauth` in `internal/web/handlers` with bcrypt and
`AllowPasswordChange` (ten in fifteen minutes per user, shared with the
Account form); a wrong or missing one is 401 on the same page, logged, and
nothing is sent. Handler and e2e tests; security.md § Backup and domain
export, guide § Full backup and restore, help.
**Version:** `2.x` MINOR; `agreed`.
---
@@ -491,6 +525,26 @@ the database's stored instants and the e2e stay as they are.
**Version:** `2.x` MINOR; `agreed`; the plan is written when it is taken up.
**Where the zone comes from, changed (owner, 2026-10-17):** not a setting
in the panel but the container's own zone, `TZ` in `.env`; Settings only
shows it ("Да" to "пояс в `.env`, на Settings только показ?"). His reason
was the trap above: a container cannot change its zone without a restart,
and only the container's zone moves Postfix's stamps with the rest. So the
trap is closed by the third way - the container runs in the zone, Postfix
stamps `mail.log` in it (`TZ` is in Postfix's `import_environment`), and the
tailer reads the stamps in the same zone, as it always did (`lineTime`,
`time.Local`).
**Done (2026-10-17):** design c205106 (no "UTC" on any screen, Settings box
05 *Time zone*, read-only, the help topic). The panel formats every time in
`time.Local`; the day tables key their rows by the local date, the sender
check cuts downtime at local midnights, the log filters' days run from local
midnight; stored instants stay UTC. Notification mail and the test message
write the zone's abbreviation after a time (they are read away from the
panel). The year question settled as the mockups have it. `tzdata` is named
in the image. Tests run in UTC (a `TestMain` in each package that formats
or counts days) and in Moscow where the zone is the point.
---
## phone-desktop-mode
@@ -524,6 +578,23 @@ expected there. A design change is mockup-first
has one coherent layout, checked on a phone in "desktop site" mode and in a
980 px window.
**Cause (2026-10-16):** not the mode - every width under 1024 px. The bar's
menu was always open (`navbar-menu is-active`, and no burger to close it),
and Bulma lays a navbar menu out below 1024 px as a block with every
dropdown open: on any phone the sections and all their pages filled the
first screen. "Desktop site" makes the page about 980 px wide, where Bulma
already puts columns side by side (from 769 px) but not yet the bar - the
mix in the screenshot. And a Bulma `.container` has side margins only from
1024 px, so below it the page met the screen's edge.
**Done (2026-10-16):** the owner chose "Вариант 1" - a menu behind a button
- with "сворачивание разделов". Below 1024 px the bar's menu is hidden and
*Menu* at the right of the brand opens a panel over the page: Overview, each
section a fold with the current one open, the user's last (Account, Help,
Sign out); `<details>` all through, no script. The page and the strip of
sibling pages have a 1rem gutter. From 1024 px nothing changed. Mockup
`e0e30e2`.
**Version:** `2.x` PATCH or MINOR; `agreed` (owner, 2026-10-13), taken up
after stages 1 and 2 of [audit-2026-10-12](audit-2026-10-12.md) - mail shown
wrong and the tenant boundary come before layout.
@@ -729,6 +800,16 @@ and closes without it under the panel's CSP. Whether a fold remembers being
opened across the polls of a page, and whether *Re-check DNS* folds a record
that has just turned right, are for the mockup.
**Done (2026-10-16):** a record whose tag is *published* and that has no
*In DNS now* is a `<details>` whose summary is the head: name, tag and
*Show values* (the eye of the icon subset - it has no chevron), the
values behind it. The head's note is hidden while folded, or a long one
(the server's SPF in a half column) wraps the fold to two lines; it shows
when opened. The two questions answered themselves: no page that draws
records polls them, and *Re-check DNS* posts and draws the page again, so a
fold is never kept across a refresh and a record that has just turned
right comes back folded.
## notification-mail-recheck
**Problem (owner, 2026-10-15):** Server › Settings shows the DNS records of
@@ -745,6 +826,15 @@ the check is and how long a result is kept. Design change, mockup first;
decide together with [dns-record-collapse](#dns-record-collapse) (a record
that turns right on the re-check folds).
**Done (2026-10-16, the owner: "Да, бери notification-mail-recheck"):**
*Re-check DNS* in the page head of Settings - where an outbound domain's
page and Health put the same action; a box head holds a note, a tag, a
link or a filter form - posting to `POST /server/settings/dns-recheck`
(global role), which re-runs Health's lookups (the server's address and
`serverMail(true)`) and returns to Settings with "DNS re-checked."; the
Notification mail box's end slot reads "The server's own name · checked
3 min ago". A record that has turned right comes back folded.
## own-mta-builds
**Question (owner, 2026-10-15):** is it worth patching Postfix and OpenDKIM
@@ -875,3 +965,49 @@ the records that are right, or the page changes - the records out of the
form's columns, or the button nearer its fields. Decide with the mockup,
after dns-record-collapse, on a page whose records are missing as well as
on one whose records are right.
**Done (2026-10-16):** shown both layouts in both states, the owner chose
"Вариант 2: форма в левой колонке, кнопка под своими полями." Folding alone
closed the gap only while the records are right. The form is now the left
column - 01 Log retention, 02 Notifications, *Save settings* - and the
right column, outside it, holds 03 Notification mail and 04 Sending rate
limits.
## go-milter-upstream
**Problem (owner, 2026-10-16):** the panel builds go-milter v0.4.1 patched
(`third_party/go-milter`, its `PATCHES.md`): upstream adds every header of
a session to a map it hands to `Headers` and empties only on ABORT. Two
things follow, for every user of the library: the map of a second message
on one connection still holds the first message's headers (SelfPost met it
as audit 2026-10-12, L9, and keeps its own headers since), and the map is
bounded by no limit of its own - a message of nothing but headers, up to
the MTA's message size limit, is held whole in each milter. Upstream had no
issue about it on 2026-10-16; its last commit was 2026-04-04.
**Goal:** an issue and a PR, prepared here and posted by the owner from his
GitHub account (nothing is sent from this side):
- the issue: both effects, each with a reproduction through the library's
own client (two messages on one connection; the map's size at EOH);
- the PR, small and compatible: the map emptied when a message starts
(`CodeMail`), as it is on ABORT - this fixes the first effect and bounds
the second to one message;
- in the issue, as a proposal: an option of `Server` not to keep the map,
for a milter that reads headers through `Header` alone - what SelfPost's
patch does unconditionally, which upstream cannot take as it is (it would
break backends that read the map).
**Done when:** the texts and the branch are ready for the owner to post.
*Prepared (2026-10-16):* in `dev/upstream-pr` (not in git): `ISSUE.md`,
`PR.md`, a README with the steps, and a clone of go-milter on branch
`reset-headers-per-message` - one commit over `master` (0277691) that
empties the map on SMFIC_MAIL, with `TestMilterClient_HeadersPerMessage`,
which fails on `master` (the second message sees both `From` values)
and passes with it. Read for the issue: Postfix carries every message of
one SMTP session over the one milter connection smtpd opened (Postfix
3.10.13, src/milter/milter8.c:2723, LOCAL_SEND_FD of the milter socket to
cleanup; src/smtpd/smtpd.c:3475, milter_send()).
When upstream releases a version that does not keep the map (or offers the
option), `third_party/go-milter` goes and `go.mod`'s `replace` with it.
+10 -8
View File
@@ -17,8 +17,8 @@ When you add or review a migration, update this file in the same change.
| Field | Value |
|---|---|
| Chain head | `user_version = 11` |
| Files | `0001_init.sql` (the 2.0 baseline), `0002_inbound_spam_log.sql`, `0003_send_log_lines.sql`, `0004_notifications.sql`, `0005_send_log_rejected_index.sql`, `0006_sender_check.sql`, `0007_delivery_log_days.sql`, `0008_tenant_boundary.sql`, `0009_refusal_line.sql`, `0010_send_log_indexes.sql`, `0011_notify_addressees.sql` |
| Chain head | `user_version = 12` |
| Files | `0001_init.sql` (the 2.0 baseline), `0002_inbound_spam_log.sql`, `0003_send_log_lines.sql`, `0004_notifications.sql`, `0005_send_log_rejected_index.sql`, `0006_sender_check.sql`, `0007_delivery_log_days.sql`, `0008_tenant_boundary.sql`, `0009_refusal_line.sql`, `0010_send_log_indexes.sql`, `0011_notify_addressees.sql`, `0012_inbound_upstream.sql` |
| File mark | `PRAGMA application_id = 0x53503230` ("SP20") |
| Database file | `/data/selfpost.db` (bind mount) |
| Compatibility | 2.x MINOR releases must boot a `2.0.0` data directory. **Nothing is carried over from 1.x.** |
@@ -69,7 +69,8 @@ delivery-log-storage (`2.2.0`), `0004_notifications.sql` and
`0007_delivery_log_days.sql` and `0008_tenant_boundary.sql` of the 2026-10-12
audit (`2.5.0`), `0009_refusal_line.sql` (`2.5.1`) and
`0010_send_log_indexes.sql` (`2.5.3`) and `0011_notify_addressees.sql`
(`2.5.4`).
(`2.5.4`), and `0012_inbound_upstream.sql` of inbound-upstream-result
(`2.10.0`).
---
@@ -85,9 +86,10 @@ audit (`2.5.0`), `0009_refusal_line.sql` (`2.5.1`) and
| 6 | `0006_sender_check.sql` | 2.4.0 | DDL | `send_log.reason` and `send_log.header_from` (both default `''`: a row refused earlier keeps no reason), and `sender_check_days` |
| 7 | `0007_delivery_log_days.sql` | 2.5.0 | DDL | `delivery_log_days`: what the reconcile sweep and the log tailer did, a row per UTC day, kept a year, shown nowhere - the measurement that decides the sweep's fate (audit 2026-10-12, stage 1). `send_log.reason` gains the values `reconcile` and `reconcile-contradicted` without a schema change |
| 8 | `0008_tenant_boundary.sql` | 2.5.0 | DDL + data | `idx_applications_login_nocase`: an application login is unique without regard to case (audit 2026-10-12, H4). `send_log.domain_id` and `inbound_spam_log.inbound_domain_id` (`ON DELETE SET NULL`), each with an index, filled once by name from the registry as it was then: a row belongs to a domain by id, and a domain deleted and added again starts with an empty log (L12) |
| 9 | `0009_refusal_line.sql` | 2.5.1 | DDL | `send_log.refusal_line`: Postfix's line about a refusal at MAIL FROM (a level-2 limit, a client address off the list), which has no queue id; the log tailer puts it on the row; `send_log.recovered`, a refusal row the tailer wrote from `mail.log` because the panel stopped before writing it; `sender_check_days.messages` (messages deferred mid-message, counted apart from the connections turned away) and `sender_check_days.estimated` (a day whose downtime is an upper bound) (audit 2026-10-12, stage 2b) |
| 9 | `0009_refusal_line.sql` | 2.5.1 | DDL | `send_log.refusal_line`: Postfix's line about a refusal at MAIL FROM (a level-2 limit, a client address off the list), which has no queue id; the log tailer puts it on the row, found by `send_log.client_ip`, the client the refusal came from; `send_log.recovered`, a refusal row the tailer wrote from `mail.log` because the panel stopped before writing it; `sender_check_days.messages` (messages deferred mid-message, counted apart from the connections turned away) and `sender_check_days.estimated` (a day whose downtime is an upper bound) (audit 2026-10-12, stage 2b) |
| 10 | `0010_send_log_indexes.sql` | 2.5.3 | DDL | Four indexes of `send_log`, each for a question that walked the table or sorted a domain's log (audit 2026-10-12, M2, stage 3): `idx_send_log_open`, partial, the queued and deferred rows by time (the tailer's sweep every 5 min); `idx_send_log_domain_id` and `idx_send_log_app_login`, a page of one domain's or one application's log in id order; `idx_send_log_refusal_line`, partial, whether a refusal line is already on a row. `TestSendLogQueriesUseTheirIndexes` checks the plans of these and of the other by-time and by-scope statements the store runs |
| 11 | `0011_notify_addressees.sql` | 2.5.4 | DDL | `notify_addressees (key, address, told_at)`: who a subject's notification is owed to and who was handed it, so a copy that failed or was held back by the hourly ceiling goes out at a later check and a subject's end goes to those told (audit 2026-10-12, stage 4); deleted with its `notify_state` row |
| 12 | `0012_inbound_upstream.sql` | 2.10.0 | DDL | `inbound_spam_log` gains `queue_id`, `upstream_status` (`''`, `queued`, `held`, `sent`, `deferred`, `bounced`; CHECK), `upstream_relay`, `upstream_reply` and `upstream_at`, and the partial index `idx_inbound_spam_log_queue_id` on rows that have a queue id: what the upstream answered about mail accepted on port 25, put on the row by the log tailer (inbound-upstream-result). Rows from before have none |
**Kind:** *DDL* — schema only; *data* — `INSERT`/`UPDATE` that must stay correct
for operators upgrading from older 2.x images.
@@ -106,7 +108,7 @@ for operators upgrading from older 2.x images.
| `applications` | SASL applications per domain, with the client-IP restriction (`auth_ip_restrict`, `auth_allowed_ips`) |
| `application_addresses` | Explicit From addresses (`list` mode) |
| `rate_limits` | Level-2 limits per domain / application, manual or auto |
| `send_log` | Delivery journal. `reason` says why a row was refused (`limit`, `client`, `from`) or that the sweep closed it (`reconcile`, `reconcile-contradicted`), `header_from` what the From: line of a message refused for it said (v6), `refusal_line`, `client_ip` and `recovered` (v9), and `domain_id` (v8: the row belongs to a domain by id, `ON DELETE SET NULL`) |
| `send_log` | Delivery journal. `reason` says why a row was refused (`limit`, `client`, `from`, and `sender` since 2.8.0) or that the sweep closed it (`reconcile`, `reconcile-contradicted`), `header_from` what the From: or Sender: line of a message refused for it said (v6), `refusal_line`, `client_ip` and `recovered` (v9), and `domain_id` (v8: the row belongs to a domain by id, `ON DELETE SET NULL`) |
| `logtail_state` | Log-tailer read position |
| `inbound_domains` | Inbound relay domains |
| `inbound_transports` | Upstream host / port / TLS per inbound domain |
@@ -114,11 +116,11 @@ for operators upgrading from older 2.x images.
| `user_inbound_domains` | Inbound domains assigned to a `domain` user — granted separately from the outbound ones |
| `dmarc_reports` | Parsed aggregate report summaries |
| `dmarc_report_records` | Per-source rows inside a report |
| `inbound_spam_log` | Inbound antispam journal: one row per recipient of a message offered on port 25, with the decision (`accept`, `reject`, `tempfail`, `quarantine`) and who made it (`rspamd`, `postfix`, `selfpost`). Metadata only. `inbound_domain_id` (v8) ties a row to its domain by id (v2) |
| `inbound_spam_log` | Inbound antispam journal: one row per recipient of a message offered on port 25, with the decision (`accept`, `reject`, `tempfail`, `quarantine`) and who made it (`rspamd`, `postfix`, `selfpost`). Metadata only (v2). `inbound_domain_id` (v8) ties a row to its domain by id; `queue_id` and `upstream_status`, `upstream_relay`, `upstream_reply`, `upstream_at` (v12) are what the upstream answered for an accepted message |
| `inbound_spam_list` | Allow / deny entries for the external filter (`address`, `domain`, `ip`), instance-wide; unique per list (v2) |
| `notify_state` | What the panel has already written to its users about, one row per subject (`dns:<domain>`, `cert`, `queue`, `limit:<domain>`), so that each thing is said once and a restart says nothing again (v4) |
| `sender_check_days` | What the sender check costs, a row per UTC day that has something to say: seconds the panel did not answer while Postfix ran, connections turned away (`deferred`), messages deferred mid-message (`messages`), and whether the day's downtime is an upper bound (`estimated`). Kept a year (v6, v9) |
| `delivery_log_days` | What the reconcile sweep and the log tailer did, a row per UTC day, kept a year. Read off a copy of the database; no page shows it (v7) |
| `sender_check_days` | What the sender check costs, a row per day of the container's zone (`TZ`) that has something to say: seconds the panel did not answer while Postfix ran, connections turned away (`deferred`), messages deferred mid-message (`messages`), and whether the day's downtime is an upper bound (`estimated`). Kept a year (v6, v9) |
| `delivery_log_days` | What the reconcile sweep and the log tailer did, a row per day of the container's zone, kept a year. Read off a copy of the database; no page shows it (v7) |
| `notify_addressees` | Who a subject's notification is owed to and whether each was handed it; goes with the subject's `notify_state` row (v11) |
| `send_log_lines` | The `mail.log` lines Postfix wrote under a queue id, with the line's own time; what a message's page shows once the log has rotated. No reference to `send_log`: a line belongs to a row by queue id and time, and goes when no row carries its id (v3) |
+258 -85
View File
@@ -52,7 +52,19 @@ under Accepted risks (the allow list keyed on an address anyone can claim, the
list files readable by anyone). A third it named - with the filter down, a
sender's own verdict headers were recorded as the filter's - was closed the
same day at the owner's suggestion: the filter signs each message and a
verdict is read only next to its signature.
verdict is read only next to its signature. The 2026-10-16 security review of
panel-dkim-signing (`2.8.0`: the panel's own DKIM signer, OpenDKIM removed)
found no path by which an application gets another tenant's key or the
server's, or a valid signature over a `From:` a mail program reads as another
domain; no Critical or High. The signer held a message's whole header section,
bounded only by `message_size_limit` (now 1 MiB, refused above it); the old
key tree stayed readable by group `selfpost` and went into every backup (now
the panel's alone and left out); a hostname with capitals deferred every
notification (the server's name is kept in lower case for DKIM); only `From`
was over-signed (now the shown headers too) - all fixed. `Sender:`, which
the review found neither held nor signed (F1), is now both: signed, and held
to the login like `From:` (the owner's decision, 2026-10-16). What the header
cap leaves is under Accepted risks.
(2) **Accepted risks** —
deliberate departures beyond the mandatory, recorded so the decision is not
lost.
@@ -192,6 +204,8 @@ assigned outbound domains and inbound domains **separately**, each list with
host (the panel's `Referrer-Policy` is `same-origin`, so a browser puts the
panel's own origin on a form post of the panel); the session cookie is
`SameSite` as well.
- A request with neither header is refused as well (origin-strict,
`2.10.4`).
- There are **no CSRF tokens**. The decision, its price and the conditions
for revisiting it are in the accepted risks and the ADR below.
@@ -211,8 +225,24 @@ operator can act on it.
- The panel process is **not root** (`user=panel` in supervisord); path access
is granted through the `selfpost` group with minimal permissions. The
supervisord control socket, which can stop or restart any program, is the
`supervisor` group's, whose one member is the panel; OpenDKIM and Postfix
share `selfpost` for files and do not reach it (`2.6.0`).
`supervisor` group's, whose one member is the panel; Postfix shares
`selfpost` for files and does not reach it (`2.6.0`; OpenDKIM shared
`selfpost` too until `2.8.0`).
- **Group `selfpost` (gid 10998) has two members, `panel` and `postfix`.**
Through it Postfix reads what the panel writes - `sasldb2` under
`/data/sasl` and the maps under `/data/postfix` (both 2750, files 0640) - and connects to the panel's
milter sockets in `/run/selfpost` (2750, sockets 0660); the panel reads
`mail.log`, which `postfix` writes, through it the other way
([build/Dockerfile](../build/Dockerfile), `build/entrypoint.sh`). The DKIM
keys are not in it (§ Mail path).
- **The container keeps `KILL`** (`deploy/docker-compose.yml`, `cap_add`;
every other capability not listed there is dropped). Without it root
cannot signal a process of another uid (kill(2), capabilities(7); measured
on the test server, 2026-10-16), and two root processes must: supervisord
stops and restarts the panel, and Postfix's master ends its children,
which run as `postfix`. Until `2.8.0` it also carried OpenDKIM's `USR1`
reload through supervisord, which went with OpenDKIM; the plan had taken
that for its only use, wrongly.
### Mail path (security-relevant)
@@ -226,50 +256,94 @@ operator can act on it.
application sent from. What a complaint needs stays with the operator - the
queue id in the header, the panel's log and smtpd's `client=` line in
mail.log. Port 25 keeps the full header, as RFC 5321 requires of an MTA.
- **An application signs only as itself (`2.4.0`).** OpenDKIM chooses the
key by the `From:` header and Postfix holds only the envelope sender
against the login, so until `2.4.0` an application of one sending domain
could write another registered domain - or, since `2.3.0`, the server's
own name - into `From:` and have the message signed with that name's key
(found by the security review of panel-notifications, 2026-10-12). The
panel's sender check now holds the header to the table the envelope is
held to and refuses anything else with `550 5.7.1`, a domain the server
does not sign for included (the owner's rule: the header may say what the
envelope may, nothing more). It is the first milter of the chain and
strict: a message it could not judge is deferred, never passed on to the
signer. Since `2.5.2` it reads the header with OpenDKIM's own parser,
`dkim_mail_parse()` ported line for line from the opendkim the image runs
(`internal/milter/mailparse.go`), and judges the address that parser
returns - the address OpenDKIM signs for - whatever a display name, a
comment or a quoted local part holds; one `From:` field and one address in
it stay required, and an address longer than the 256 bytes OpenDKIM
matches its `SigningTable` against is refused, since cut there it could end
in another domain's name (opendkim.c, `dkimf_apply_signtable()`,
`MAXADDRESS`). That the port is OpenDKIM's parser is tested against the C
itself, two hundred thousand lines and every result alike; that the signed
domain follows, by the e2e: it sends every `From:` line the check passes (a
list shared with the unit test,
`internal/milter/testdata/from-lines-passed.txt`) and wants it signed for
the application's domain and no other, and every
line of a second list naming another sender of the server in a quoted
string, a comment, a quoted pair, capitals or an encoded word
(`from-lines-other.txt`) must be refused or arrive signed for the
application's domain, never for that sender. What this does not promise is
what a display name looks like to a person: a name of another sender in
it passes, signed with the application's own key - a forged display name is
as old as mail, and no key of another's is involved. What the client wrote
reaches the reply, the log and the send log only as printable ASCII, cut,
and a refused message is one row of the log whatever its recipient count.
Mail the panel submits itself does not pass the check: only root,
`postfix` and the panel can submit locally. Architecture § Sender check.
- **The panel signs, with the key of the login's domain (`2.8.0`).** The
DKIM signer is a role of the panel process (`internal/milter/sign.go`,
`cmd/panel/signer.go`) on two sockets of its own, both attached strictly
(`default_action=tempfail`, `build/postfix-config.sh`). `sign.sock`,
second in `smtpd_milters` after the sender check, signs what applications
send on 465/587 with the key of the domain the SASL login belongs to:
`{auth_authen}`, looked up in the `sender_login_maps` file Postfix itself
reads (`Bindings.DomainOf`). `sign-local.sock`, the whole of
`non_smtpd_milters`, signs what is submitted with sendmail(1) with the key
of the server's own name. Nothing in a message chooses the key, and the
socket a message comes through - not a missing login - decides that the
server's key may sign it. What cannot be signed (a login with no domain, a
name with no key, a read error) is deferred, never sent unsigned. Port 25
has no signer. `relaxed/simple`, rsa-sha256; `h=` names every header of
RFC 6376 § 5.4.1's list the message has, and `Sender` and
`List-Unsubscribe-Post`, then once more each of the shown ones present
(Subject, Date, To, Cc, Reply-To, Sender) and `From` last, so one of those
added after signing breaks the signature. Until `2.8.0` OpenDKIM signed - a
2018 beta with three 2026 CVEs open and nobody upstream to fix them
([own-mta-builds](own-mta-builds-2026-10-15.md) § 1) - and chose the key
by the `From:` header; it left the image, and with it the syslog relay and
`/dev/log`, the port of its `From:` parser, its tables and their reload by
a signal across uids. Architecture § DKIM signing.
- **The DKIM keys are the panel's alone (`2.8.0`).** The key tree and the
selector table the signer reads, `/data/dkim/keys/<name>/<selector>.private`
and `/data/dkim/selectors` (`internal/domain`, `Keys`), are owned by
`panel`, directories 0700 and files 0600 - no group, no other user; the
signer reads them and never the database. The entrypoint sets these modes
at every start (e2e `checkDKIMTree` holds the tree to them). Until `2.8.0`
the keys were under `/data/opendkim`, 2750/0640 in group `selfpost` so
that OpenDKIM could read them - and so could every `postfix` process.
2.8.x kept that tree, the panel's alone, for a rollback to 2.7.x and copied
its keys over; since `2.9.0` the entrypoint removes it at start, and refuses
to start when the keys are only there (start 2.8.x once first). A root step
of the entrypoint follows no symbolic link the panel could plant in the
tree (`drop_links`; `rm -rf`, `chown -R` and `find` do not follow one).
- **An application signs only as itself (`2.4.0`).** The signature's `d=` is
the login's domain, and a receiver holds it to the domain of the `From:`
line (DMARC, RFC 7489 § 3.1.1): without a check of that line an
application could still send under the server's signature as another
domain of the server, or as the server itself. The panel's sender check
holds the header to the table the envelope is held to and refuses anything
else with `550 5.7.1`, a domain the server does not sign for included (the
owner's rule: the header may say what the envelope may, nothing more). It
is the first milter of the chain and strict: a message it could not judge
is deferred, and one it refuses never reaches the signer. The `From:`
line must be one field holding one address in RFC 5322's grammar as Go's
`net/mail` reads it - a mail program's reading; a line a lenient program
could show as another address (`ceo@bank.com <me@mine.com>`,
`<b@evil.com, a@mine.com>`) is refused - and that address, its local
part unquoted as Postfix unquotes the envelope's, is held against the
application's bindings; its domain is compared in ASCII lower case only,
so a look-alike such as the Kelvin sign for `k` is no letter of a
registered domain (`internal/milter/sender.go`, `judge`, `asciiLower`).
The unit test reads a list of lines naming another sender of the server in a quoted string, a
comment, a quoted pair, capitals or an encoded word
(`internal/milter/testdata/from-lines-other.txt`) and wants each refused
or read as the application's own address; the e2e sends every line of a
second list the check passes (`from-lines-passed.txt`, shared with the
unit test) and wants it signed for the application's domain and no other.
What this does not promise is what a display name looks like to a person:
a name of another sender in it passes, signed with the application's own
key - a forged display name is as old as mail, and no key of another's is
involved. Since `2.8.0` a `Sender:` line - shown beside `From:` as "on
behalf of" - is held to the same rule when there is one, and refused as
`550 5.7.1 Sender: ...`; `Resent-From:` is signed but not judged
(Accepted risks). What the client
wrote reaches the reply, the log and the send log only as printable ASCII,
cut, and a refused message is one row of the log whatever its recipient
count. Until `2.4.0` nothing held the header: OpenDKIM chose the key by it
and Postfix holds only the envelope sender against the login, so an
application of one sending domain could write another registered domain -
or, since `2.3.0`, the server's own name - into `From:` and have the
message signed with that name's key (security review of
panel-notifications, 2026-10-12). From `2.5.2` to `2.8.0` the check read
the line with OpenDKIM's own parser, ported line for line, and refused an
address over the 256 bytes OpenDKIM matched its `SigningTable` against;
with the key chosen by the login both went. Mail the panel submits itself
does not pass the check: only root, `postfix` and the panel can submit
locally (Accepted risks). Architecture § Sender check.
- **Inbound relay (optional, `INBOUND_RELAY_ENABLE`)** — port 25 is not an
open relay either: SASL is off; `smtpd_relay_restrictions` /
`smtpd_recipient_restrictions` are `reject_unauth_destination` and
`reject_unlisted_recipient`; maps list only configured domains and
recipients. Domains with no upstream host are omitted from the maps so mail
is never accepted with nowhere to send it. Prefer recipient mode `list` to
refuse unknown addresses at RCPT (no backscatter). OpenDKIM is not attached
on inbound. An optional antispam milter is inbound-only; default action is
refuse unknown addresses at RCPT (no backscatter). Nothing signs on port
25. An optional antispam milter is inbound-only; default action is
fail-open (`accept`). Since `2.6.1` the listener ends DATA only at the
standard `<CR><LF>.<CR><LF>` (`smtpd_forbid_bare_newline=normalize`, the
2023 SMTP-smuggling defence, which Postfix 3.7 left off by default): a
@@ -294,6 +368,29 @@ operator can act on it.
ask for it at all, an application with a client-address list is refused when
no address is known, and neither milter replies anything but "continue" to a
message it lets through. `test/e2e` sends two `MAIL FROM` on one connection.
- **The upstream's answer is an update of a row, not a row**
(inbound-upstream-result, `2.10.0`). A delivery line of mail accepted on
port 25 changes the result of that message's journal rows and adds
nothing: a stranger who sends more mail gets more rows from the milter, as
before, and each row at most one update per delivery attempt, at Postfix's
retry pace. The lines themselves of port-25 mail are still not kept. What
is written: the status and the time are Postfix's fields; the host is
Postfix's `relay=`, the reply the upstream's words - which may quote the
stranger's address - or Postfix's own about a host it could not reach, both
printable and cut to 255 bytes, shown in `sp-mono` cells like a refusal's
reply (escaped by html/template). The stranger's own text reaches the line
only as its `to=` and `orig_to=`, which is the key and nothing else: they
are read by Postfix's quoting (`loggedAddress`, `internal/logtail`), so a
quoted RCPT TO holding `>`, `,` or `status=` cannot end the address early
and pass fields of its own for another recipient's row - and one holding an
`@` is a routed address, which port 25 refuses as relaying
(`allow_untrusted_routing=no`). Queue ids do not repeat since `2.10.1`
(`enable_long_queue_ids=yes`, below); for a queue file from before, which
keeps its short name, the milter detaches older rows of a queue id before
writing a message's rows, so a message that is gone cannot take a new
one's results. Accepted: a row whose insert waited longer than the
milter's 30 s timeout - the database held - can miss its first delivery
line and stay `queued`.
- **The inbound journal is written from what strangers send.** The inbound
journal milter is the last on port 25, read-only and attached fail-open; it
keeps of the filter's verdict only what its patterns recognise. **A verdict
@@ -331,11 +428,27 @@ operator can act on it.
### Backup and domain export
- **A session is not enough to take them** (backup-reauth, owner
2026-10-13; built 2026-10-16). The full backup and a domain's export ask
for the signed-in user's own account password at every download, checked
with bcrypt as a password change checks it and under the same limiter -
ten checks in fifteen minutes per user, shared with the Account form -
so a stolen session cookie or a panel left open no longer hands over a
copy of the server, and the field is no place to guess a password from.
A wrong one is refused with 401 and logged; nothing is sent
(`internal/web/handlers`, `reauth`). *Encrypt with a password* is the
file's own protection, not this one: whoever downloads it chooses that
password. The import and the `selfpost-backup` CLI, which runs inside the
container, are not asked.
- Both files are secrets: a full backup carries DKIM keys, `sasldb2`, the
panel users' password hashes, `docker-compose.yml`, `.env`, and the TLS
private key from `certs/` when present; a domain export carries the DKIM key
and **working** application passwords in the clear (otherwise a transfer
without recreating credentials would be impossible).
without recreating credentials would be impossible). The keys a backup
carries are those of `/data/dkim`. `/data/opendkim`, the tree 2.7.x read,
was left out while 2.8.x kept it for a rollback (`2.8.0`); since `2.9.0`
the entrypoint removes it before the panel starts, so no second copy of
every key - one that outlived a deleted domain's - is left on disk.
- Links under `certs/` (and `certs` itself as a link) are followed, in the
container's view: the archive carries whatever regular file they lead to
that the panel's user may read. `certs/` is the operator's, mounted
@@ -383,7 +496,8 @@ operator can act on it.
CLI the password comes only from `SELFPOST_BACKUP_PASSWORD` or
`-password-file`, never as an argument (the process list is readable by any
process in the container).
- Minimum password length matches the administrator password (12): the file
- Minimum password length matches the administrator password (12
characters, counted as characters, not bytes, in both): the file
sits offline and can be attacked without a time limit. The panel's forms and,
since `2.5.3`, the CLI hold an encrypting password to it; decrypting takes
any.
@@ -399,17 +513,6 @@ operator can act on it.
An accepted risk is a decision with a condition for revisiting it, not a
deferred item from the roadmap.
- **A `POST` with neither `Sec-Fetch-Site` nor `Origin` is allowed through.**
A client that sends neither — a genuinely old browser, or a webview with a
frozen engine — stays vulnerable to CSRF from any site. Accepted
deliberately: every panel user (role `global` or `domain`) is an operator who
picks their own browser, not an untrusted party the panel needs to defend
against, and a strict mode would not "protect" such a client, it would
simply break the panel in it. Tightening is one line in `originAllowed`
([internal/web/security.go](../internal/web/security.go)): return `false`
instead of `true` in the "neither header present" branch. That tightening
is agreed (owner, 2026-10-13) as
[origin-strict](roadmap.md#origin-strict); this bullet goes when it ships.
- **Session-bound CSRF tokens are not implemented.** The origin check closes the
neighbouring-subdomain case but depends on browser behaviour; a token does
not. The price is a hidden field in roughly two dozen forms. The trigger to
@@ -446,8 +549,9 @@ deferred item from the roadmap.
keep a password would lose the ability to take a backup at all, and a
permanently undecryptable archive is worse than an unencrypted one:
SelfPost does not store the password. The trigger to make encryption
mandatory is a second administrator (at which point "who downloaded it"
stops being one person).
mandatory is a second person who takes backups or exports - a second
global user, or a domain user exporting their domain - at which point "who
downloaded it" stops being one person.
- **A journal row left without delivery lines is closed as `bounced` rather
than left as it is.** The "forever `queued`" risk is gone: `mail.log` moved to
`/data/log/` and survives container recreation, and the log tailer keeps its
@@ -526,11 +630,12 @@ deferred item from the roadmap.
instead be given the files through a group it shares with the panel.
- ~~**An application can learn which domains are senders of this server.**~~
Struck in `2.5.2` (audit 2026-10-12, stage 2c; L8 closed): the sender
check reads a `From:` line with OpenDKIM's own parser, ported
(`internal/milter/mailparse.go`), and decides by the address OpenDKIM signs
for; what a display name, a comment or a quoted local part names no longer
matters, so refused or passed says nothing about other domains of the
server.
check read a `From:` line with OpenDKIM's own parser, ported, and decided
by the address OpenDKIM signed for; what a display name, a comment or a
quoted local part names no longer mattered, so refused or passed said
nothing about other domains of the server. Since `2.8.0` the check judges
the one address `net/mail` reads and the signer takes the login's key, so
the same holds without the port.
- ~~**A row of the inbound log belongs to a domain by name.**~~ Struck in
`2.5.0` (audit 2026-10-12, L12): both logs now hold a domain by id, see
§ Roles.
@@ -581,12 +686,48 @@ deferred item from the roadmap.
a dozen times a day at most by someone who flaps the zone; a domain's
limits once a day; and no more than 60 messages leave in any hour whatever
the rules arrive at.
- **The sender check stops mail while the panel does not answer.** It is a
role of the panel process, attached with `default_action=tempfail`, so a
panel that is down or slower than the milter timeouts (15 s to connect
and for a command, 30 s for the verdict at the end of the headers) defers
every submission: availability of sending now depends on the panel,
which it did not before `2.4.0`. Chosen by the owner over the alternative,
- **The test message is a form that sends mail** (test-email, `2.12.0`):
Server › Health sends one message to an address a global user types. It is
bounded as notifications are - the same `sendmail(1)` call, a fixed argument
list, the address after `--` - and further: the global role only; one
address, held by `validate.AccountEmail` (no list, no separator, nothing
that reads as an option, any public host); a fixed subject and body the
panel composes; ten an hour, counted from the send log whoever asks; the
origin check of every form. It is written to the Outbound log before it
goes, so each one is on record with its recipient and who asked is in the
panel's log. It cannot be made to say anything but the panel's own text,
and the sender is always `postmaster@` the hostname. Its row learns its
queue id from the head of cleanup's `message-id=` line; cleanup logs a
client's own Message-ID the same way, but the panel's is 96 random bits,
known to nobody before the line is read, taken once and not after ten
minutes - a message of someone else's cannot take the row (security
review, 2026-10-17).
- **Whatever is submitted with sendmail(1) is signed as the server.** The
signer's local socket, the whole of `non_smtpd_milters`, signs every
message submitted locally with the key of the server's own name, whatever
its `From:` says, and no sender check stands on that path (`2.8.0`; under
OpenDKIM a local message was signed for its `From:` domain). Who can submit
is what bounds it: root, `postfix`, and the panel through `postdrop` (the
item above) - so any code running as `panel`, the panel process and
`dmarc-ingest`, which pipe(8) runs as `panel`, can have a message signed as
the server; the same code can read every key anyway, as it could in 2.7.
Mail from the Internet never reaches the socket: smtpd runs its own milter
list, and one whose list is empty (port 25 with DMARC reports on and the
inbound relay off, `-o smtpd_milters=`) turns `non_smtpd_milters` off for
what it receives
(Postfix 3.10.13, src/smtpd/smtpd.c, `setup_milters()`, "Safety: disable
non_smtpd_milters when not sending our own mail filter list"), and the
applications on 465/587 have no code in the container. Trigger to revisit:
anything else able to run as `panel` or `postfix` in the container, or a
local submitter that is not the panel.
- **The sender check and the signer stop mail while the panel does not
answer.** Both are roles of the panel process, attached with
`default_action=tempfail`, so a panel that is down or slower than the
milter timeouts (15 s to connect and for a command, 30 s for the verdict
at the end of the headers) defers every submission and its own
notifications: availability of sending now depends on the panel, which it
did not before `2.4.0` (until `2.8.0` the signer was OpenDKIM, attached as
strictly, a process of its own). Chosen by the owner over the alternative,
a check that is absent whenever the panel is - that one could be switched
off by whoever can make the panel slow. Supervisord starts a panel that
died again at once; one that dies within a second of starting is tried
@@ -607,21 +748,50 @@ deferred item from the roadmap.
of the restart defer mail instead of passing it unjournalled (technical
review, 2026-10-12). Trigger to revisit: anything untrusted able to open
a Unix socket in the container.
- **The container's `/dev/log` takes datagrams from any user in it** (since
`2.6.0`, for OpenDKIM's log): what is written there reaches the container
log, each line marked `syslog: `, control characters written out, at most
200 lines a second (the rest counted and dropped). A user who can write it
can add lines to `docker logs` under that mark and no other; the panel
reads only `mail.log`. logrotate's state file is under `/data/log`, the
`postfix` user's directory: that user could stop rotation through it -
as through the log itself, which it owns. Trigger to revisit: anything
untrusted able to run in the container.
- ~~**The container's `/dev/log` takes datagrams from any user in it.**~~
Struck in `2.8.0`: the syslog relay that read it from `2.6.0` existed for
OpenDKIM's log, and left the image with OpenDKIM, as did the `/dev/log`
link; the signer logs to the panel's own output (the container log).
- **logrotate's state file is under `/data/log`,** the `postfix` user's
directory: that user could stop rotation through it - as through the log
itself, which it owns. Trigger to revisit: anything untrusted able to run
in the container.
- **When the hostname is itself a sending domain,** the server's mail is
signed with that domain's key. Whoever may export the domain has the key,
and its applications may send as `postmaster@<hostname>` by right. The
guide says so; a hostname that is nobody's sending domain avoids it.
- **A message's page can show another message's log lines after a queue-id
collision.** Postfix's short queue ids repeat: the id is the microsecond of
- **`Resent-From:` is signed but not held to the login.** Since `2.8.0`
the sender check holds `Sender:` to the rule `From:` is held to (the
owner's decision, 2026-10-16; security review F1), and the signer signs
both `Sender:` and `Resent-From:`. `Resent-From:` - written when a
message is passed on again (RFC 5322 § 3.6.6) - is not judged: an
application of `mine.com` could write `Resent-From: ceo@bank.com` beside
its own `From:`, signed `d=mine.com`. Few mail programs show it, and no
key of another's is involved, as with a display name. Holding it too is a
few lines in `internal/milter/sender.go` (`judge()`); the trigger to do it
is a mail program that shows it in place of `From:`.
- **A header section costs the signer up to 1 MiB.** The signer counts a
message's headers as Postfix hands them over and refuses a section of
more than 1 MiB with `552 5.3.4` (`internal/milter/sign.go`,
`headerLimit`; `2.8.0`): it keeps no more than that and drops its list
once the block it signs is built. No mail program writes such a section,
and the refusal is permanent, so a sender is told rather than retried.
The other milters keep a few headers each (From, Sender, Subject).
Until `2.8.0` the milter library itself added every header of a message
to a map of its own in each milter - up to the port's message size limit,
from strangers on port 25 - which none of the milters read; the panel
builds go-milter v0.4.1 patched not to keep it
(`third_party/go-milter/PATCHES.md`; the owner's decision, 2026-10-16).
`deploy/docker-compose.yml` sets no memory limit: what is left - many
messages at once, each held to the bounds above - is the host's to
size.
- **A message's page could show another message's log lines after a
queue-id collision - until `2.10.1`.** Since then the image makes long,
non-repeating queue ids (`enable_long_queue_ids=yes`,
`build/postfix-config.sh`; owner, 2026-10-16), which ends it, and with it a
delivery line of a later message rewriting an older send-log row of the
same id and recipient. What follows is the exposure of rows from before,
whose ids are short. Postfix's short queue ids repeat: the id is the microsecond of
arrival and the queue file's inode, and inodes come round within seconds.
The page takes the stored lines of the row's queue id dated within the
row's own queue lifetime (about six days), so a second message has to get
@@ -629,9 +799,9 @@ deferred item from the roadmap.
million per reuse of the inode, and not something a sender can aim. When it
happens, a `domain` user could read the log lines of a message that is not
theirs. The search of the current `mail.log` that the page did before
2.2.0 had the same exposure over a day. Trigger to revisit: a report of
foreign lines on a page, or a reason to turn on `enable_long_queue_ids`,
which ends it.
2.2.0 had the same exposure over a day. It lasts for a short-id row only
as long as the row's queue lifetime, so it is gone from the server some
six days after `2.10.1`.
- **A `domain` user may point an inbound domain's upstream at any host and
port.** The upstream form checks that the host is a well-formed name or IP
and the port is 1–65535; loopback, private and link-local addresses are
@@ -681,7 +851,8 @@ through the browser.
**Decision.** `originAllowed` in
[internal/web/security.go](../internal/web/security.go) checks `Sec-Fetch-Site`
(when the browser sends it) or `Origin` (fallback) against the panel's host; a
request carrying neither header is **allowed through** rather than rejected.
request carrying neither header is **refused** (403), since `2.10.4`
(origin-strict; until then it was let through as an accepted risk).
There are no session-bound tokens embedded in forms. The check applies the same
way regardless of the requesting user's role.
@@ -701,9 +872,11 @@ and sends the request itself. XSS is handled by `html/template` auto-escaping
and CSP, so that is a separate line of defence, not a CSRF token.
**Trade-off.** A client that sends neither `Sec-Fetch-Site` nor `Origin` (a
genuinely old browser, or a webview with a frozen engine) stays vulnerable — see
"Accepted risks" above. This is a deliberate choice not to break the panel in
such a client, at the price of a narrow residual surface.
genuinely old browser - Firefox before 70 sent no Origin with a same-origin
form - or a webview with a frozen engine) can no longer save anything in the
panel: refused rather than left open to a request forged by any site. Until
`2.10.4` the choice was the other way round, as an accepted risk. A script
that posts to the panel sends `Origin` itself (the e2e suite's client does).
**Revisit if:** a requirement appears for protection that does not depend on
browser behaviour, or `domain` accounts stop being trusted operators (for
+4 -1
View File
@@ -4,6 +4,7 @@ go 1.26.0
require (
github.com/emersion/go-milter v0.4.1
github.com/emersion/go-msgauth v0.7.0
golang.org/x/crypto v0.58.0
modernc.org/sqlite v1.60.1
)
@@ -11,7 +12,6 @@ require (
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emersion/go-message v0.18.1 // indirect
github.com/emersion/go-msgauth v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
@@ -21,3 +21,6 @@ require (
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)
// go-milter with its header map dropped: third_party/go-milter/PATCHES.md.
replace github.com/emersion/go-milter => ./third_party/go-milter
-2
View File
@@ -2,8 +2,6 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-message v0.18.1 h1:tfTxIoXFSFRwWaZsgnqS1DSZuGpYGzSmCZD8SK3QA2E=
github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-milter v0.4.1 h1:gLs9QD0zEHF8omgEw8M+aGz6iwBNpWLAcwgSur0ra4M=
github.com/emersion/go-milter v0.4.1/go.mod h1:erCQVl0mH4SX9jEvwe+wyndit0rQtmvMLH86V6NGtkI=
github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc=
github.com/emersion/go-msgauth v0.7.0/go.mod h1:mmS9I6HkSovrNgq0HNXTeu8l3sRAAuQ9RMvbM4KU7Ck=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
+4 -1
View File
@@ -105,7 +105,10 @@ const QueueDir = "postfix/queue"
// fresh one is written instead); a "tls" directory under /data is skipped when
// an operator pointed TLS_CERT_FILE inside /data; and "log" is Postfix's raw
// delivery log plus its fourteen rotated files, which is diagnostic output, not
// state to restore, and by far the largest thing under /data.
// state to restore, and by far the largest thing under /data. (Until 2.9.0
// "opendkim" was left out too: the key tree OpenDKIM read until 2.8.0, kept on
// disk for a rollback to 2.7.x; the entrypoint removes it before the panel
// starts since.)
var excludedFromArchive = map[string]bool{
"selfpost.db": true,
"selfpost.db-wal": true,
+1 -1
View File
@@ -255,7 +255,7 @@ func TightenPolicyHint(pass, fail int, sources []SourceHint) string {
if fail > 0 {
for _, s := range sources {
if s.FailCount > 0 && !s.ThisRelay {
return "A third-party source is not aligned. Do not tighten p= until that sender is fixed or removed."
return "Failures come from a source that is not this relay — review before tightening p=."
}
}
}

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