53 Commits
Author SHA1 Message Date
mixandClaude Opus 5.5 403ce93990 release: 2.0.0: Panel redesign; new URLs; database from zero
test / test (push) Canceled after 0s
The panel redesign (docs/plans/panel-redesign.md, 27/27). A breaking
release: every panel URL changed and the database starts from a single
baseline - a 1.x /data is not upgraded.

Since the last cut also fixed, all found on the test server:
  - the panel can read the mail queue;
  - port 25 answers with DMARC report ingest on, also next to the
    inbound relay;
  - DMARC reports reach the panel (the delivery service never worked
    since 1.7.0);
  - Postfix start-up warnings about the queue directory.

Cut: CHANGELOG [Unreleased] -> [2.0.0]; image pin 2.0.0 in compose,
README, guide; roadmap, plan and version table.

On the test server, on 252e5d1: go vet ./... ok, go test ./... ok
(22 packages), make e2e ok.

Full detail: CHANGELOG.md (2.0.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:32:08 +03:00
mixandClaude Opus 5.5 252e5d1cff dmarc-ingest: Postfix can run the command, and it is told the recipient
The first DMARC report to reach the test server (Google, 2026-10-11
09:41 UTC) was accepted on port 25 and then sat in the queue:

  postfix/pipe: fatal: missing user= command-line attribute
  postfix/qmgr: warning: private/dmarc-ingest socket: malformed response

Three things stood between an accepted report and the panel, all since
the feature shipped in 1.7.0 - no report had ever been delivered:

- The service was addressed as dmarc-ingest/unix-pipe. Its type is unix:
  postconf -P answered "unmatched service_name/type" for flags=, user=
  and argv= (the warnings in the container log at every start), which
  are arguments of pipe(8) anyway and not -o parameters, and the service
  stayed a bare "pipe". For the same reason postconf -MX never removed
  it. It is dmarc-ingest/unix now, with the arguments in the service line.
- pipe(8) gives the command export_environment and nothing else, so the
  command would have refused to run: it reads DMARC_REPORTS_ENABLE (and
  the data directory, when it is not the default) from the environment.
  Those are now named in export_environment.
- The command took the recipient from RECIPIENT, which local(8) sets and
  pipe(8) does not. With no recipient the check that a report came to the
  address of its own domain is skipped. It is now ${recipient} in argv,
  one recipient per run.

When DMARC ingest is off the service and both parameters are removed.

e2e: every port-25 combination now asks Postfix for the service and the
two parameters (postconf -M / -h), and for their absence when ingest is
off - which is how the wrong type was found. make e2e on the test
server: ok. go test ./cmd/panel/ ./internal/dmarc/ ./internal/postfix/:
ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:25:14 +03:00
mixandClaude Opus 5.5 5af4d6c368 panel-redesign: the health cards of Overview carry no anchor
Follows the design commit before it: Machine, Processes, TLS certificate, Milter sockets and Reverse DNS lead to /server/health as it opens; so does the link in the Needs attention line, which takes its address from the card.

  go vet ./internal/web/...: ok
  go test -count=1 ./internal/web/...: ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:14:17 +03:00
mixandClaude Opus 5.5 ddde177776 design: the health cards of Overview open Health at its top
test / test (push) Canceled after 0s
Owner, 2026-10-11, after looking at the test server: the anchors work but mean little, Health is short and there is nothing to scroll past. The five cards (Machine, Processes, TLS certificate, Milter sockets, Reverse DNS) lose their #machine ... #hostname, as the links into Domain settings lost theirs.

check.py: 0 violations; outlines.json unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:12:53 +03:00
mixandClaude Opus 5.5 e69d139487 docs: the preflight and inbound-antispam plans use the 2.0 paths
test / test (push) Canceled after 0s
/preflight is /server/preflight and the retention setting is on /server/settings; the routes are renamed, so the plan no longer says once they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:31:36 +03:00
mixandClaude Opus 5.5 3f0d15c9e3 entrypoint: the queue directory and pid/ stay owned by root
The recursive chown to postfix at the end of the entrypoint also takes the queue directory itself and pid/, which Postfix keeps owned by root; postfix-script logs warning: not owned by root for both at every start. They are handed back to root after the chown, as public/ and maildrop/ are handed back to postdrop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:31:36 +03:00
mixandClaude Opus 5.5 bbd2d83364 panel: show/hide rules reach a form that arrives in an htmx swap
initShowWhen bound a change listener to each control it found at DOMContentLoaded, so a form swapped in later would have been neither synced nor wired. The rules now listen once on the document and are applied again on htmx:load. No form arrives by a swap today; the roadmap row that recorded the gap is struck.

Checked in a browser on the application form: the rate-limit select toggles the manual and auto fields as before, and a copy of the form put into the page with htmx.swap is synced on arrival and follows its select. go vet and go test ./internal/web/... pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:31:36 +03:00
mixandClaude Opus 5.5 c5588ebba1 panel-redesign: the DMARC hub before the first report says what to do now
- The hint under "Last report: None yet" still said "Point rua= at the
    hosted address below" - there is no such address any more, and nothing
    is below. It names the three steps of the three-choice scheme: set a
    domain to SelfPost hosted in its Domain settings, publish the DMARC
    record the domain then shows, publish MX for the server's name.
  - The hosted address form is Fact.Small, as in the mockup.

  go test -count=1 ./internal/web/...: ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:13:51 +03:00
mixandClaude Opus 5.5 f6838e4696 design: the hosted address form on the DMARC hub is in the smaller mono size
On the test server the form dmarc-reports+<domain>@selfpost.mixfed.ru
broke over three lines in its column (2026-10-10). It takes the
supporting size the report id and rua of a report already use.

check.py: 0 violations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:13:51 +03:00
mixandClaude Opus 5.5 36db4223f2 postfix-config: report addresses are relay recipients when the relay is on
Found by talking SMTP to port 25 of the test server once it answered
again (2026-10-10): RCPT TO:<dmarc-reports+bs.mixdep.ru@selfpost.mixfed.ru>
was refused with "550 5.1.1 User unknown in relay recipient table".

With INBOUND_RELAY_ENABLE and DMARC_REPORTS_ENABLE both true the server's
own name is a relay domain (for the reports) and relay_recipient_maps
lists the relay's recipients only. smtpd_reject_unlisted_recipient
applies after the restriction list, so the OK that
check_recipient_access gives a report address does not help: every
report was refused. The table of report addresses is added to
relay_recipient_maps in that combination.

Checked on the server by setting the parameter in the running container
and reloading Postfix: the domain's report address is answered 250, the
former server-wide mailbox and an unknown local part 550, a foreign
domain and an inbound domain without recipients 554. e2e: the combined
case requires relay_recipient_maps to name both tables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:05:40 +03:00
mixandClaude Opus 5.5 80748e6c94 postfix-config: no whitespace in the port-25 service's -o values
Seen in the System log of the test server (2026-10-10): on every
connection to port 25

  postfix/smtpd: fatal: unexpected command-line argument:
      texthash:/data/postfix/dmarc_recipients,reject_unauth_destination,...
  postfix/master: warning: .../smtpd: bad command startup -- throttling

With DMARC_REPORTS_ENABLE=true the script wrote
  -o smtpd_recipient_restrictions=check_recipient_access texthash:...,...
into master.cf. Master splits a service line on whitespace, so smtpd got
the option cut at the space plus a stray argument and died at start:
port 25 greeted nobody - no inbound relay, no DMARC reports. postconf -P
smtp/inet/smtpd_recipient_restrictions showed the cut value,
"check_recipient_access". The server's logs have it under the previous
image too (2026-10-09), so it is not from the redesign; postfix check
does not notice, and the e2e check compared the text of master.cf with
an expectation that had the space in it.

  - The restriction list is written with commas only (Postfix splits it
    on commas as well as whitespace).
  - master_cf_set_smtp_inet_o stops the container on a value with
    whitespace instead of writing it.
  - e2e: for every option it checks, the port-25 case also asks
    postconf -P and requires Postfix to read the same value.

Not fixed here: Postfix's start-time warnings "not owned by root" for
the queue directory and its pid/ - the entrypoint's recursive chown
again; harmless, logged at every start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:57:36 +03:00
mixandClaude Opus 5.5 c5086510f0 panel-redesign: links into Domain settings carry no anchor
Follows the design commit before it: the domain page's menu entries
Report address / Rate limit / Export, the DMARC record's note and the
delete page's "Export the domain" lead to /outbound/domains/{id}/settings
as it opens, not scrolled to a box.

  go test -count=1 ./internal/web/...: ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:57:35 +03:00
mixandClaude Opus 5.5 5b1a868ccf design: links into Domain settings open the page at its top
Seen by the owner on the test server (2026-10-10): following Report
address, Rate limit or Export from the domain's menu opened Domain
settings scrolled down to that box. The page is one screen and a bit, so
the anchor only pushed the title and the first row out of view. The
links (the domain menu, the DMARC record's note, the delete page, Server
> Settings) lose their #reports / #limit / #export.

check.py: 0 violations; outlines.json unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:57:35 +03:00
mixandClaude Opus 5.5 c36bd60022 panel-redesign: an idle one-shot helper is "on demand", not "stopped"
The owner's choice (2026-10-10) for the last line of the Processes table
on Server > Health: postfix-reload runs when the configuration is
reloaded and exits, so between runs supervisord calls it STOPPED - a
word that reads as a fault beside six running programs, in a box that
rightly says ok. Its row now has the neutral tag "on demand" and says
when it last ran (or that it has not run since the container started).
A one-shot in trouble keeps supervisord's word and level, and a daemon
that is stopped is still an error.

  go test -count=1 ./internal/web/...: ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:45:05 +03:00
mixandClaude Opus 5.5 baa053d250 panel-redesign: three things the first look at the deployed panel showed
Seen by the owner on the test server, on real data (2026-10-10).

  - Overview's Processes card said "7 of 7 running" with six programs
    running and postfix-reload idle. The card counts the programs that
    are meant to stay up; a one-shot helper (health.Process.OneShot) is
    left out of both numbers and named only when it is in trouble.
  - The Milter sockets card clipped its second line ("OpenDKIM,
    send-log,...") once the spam filter was the third socket. The line
    names the sockets that do not answer when some do not; with all
    answering it is their names, or "incl. the spam filter" - the
    mockup's words - when the filter is among them.
  - A real 2048-bit DKIM value did not fit the three rows of its field
    and scrolled inside it. The field is as tall as the value needs
    (3 to 12 rows).

The fixtures had a shortened key, two sockets and no one-shot program,
so none of this showed in the evidence screenshots.

  go test -count=1 ./internal/health/... ./internal/web/...: ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:39:39 +03:00
mixandClaude Opus 5.5 ce41a8cf34 changelog: the panel can read the mail queue
The entrypoint fix of 783bca8, with what the two e2e runs on the test
server showed: the new subtest fails on the commit before the fix
("the panel could not run postqueue -p") and the whole suite passes on
the commit with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:30:41 +03:00
mixandClaude Opus 5.5 783bca806a entrypoint: public/ and maildrop/ of the queue stay in group postdrop
The entrypoint ends with chown -R postfix:postfix on the queue tree, on
every start, after postfix set-permissions has run. That puts public/
and maildrop/ in group postfix; Postfix keeps them in its setgid group,
and postqueue (setgid postdrop) reaches the showq socket through that
group only. So the panel, which runs postqueue -p as its own user, got
"Connect to the Postfix showq service: Permission denied": the Queue
card of Overview said "Could not be read" and the queue page showed no
listing. The data directory of the test server has carried the wrong
group since 2026-08-18, so this is older than the redesign - the
recursive chown came with the persistent queue in 1.3.0; Postfix itself
logs "warning: not owned by group postdrop" for both at start.

The two directories are handed back to postdrop after the chown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:28:13 +03:00
mixandClaude Opus 5.5 088c8a5986 e2e: the panel can read the queue
A subtest that opens Outbound > Queue right after sign-in and expects
postqueue's own line for an empty queue, not the page's "Could not read
the mail queue". Seen on the test server after the 2.0 deployment: the
Queue card said "Could not be read", and the panel's log had
"postqueue: fatal: Connect to the Postfix showq service: Permission
denied" on every poll. The fix follows in the next commit; this one is
expected to fail on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:28:13 +03:00
mixandClaude Opus 5.5 b951eae58c panel-redesign: no report mailbox for the server as a whole
test / test (push) Canceled after 0s
Follows the design commit before it.

  - internal/dmarc: the ingest allow-list is the report address of each
    domain that is on this host and nothing else;
    DefaultHostedReportAddress and DefaultHostedSuggestion are removed.
    Since 1.7.0 the general mailbox dmarc-reports@<hostname> was open
    while no panel-wide default existed; the three-choice scheme had
    left it open always.
  - The DMARC hub's Ingest box shows the form of a hosted address,
    dmarc-reports+<domain>@<hostname>.
  - guide.md: what port 25 accepts.

Evidence: docs/assets/panel-redesign/evidence/dmarc.png re-shot (the
address form wraps after the plus sign in its column at 1440 px).

  go test -count=1 ./internal/dmarc/... ./internal/web/... ./cmd/panel: ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:01:51 +03:00
mixandClaude Opus 5.5 ea847fea44 design: the DMARC hub names the form of a hosted address, not a server mailbox
The owner's decision (2026-10-10): the mailbox for the server as a
whole, dmarc-reports@<hostname>, goes. Under the three-choice scheme a
domain can only choose its own hosted address, so nothing can be told
to report to the general one. The Ingest box's fact becomes "Hosted
addresses: dmarc-reports+<domain>@<hostname>".

check.py: 0 violations; outlines.json unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:01:50 +03:00
mixandClaude Opus 5.5 06ae9ca8d3 design: is-hidden is in the Bulma subset
The owner's instruction (2026-10-10). panel.js sets is-hidden, beside
the hidden attribute, on a block that a control switches off - the
fields of a mode that is not chosen, the password fields of an unticked
encryption box, the domain lists of a global user - because hidden alone
does not hide an element whose stylesheet gives it a display of its own
(a box, a grid row). No screen writes the class and it draws nothing, so
the components page has nothing to show for it; the list now names
everything the panel uses.

check.py: 0 violations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:22:14 +03:00
mixandClaude Opus 5.5 be85cb71c9 panel-redesign: technical review - every page re-shot and audited
Stage 3, step 1 (Opus). Each step of stage 2 was reviewed against its
mockup when it landed; this closes the review over the whole, after the
changes of stage 3 (Help topics and the ? on boxes, the three-choice
report address, row actions, the filter's socket).

  - Evidence: all 29 screenshots under docs/assets/panel-redesign/
    evidence/ are re-shot from the current templates - the 28 pages of
    pageFixtures and the sign-in page without an administrator - 1440 px
    wide at each page's full height. Headless Edge against the real
    templates served locally with their fixtures; NOT from the container
    (no Docker on this machine) - the container is checked on the test
    server at deployment.
  - panel/audit.js over the same 29 pages at 1440 and 1280: 4160 texts
    checked, problems: [] (type floor, contrast, sideways scroll,
    overflow in boxes and heads, scrolling tables, clipped card values,
    missing icons). Every ? link has the form /help#topic, and
    TestEveryHelpLinkLandsOnATopic proves each lands.
  - Guards: outlines, class vocabulary, page structure, components-only,
    stylesheet equality, routes, static allow-list - green; the ratchet
    is empty.

Where the product knowingly differs from a mockup (each recorded in the
commit of its step): the queue is the postqueue listing, not a table;
the outbound log has no status filter; the message history has no
"accepted from IP" step; Health draws the sockets this server has;
the Spam filter box states only what the configuration says; the user
form's e-mail help promises nothing that is not built; Overview's title
does not claim that mail is flowing. Roadmap screens (in-log, in-queue,
in-quarantine, in-filter-lists, preflight) are drawn and not built.

Open with the owner: is-hidden, which panel.js sets at run time, is not
in check.py's Bulma subset (the edit of check.py is his).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:07:21 +03:00
mixandClaude Opus 5.5 ed3cb4aaac docs: the panel as it is in 2.0
Stage 3, step 3. Written by Sonnet from the code and the plan, checked
by Opus.

  - guide.md: a section on the panel - menu, paths, the roles global and
    domain with outbound and inbound assigned separately -, Overview and
    Health, Queue and System log, Account and Server > Settings, the
    domain page / Domain settings / application form, Help as a page of
    topics. The DMARC report address is one of three choices set in
    Domain settings. The Spam filter box, the two INBOUND_ANTISPAM_*
    variables the panel reads, and the filter's socket on Health.
    Sessions; backup and export encrypted unless the box is unticked;
    1.x data is not restored into 2.0.
  - architecture.md: Panel HTTP surface rewritten from web.go (routes,
    the /server/ guard, per-object checks, <page>/fragment, one layout,
    typed page data, the kit and its guards, CSP and htmx); Persistence
    gains the database - one baseline, a 1.x file refused, the tables as
    they are.
  - security.md: roles and what each reaches, as built; sessions by user
    id and what ends them; state-changing requests; errors shown to
    users; the 2.0 review; two accepted risks from it - a delegated
    inbound upstream may be any host and port, and Account's profile is
    saved without the password.
  - CHANGELOG: Removed opens with "2.0 starts from an empty data
    directory"; entries that describe the user-default report address
    say they were replaced before release; the filter's socket.
  - roadmap.md: two review follow-up rows struck as retired by the
    redesign (.pair / .split, the two back links); password-reset notes
    that changing the account e-mail must then ask for the password.
    Progress 25/27.
  - development.md: the component kit and its guards; README and
    product.md: wording.

Left as they are
  - The third follow-up row the plan expected to strike - initShowWhen
    is not re-run after an htmx swap - still holds: no polled fragment
    carries such a control today, so nothing breaks, but the redesign did
    not retire it.
  - guide.md embeds no screenshots, so none needed retaking.
  - README and guide still name image 1.9.5 - the version cut.
  - plans/preflight.md and plans/inbound-antispam-panel.md use pre-2.0
    paths; roadmap's template-data-typing is largely done by the
    redesign.

  go test -count=1 ./cmd/panel (env-doc tables): ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:01:18 +03:00
mixandClaude Opus 5.5 f37424d9fc panel-redesign: the spam filter's socket is one of the milter sockets
The owner's decision of 2026-10-10, as the plan has it (Who sees what:
for the global administrator the filter's socket is one of the milter
sockets on Health and Overview). Written by Sonnet, reviewed by Opus.

  - internal/health: CheckMilter probes an address in Postfix's two
    forms. unix:/path is the existing stat; inet:host:port is a TCP
    dial with a 1 s cap, closed at once, nothing written. An address in
    neither form is reported as not checkable, never as fine. Details
    are plain sentences ("No answer from antispam:11332 within 1 s.",
    "Connection to ... refused.", "The name ... could not be
    resolved.") and never the Go error text.
  - Health: with the filter on, a third row "Spam filter - inbound"
    with the configured address; it counts in the socket status and in
    Overview's Milter sockets card. A filter that does not answer is an
    error when INBOUND_ANTISPAM_MILTER_ACTION is tempfail (inbound mail
    is deferred) and a warning otherwise (mail goes through
    unfiltered), and the row says which. With the filter off nothing
    changes.
  - Both pages are global-only; a test checks a domain administrator
    never receives the address.

The dial runs with the other checks on every request and poll of
Overview and Health (5 s active, 30 s idle, none while the tab is
hidden): a dead filter costs a poll at most 1 s, and a live one sees a
connect-and-close at that rhythm per open tab.

Not changed: fixtures and screenshots (the mockup draws two sockets),
help texts. The CHANGELOG line comes with the documentation step, which
is editing that file now.

  go test -count=1 ./internal/health/... ./internal/web/... ./cmd/panel
    ok (seven packages)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:48:00 +03:00
mixandClaude Opus 5.5 96db0b7a86 panel-redesign: three choices for a domain's report address; row actions
Follows the design and guard commits before it. Written by Sonnet,
reviewed by Opus.

The report address
  - Baseline: users.dmarc_default_mode, users.dmarc_default_address and
    domains.dmarc_rua_user_id are gone. domains.dmarc_rua alone says
    where reports go: '' is no reports (a new domain), the address
    SelfPost derives for the domain is the hosted choice (while hosted
    reports are on), anything else is a typed address.
  - Removed: the user default and its store functions, a domain
    following a user, keep / inherit, followCreator, POST /account/dmarc
    with its handler, the DMARC box of Account, the user-delete item
    about domains losing their address.
  - Domain settings offers the three choices; beside the typed address a
    "Use my e-mail" button fills in the profile e-mail (panel.js: one
    generic data-fill hook; absent without a profile e-mail; it fills,
    it does not save).
  - The DMARC record's note names the address and no user. This closes
    L4 of the security review.
  - Domain export / import carries the address as a plain string.
  - The ingest allow-list is the generic mailbox plus each domain's
    address on this host; the generic mailbox is always open now.
  - dev/migrate1x (not in the tree): a 1.x domain that inherited the
    instance default gets that address as its own, so no published rua=
    changes.

Row actions
  - row_action partial (RowAction: Label, Post, Confirm, Danger);
    panel.css has the mockup's three rules. A row of the Applications
    table is Edit, New password, Delete; the confirmation texts are the
    ones the application page's head uses. Both roles, with the checks
    of the application page.

Tests cover: a new domain has no reports; each choice saved and shown
selected; hosted refused when hosted reports are off; a bad address
refused with what was typed kept; an untouched save changes nothing in
each state; the fill button with and without a profile e-mail; deleting
a user leaves every domain's address; the row's forms, targets and
confirmations for both roles and a 404 for a stranger.

Known: if hosted reports are switched off later, a stored hosted address
reads as a typed one. docs/guide.md still describes the old choices -
the documentation step.

Evidence
  - docs/assets/panel-redesign/evidence/account.png,
    out-domain-settings.png, out-domain.png, user-delete.png, help.png:
    1440 px wide, headless Edge, real templates served locally with
    their fixtures - NOT from the container.
  - panel/audit.js on out-domain, out-domain-settings, account, help,
    components and user-delete at 1440 and 1280: 1420 texts checked,
    problems: [].
  - In a browser: the fill button sets the field and does not submit;
    the row buttons have the font, size and colour of the Edit link,
    Delete the red of the other delete links; data-confirm fires on
    both. Hover and focus states were not observed.
  - go test -count=1 ./internal/web/... ./internal/store/...
    ./internal/dmarc/... ./internal/app/... ./internal/inbound/...
    ./cmd/panel: ok (ten packages; dev/migrate1x also ok).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:38:35 +03:00
mixandClaude Opus 5.5 e15f8896c5 guard: the route table has no POST /account/dmarc
The owner's decision of 2026-10-10 (the design commit before this one)
removes the per-user default report address, and with it the form that
POST /account/dmarc served. The plan's Routes table no longer lists it;
the guard's copy of that table follows. Nothing else in the guard
changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:38:35 +03:00
mixandClaude Opus 5.5 fc53ad61ee design: a domain's report address is one of three choices; row actions are POSTs
Two decisions of the owner, 2026-10-10.

The report address. "Three choices: no reports, the SelfPost address, a
typed address. No reports by default. A button that fills the typed
address with one's own e-mail from the profile." This replaces the
2026-09-21 design in which a domain followed a named user's default: a
co-administrator of a domain was shown another user's login name, and
three explicit choices are simpler.
  - src/out-domain-settings.html: the box is a select of the three
    choices and the address field with a "Use my e-mail" button.
  - src/out-domain.html: the DMARC record's note names the address, with
    no user in it.
  - src/account.html: the DMARC reports box is gone; Account is Profile
    and Password, one box per column.
  - src/settings.html: the lead no longer points at Account for the
    report address.
  - docs/plans/panel-redesign.md: Account e-mail rewritten, the Routes
    row of account without /account/dmarc. docs/schema-migrations.md:
    the columns that go.

Row actions. New password and Delete in a row of the Applications table
are POSTs, so each is a form with a button that looks like the links
beside it - as Sign out became one.
  - src/out-domain.html: Edit / New password / Delete per row, the box
    foot about confirmations is back; the login cell does not wrap.
  - theme.css: .table td.sp-actions form sits inline with the links'
    spacing, and its button takes the link's colour and type.
  - src/components.html shows the pattern once.

check.py: 0 violations. outlines.json: only account changed (two equal
columns, one box each).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:38:35 +03:00
mixandClaude Opus 5.5 4ab63d08ae panel-redesign: Help with seventeen topics; every box links to its own
Follows the design commit before it. Written by Sonnet, reviewed by
Opus.

  - view/help.go, templates/help.html: the mockup's topics, texts, ids
    and menu. The page shows the groups the reader's menu shows:
    Overview and Server for the global role, Outbound and Inbound by
    reach (Inbound only with the feature on), Account for everyone;
    boxes are numbered as shown.
  - Box.Help set on the boxes the mockups mark; the inbound MX box has
    its link again.
  - TestEveryHelpLinkLandsOnATopic also fails on a topic that no page
    links to, unless it is listed as menu-only (account) - and on a
    stale exception.

Known until the change that follows: the dns, dmarc and account topics
describe the three-choice report address, which the code does not have
yet; Account's DMARC box still links to #dns.

Not verified by running: three sentences rest on Postfix behaviour
(delivery is deferred when a required TLS session cannot be made; a
milter with default_action=tempfail answers 4xx; with "any recipient"
an address the upstream refuses comes back as a bounce).

Evidence
  - docs/assets/panel-redesign/evidence/help.png (1440x3600) and
    in-domain.png: headless Edge, real templates served locally with
    their fixtures - NOT from the container.
  - go test -count=1 ./internal/web/... ./cmd/panel: ok (six packages).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:58:36 +03:00
mixandClaude Opus 5.5 ef19c1448e design: Help covers every section; boxes link to their topic
The owner's decision (2026-10-10): help is needed by almost every
section, not by Overview and Outbound alone. The Help mockup had six
topics while other screens already linked to topics it did not have
(#dmarc, #inbound).

  - src/help.html: seventeen topics in five boxes - Overview (checks,
    certificate, rdns), Outbound (dns, apps, limits, log, queue, dmarc),
    Inbound (inbound, upstream, recipients, filter), Server (backup,
    users, settings), Account (account) - one paragraph each, and the
    side menu lists them by group.
  - A box head gets the sp-help link where a topic now exists: health,
    out-domain-settings, out-app, out-log, out-message, out-queue, the
    three DMARC screens, in-domains, in-domain, backup, users, user,
    settings. No link on boxes that explain themselves (confirmations,
    the shown-once password, sign-in) and at most one per box.
  - outlines.json regenerated (check.py --write): only help changed -
    Outbound holds six topics, and Inbound, Server and Account are new
    boxes. 0 violations.

The dns, dmarc and account topics describe the report-address scheme the
owner decided the same day (no reports / the SelfPost address / a typed
address, no user default); the account and out-domain-settings mockups
are redrawn for it in the change that follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:58:35 +03:00
mixandClaude Opus 5.5 b2fbe91de9 panel-redesign: security review of 2.0 - sessions belong to a user by id
Stage 3, step 2. Reviewed by Fable (read-only), fixed by Sonnet with a
failing test before each fix, checked by Opus. No Critical or High
finding: route authorization, tenant isolation by id, the /server/
subtree guard, output encoding and the origin check were found sound.

Fixed
  - M1: a session was keyed by user name and had no link to the user. A
    deleted or renamed user's cookie stayed alive (renewed on every hit,
    before the user was looked up) and became a session of whoever was
    later created under that name, with that account's role. The
    baseline now has sessions.user_id REFERENCES users(id) ON DELETE
    CASCADE; the principal is resolved by id and a session is renewed
    only after its user was found. RenameSession is gone.
  - M2: any user's password change deleted every other user's sessions.
    It ends that user's other sessions only.
  - L1: a password set on the user form ends that user's sessions (an
    administrator editing their own account keeps the current one).
  - L2: store, saslpasswd2 and Postfix reload errors were shown verbatim
    on the application form, the inbound upstream / recipients forms and
    the limit recalculation - pages a domain administrator reaches.
    Validation errors (app.ValidationError, inbound.ValidationError,
    store.ErrNotAutoRateLimit) keep their text; anything else is logged
    and shown as "Could not save ... Check the logs and try again."
  - L3: store.ListDMARCReports treated an empty domain list as "all". It
    takes a scope now (AllDomains or a list; an empty list is no rows),
    and the DMARC hub is open to the domain role as the plan says: the
    reports of the assigned domains, without the instance-wide ingest
    box. The menu entry was already shown to them and led to a 404.
  - I1: /static/ answered with a listing of the embedded assets. A path
    that is not a known asset is a 404.

Consequences
  - The global DMARC hub lists reports of domains that were deleted
    since, too.
  - dev/migrate1x (not in the tree) no longer carries sessions over:
    everyone signs in again after the transfer.
  - docs/schema-migrations.md: the sessions row.

Recorded, not changed - for the owner:
  - L4: the report-address select names the followed user and their
    address to every administrator of the domain, by default the global
    administrator's login name. The plan asks for it.
  - L5: a delegated inbound upstream may be any host and port, loopback
    and private ranges included - within the trusted-operator model; to
    be listed among the accepted risks in security.md.
  - L6: Account's profile is saved without the password; becomes a
    requirement once password-reset mails to users.email.
  - Still echoing an error's text: backup and domain import (global
    only), the domain form's validation messages.

Not checked by the review: anything needing the container (e2e, real
Postfix / DNS), CSP behaviour in a live browser.

  go test -count=1 ./internal/web/... ./internal/store/... ./internal/app/...
    ./internal/inbound/... ./internal/dmarc/... ./cmd/panel
      ok  (all eleven packages; dev/migrate1x also ok)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:04:02 +03:00
mixandClaude Opus 5.5 32c6ae98a1 panel-redesign: the queue in the new shell; the old design is removed
Stage 2, steps 7 and 8 in one commit: the static guard fails as soon as
the ratchet holds no page while legacy.css still exists, and the tests
of the old layout need it while that layout exists - the two steps do
not split into states that pass on their own. Written by Sonnet,
reviewed by Opus.

The queue
  - out-queue, named after its mockup: today's content - the listing of
    postqueue -p in a log pane, polled as before (GET
    /outbound/queue/fragment) - and the "How retries work" facts. The
    mockup's table of messages is the roadmap's queue-as-a-table; the
    outline guard exempts this page for exactly that reason. Routes and
    the global-only check are unchanged; the page has no actions and
    never had.
  - Inbound has no Queue entry and no route serves one.

The old design is gone
  - Deleted: layout_legacy.html, legacy.css, logo-compact.svg,
    mail_queue.html, mail_queue_body.html.
  - view.go has one path: every page is layout.html + components.html +
    its own files; kitPages, kitFragments, the map-data branch of Render,
    the dict and back template functions and metaOf's map branch are
    removed. handlers: pageBase removed.
  - legacy_pages.txt holds its header and no entry.
  - panel.js: the old copy handler (button.copy in .code-row) is
    removed. A show-when rule now applies to every element it matches,
    and hiding adds Bulma's is-hidden beside the hidden attribute - a
    .box or a grid row has a display of its own and stayed visible.
  - The user form hides both domain lists again when the role is Global
    (Box.DomainPick, an attribute; data-global-role on the select).
  - Tests of the old layout are deleted; what they asserted about the
    shell (version only signed in, the menu follows the user's reach,
    Inbound only with the feature, Help present, the current entry
    marked) is asserted on the kit pages, two tests added for it.

For the owner: is-hidden is a Bulma class outside the subset of
check.py. No template writes it - the script sets it at run time to
hide a block - so no guard sees it; say if it should be on the list.

Evidence
  - docs/assets/panel-redesign/evidence/out-queue.png: 1440 px wide,
    headless Edge, the real template served locally with its fixture -
    NOT from the container (no Docker on this machine).
  - panel/audit.js on out-queue, user and backup at 1440 and 1280:
    366 texts checked, problems: [].
  - In a browser against the local fixture server: the encryption
    fields of Backup, the manual / auto limit fields and the custom
    report address of Domain settings show and hide with their control;
    all 28 fixture pages answer 200 with panel.css, /static/legacy.css
    answers 404.
  - Not exercised: polling against a real postqueue (none on Windows).
  - go test -count=1 ./internal/web/... ./cmd/panel
      ok  internal/web
      ok  internal/web/auth
      ok  internal/web/handlers
      ok  internal/web/validate
      ok  internal/web/view
      ok  cmd/panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:36:04 +03:00
mixandClaude Opus 5.5 fa696efa8b panel-redesign: Server pages and Help on the kit; the help drawer is gone
Stage 2, step 6. Written by Sonnet, reviewed by Opus.

  - Six pages named after their mockups: system-log, backup, users,
    user, user-delete, help, with typed page data (view/server.go,
    help.go), fixtures and tests. The old templates of the six are gone
    (user_form.html, encrypt_fields.html deleted, the others rewritten)
    and their names are off legacy_pages.txt, which now holds mail_queue
    alone.
  - Help is six help_topic blocks (checks, certificate, rdns, dns, apps,
    limits); a test renders every fixture page and fails on a Help link
    without a topic. The help drawer (help_drawer.html and its includes
    in layout_legacy.html) is removed; its rules in legacy.css go with
    the next step.
  - The user form sets the user's e-mail (plan, Account e-mail): create
    and update, validated, and a changed address resyncs DMARC for a
    user whose default is the account e-mail - through the helper the
    Account profile now shares.
  - Handlers pass typed data; routes, POST targets, field names,
    validation and authorization are unchanged. A test loads the user
    form for four reach combinations and for the only global user, posts
    it back untouched and finds the user unchanged.

Fixed on the way
  - The user delete confirmation said the domains were not affected. A
    domain that follows the user's default report address is left with
    none; the page names those domains now.
  - A refused backup lost the encryption choice and printed a literal
    <code> element in its message.

Differences from the mockups, for the owner:
  - Help has no DMARC and no Inbound topic: its outline fixes two boxes
    of three topics, although other mockups link to #dmarc and #inbound.
    Until the owner decides, Account's Help link goes to #dns and the
    inbound MX box has none;
  - system log: newest line first, as drawn; "refreshes on its own" (5 s
    active, 30 s idle); only status=, warning: and error: lines are
    coloured;
  - backup: the file field is a plain file input; the encryption box is
    ticked, as drawn and as on the domain export, so a plain download
    means unticking it first;
  - users: an E-mail column; the flash does not name the user;
  - user form: no lead; the domain lists are no longer hidden by script
    when Global is chosen (the server ignores them for that role).

Evidence
  - docs/assets/panel-redesign/evidence/system-log.png, backup.png,
    users.png, user.png, user-delete.png, help.png: 1440 px wide,
    headless Edge, real templates served locally with their fixtures -
    NOT from the container (no Docker on this machine).
  - panel/audit.js on the six pages at 1440 and 1280: 720 texts checked,
    problems: [].
  - The system log's polling swap was not exercised in a browser; it
    uses the mechanism of Overview and Health.
  - go test -count=1 ./internal/web/... ./cmd/panel
      ok  internal/web
      ok  internal/web/auth
      ok  internal/web/handlers
      ok  internal/web/validate
      ok  internal/web/view
      ok  cmd/panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:14:25 +03:00
mixandClaude Opus 5.5 610023ef6a panel-redesign: Inbound domains, domain and delete on the kit; Spam filter box
Stage 2, step 5. Written by Sonnet, reviewed by Opus.

  - Three pages named after their mockups: in-domains, in-domain,
    in-domain-delete, with typed page data (view/inbound.go), fixtures
    and tests. The engine pages inbound, inbound_domain and
    inbound_delete are gone with their templates and are off
    legacy_pages.txt.
  - The Spam filter box (plan, Who sees what). The panel now reads
    INBOUND_ANTISPAM_MILTER and INBOUND_ANTISPAM_MILTER_ACTION, with the
    semantics of build/postfix-config.sh (on only with the inbound relay
    on and a milter set; accept | tempfail). It reports them, nothing
    more:
      on   Applies to / Filter (the address, global role only) / If the
           filter is down (accepted unfiltered | deferred)
      off  one sentence; the variable is named for the global role only
    The list shows "Spam filter on|off" in its head.
  - Handlers pass typed data; routes, POST targets, field names,
    validation and authorization (global: all inbound domains; domain
    role: the assigned ones, another tenant's id is a 404; add and
    delete are global-only) are unchanged. A test saves the upstream and
    recipients forms untouched and finds the domain unchanged.

Left out of the mockups' Spam filter box and menu - no source before
inbound-antispam-panel (2.1): the engine name, the quarantine row, the
"refused at the door, see the log" row, the lists row, "Log for this
domain". Also "Checked N min ago" on the MX box: the check carries no
time.

Not done: the filter's socket on Health and Overview. internal/health
checks a unix socket by stat; the documented form of the filter's
address is inet:host:port and needs a dial that does not exist yet.

Differences from the mockups, for the owner:
  - refusals and confirmations are the kit flash above the boxes;
  - the MX tag is coloured by the check in every state; an MX that
    points elsewhere shows "In DNS now";
  - the lead of the domain page follows what is saved ("Not accepting
    mail yet: no upstream is saved");
  - the delete page lists only what the domain has;
  - /help#inbound opens Help at the top until the Help step.

Evidence
  - docs/assets/panel-redesign/evidence/in-domains.png, in-domain.png,
    in-domain-delete.png: 1440 px wide, headless Edge, real templates
    served locally with their fixtures (filter on) - NOT from the
    container (no Docker on this machine).
  - panel/audit.js on the three pages at 1440 and 1280: 352 texts
    checked, problems: [].
  - That the panel process sees the two variables inside the container
    is read from supervisord.conf and entrypoint.sh, not run.
  - go test -count=1 ./internal/web/... ./cmd/panel
      ok  internal/web
      ok  internal/web/auth
      ok  internal/web/handlers
      ok  internal/web/validate
      ok  internal/web/view
      ok  cmd/panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:39:20 +03:00
mixandClaude Opus 5.5 2f1ec65b30 panel-redesign: Outbound log, message and the DMARC report pages on the kit
Stage 2, step 4. Written by Sonnet, reviewed by Opus.

  - Five pages named after their mockups: out-log, out-message, dmarc,
    dmarc-domain, dmarc-report, with typed page data
    (view/outbound_log.go, dmarc.go), fixtures and tests. The engine
    pages deliveries and delivery are gone with their templates; the
    three DMARC templates are written anew. All five old names are off
    legacy_pages.txt.
  - The log polls its table (GET /outbound/log/fragment, as before); the
    page count in the box foot follows out of band, and the filter in
    the head is no longer swapped away while it is being chosen.
  - Handlers pass typed data; routes, query parameters, filters, paging
    and authorization are unchanged.
  - Kit: Foot.ID / Foot.OOB (attributes only, as on Head) and Fact.Small,
    the mockup's "sp-mono sp-small" value, for a report id and an rua
    that wrapped mid-word in their column.
  - test/e2e status scrapers match the new tag markup. Not run: no
    Docker here.

Fixed on the way
  - The DMARC domain page read a field its source rows do not have and
    answered 500 as soon as the domain had a source. It builds the rows
    from the store's rollup now, with a test.

Differences from the mockups, for the owner:
  - the log has no Status filter (the store filters by domain and
    application only); a refused message is "rejected", a neutral tag as
    the mockup's "rate-limited" is;
  - the message history keeps the panel's own wording with full dates and
    has no "accepted from <IP>" step - the client address is not stored;
  - dmarc: before the first report the box says "None yet" with what to
    check; a quiet mailbox is an amber "quiet" tag;
  - dmarc-domain: the stamp is pass / (pass + fail) rounded down, amber
    on any failure, red when nothing passed, absent without mail;
  - dmarc-report: the title has no year; "this relay" marks record rows
    as well as sources.

Known and left as it is (not introduced here): the DMARC hub answers 404
to a domain administrator while the menu shows the entry and the plan
gives that role the reports of its own domains. Opening it needs the
report list to refuse an empty domain filter first - for the stage-3
security review.

Evidence
  - docs/assets/panel-redesign/evidence/out-log.png, out-message.png,
    dmarc.png, dmarc-domain.png, dmarc-report.png: 1440 px wide, headless
    Edge, real templates served locally with their fixtures - NOT from
    the container (no Docker on this machine).
  - panel/audit.js on the five pages at 1440 and 1280: 784 texts checked,
    problems: [].
  - Polling of the log was exercised in a browser against a local
    fixture server with document.hidden overridden: four polls, one
    table and one foot after each, the filter untouched.
  - go test -count=1 ./internal/web/... ./cmd/panel
      ok  internal/web
      ok  internal/web/auth
      ok  internal/web/handlers
      ok  internal/web/validate
      ok  internal/web/view
      ok  cmd/panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:21:06 +03:00
mixandClaude Opus 5.5 acdfaaecd7 panel-redesign: Outbound domains, domain, settings, application on the kit
Stage 2, step 3. Written by Sonnet, reviewed by Opus.

  - Six pages named after their mockups: out-domains, out-domain,
    out-domain-settings, out-app, out-app-created, out-domain-delete,
    with typed page data (view/outbound.go, outbound_app.go), fixtures
    and tests. The engine pages dashboard, domain_detail and
    domain_delete are gone with their templates and are off
    legacy_pages.txt. GET .../settings has its own handler.
  - Handlers pass typed data; routes, POST targets, field names,
    validation, authorization (both roles, another tenant's id is a 404)
    and Cache-Control: no-store on the password page are unchanged.
  - Kit, attribute only: Action.Confirm writes data-confirm on a POST
    action's form (panel.js already reads it).
  - test/e2e/panel_client.go scrapes the new markup (copy fields, the
    application row's Edit link). Not run: no Docker here.

Fixes found on the way
  - Report address of a domain that follows another user's default: the
    select had one "inherit" option, selected, so saving the form
    untouched moved the domain to whoever saved it. It now offers that
    user's default as "keep" (selected, saves nothing) and "My default
    instead" as the change, as the mockup draws it; "keep" on a domain
    that does not follow someone else is refused.
  - panel.js: syncShowWhen stopped at the first matching rule, so the
    auto-limit fields were never toggled with the limit mode. Every
    matching rule applies now.

Differences from the mockups, for the owner:
  - application rows carry Edit only. New password and Delete are POSTs,
    and the kit has no link-styled POST control for a table row; both are
    in the application page's head with a confirmation. Drawing them in
    the row needs a design: change (as Sign out was);
  - the application login is read-only on edit (the handler refuses a
    rename); the submit says Create application on the empty form;
  - multiplier bounds are the store's 1.5-5, default 2.5;
  - "Recalculate" buttons exist where the stored limit is automatic;
  - a domain administrator has no add box and no Delete; Delete domain is
    global-only on the domain page and on settings;
  - a fourth record, Report authorization, appears when the report
    address is on another domain; record states missing / weak / not
    checked are tags with the explanation under the value;
  - refusals are the kit flash above the first box, and those of the
    settings forms now show on the settings page; successful saves still
    redirect to the domain page;
  - the export checkbox is ticked by default, as drawn.

Evidence
  - docs/assets/panel-redesign/evidence/out-domains.png, out-domain.png,
    out-domain-settings.png, out-app.png, out-app-created.png,
    out-domain-delete.png: 1440 px wide, headless Edge, real templates
    served locally with their fixtures - NOT from the container (no
    Docker on this machine). Taken before the report-address fix, which
    does not change what the fixture renders.
  - panel/audit.js on the six pages at 1440 and 1280 (text inside
    [hidden] blocks skipped): 866 texts checked, problems: [].
  - go test -count=1 ./internal/web/...
      ok  internal/web
      ok  internal/web/auth
      ok  internal/web/handlers
      ok  internal/web/validate
      ok  internal/web/view

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:49:20 +03:00
mixandClaude Opus 5.5 5c654723c2 panel-redesign: Overview, Health, Account and Server > Settings on the kit
Stage 2, step 2. Written by Sonnet, reviewed by Opus.

  - Four pages named after their mockups: overview, health, account,
    settings, each with a typed page struct (view/overview.go, health.go,
    account.go), a fixture and tests. The engine page "status" is gone
    (status.html, status_body.html deleted); "settings" is Server >
    Settings only. status and settings are off legacy_pages.txt.
  - Overview: the stamp and its sentence are derived from the six cards
    (the worst card sets the level and the lead names the cards that are
    not fine); the outbound and inbound domain tables are built from the
    existing service calls. Without the inbound relay the outbound box
    takes the full width.
  - Both pages refresh themselves as the old Status page did. Health
    polls its first row of boxes and swaps the second out of band.
    Overview polls its Server health box and the page head follows out of
    band (GET /overview/fragment), so stamp, sentence and time move with
    the cards.
  - Kit, attributes only (no class, no CSS, markup unchanged when empty):
    Box.Poll and Head.ID / Head.OOB.
  - healthCards: the Queue card says "Empty" / "N queued" instead of
    postqueue's line, which was clipped in a card; Processes and Milter
    sockets carry a sub line. OverallStatus / OverallHeading are gone.
  - Handlers pass typed data; routes, POST targets, field names,
    validation and authorization are unchanged.

States the mockups do not draw, all from existing partials: errors and
successes as the kit flash above the boxes; Health with an unreadable
process list, a certificate without facts, a missing host name; Account
with the report authorization not published / not found / not checked
and with hosted reports off; empty domain tables as empty_state.

Differences from the mockups, for the owner:
  - Overview title is one of "Everything is running normally." /
    "Running, with warnings." / "A component needs attention.", not
    "Mail is flowing..." (the checks do not prove that); the note says
    "Updated HH:MM:SS UTC" without an interval, Health's lead says
    "Refreshes automatically" (panel.js polls at 5 s active, 30 s idle);
  - one Queue card ("outbound"), two milter sockets: what this build has;
  - the days column is the stats window, min(30, retention);
  - Health: no "check port 80" paragraph (the certificate's own detail
    says what to check); a failing socket shows its detail under the path;
    figures come from the sampler ("12%", "MiB");
  - Account: the DMARC form keeps the address field for "Another
    address" (hidden unless chosen); the count "N of M now" is the
    domains following this user's default;
  - Settings: Level 2 links the domain list only;
  - /help#checks and /help#dmarc open Help at the top until the Help step.

Evidence
  - docs/assets/panel-redesign/evidence/overview.png, health.png,
    account.png, settings.png: 1440 px wide, full height, headless Edge,
    real templates served locally with their fixtures - NOT from the
    container (no Docker on this machine).
  - panel/audit.js on the four pages at 1440 and 1280: 612 texts
    checked, problems: [].
  - Polling was exercised in a browser against a local fixture server
    with document.hidden overridden (the pane was hidden): one element
    per id after a swap, no leftover hx-swap-oob, about one request per
    5 s. The first request of a visible tab was not observed.
  - go test -count=1 ./internal/web/...
      ok  internal/web
      ok  internal/web/auth
      ok  internal/web/handlers
      ok  internal/web/validate
      ok  internal/web/view

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:11:00 +03:00
mixandClaude Opus 5.5 5b979a0540 design: no Notifications box on Account and Server > Settings
Event notifications are not built (panel-notifications is a roadmap
candidate), and the outline of both screens forced a box that could only
say so. The owner's decision (2026-10-10): take the boxes out of the
mockups; they are drawn again as the first step of that feature's plan.

  - src/account.html: box 03 Notifications removed, DMARC reports is 03;
    the lead and the e-mail help no longer promise notifications or a
    reset link.
  - src/settings.html: box 02 Notifications removed, Sending rate limits
    is 02; the lead no longer lists notifications.
  - outlines.json regenerated (check.py --write): the two boxes are gone
    from account and settings, nothing else moved. 0 violations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:10:59 +03:00
mixandClaude Opus 5.5 f59f4778c9 panel-redesign: sign-in without an administrator follows its mockup
- panel.css: .sp-signin-form .sp-form, as theme.css has it now.
  - templates/login.html: the state is div.sp-form with the mockup's
    heading and line; the empty form of ef8a82e is gone.
  - tests: the hint is checked by its new wording, and that the page
    carries no form at all.

Evidence
  - docs/assets/panel-redesign/evidence/login-no-admin.png: 1440x900,
    headless Edge, real templates served locally (no Docker here).
    login.png re-shot after the selector change: byte-identical.
  - go test -count=1 ./internal/web/... : ok (web, auth, handlers,
    validate, view)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:33:26 +03:00
mixandClaude Opus 5.5 2b4a158423 design: sign-in before any administrator exists is a drawn screen
The owner asked for the state to be drawn (2026-10-09) instead of the
form.sp-form without action or controls that ef8a82e used to get the
column centred.

  - panel/src/login-no-admin.html: the signed-out screen with a
    div.sp-form in place of the form - host kicker, the heading
    No administrator yet, one muted line pointing at the one-time setup
    link. No new class: div.sp-form is on the components page already.
  - theme.css: the signed-out column is sized through .sp-form
    (.sp-signin-form .sp-form), not through the form element, so the
    column does not need a form to be centred. Same declarations.
  - index.html: the screen is on the map; thirty-four screens.

check.py: 0 violations; outlines.json unchanged (bare screens have no
outline).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:33:25 +03:00
mixandClaude Opus 5.5 ef8a82e4c3 panel-redesign: Sign in and Create administrator on the kit
Stage 2, step 1. Written by Sonnet, reviewed by Opus.

  - templates/login.html, setup.html: written from the mockups as the
    content of layout_signed_out - form.sp-form, kicker, h1, Bulma fields
    with icons, the full-width primary button. Actions, field names,
    autocomplete and autofocus are the old ones.
  - view/signin.go: typed Login and Setup (Meta carries a title only, so
    Engine.Render takes the signed-out layout); auth handlers pass them
    instead of maps.
  - login and setup are in kitPages, have fixtures, and are off
    legacy_pages.txt.

States the mockups do not draw:
  - a refused sign-in / setup is the kit's flash (notification is-danger
    is-light) above the fields;
  - login before any administrator exists shows the old sentence in a
    form.sp-form that has no action and no control: the signed-out
    stylesheet sizes and centres its column through "form" alone. A
    workaround, listed for the owner.

Differences from the mockups, for the owner:
  - the kicker is <p><span class="sp-kicker"> (the kit's partial is a
    span; a page may not write the class), the mockup has <p
    class="sp-kicker">: the block sits about 3px lower;
  - login's kicker is the configured host name and is absent when there
    is none; setup's is the mockup's fixed text;
  - the footer is the layout's legal line (full copyright holder name);
  - the user name field has autofocus, so it shows its focus ring.

Evidence
  - docs/assets/panel-redesign/evidence/login.png, setup.png: 1440x900,
    headless Edge, from the real templates served locally with their
    fixtures - NOT from the container (no Docker on this machine).
  - panel/audit.js on login, login with an error, login without an
    administrator, setup, setup with an error, at 1440 and 1280:
    118 texts checked, problems: [] ; no page is taller than 900px.
  - go test -count=1 ./internal/web/...
      ok  internal/web
      ok  internal/web/auth
      ok  internal/web/handlers
      ok  internal/web/validate
      ok  internal/web/view

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:23:10 +03:00
mixandClaude Opus 5.5 783dabbea5 panel-redesign: Server > Health; Overview reduced to verdicts; @routes off
Stage 1, step 5 - and the end of stage 1.

  - GET /server/health (+ /fragment) shows every check in full and is the
    home of POST .../recheck and POST .../reload, which now return there.
  - GET /overview (+ /fragment) shows the overall verdict and six cards -
    machine, processes, TLS certificate, queue, milter sockets, reverse DNS -
    each a view.HealthCard (the typed input of the kit's health_card) that
    links to its table on Health. A check that could not run is a warning,
    not "ok".
  - Both pages read healthChecks(), so a card and its table cannot disagree.
    Until stage 2 they share the old status template, switched by a flag.

legacy_pages.txt: @routes removed. TestRoutesFollowNavigation is enforced
from here on: every path of plan section Routes is registered with its
method, no pre-2.0 path is answered, and no template links to a path the
router does not serve.

Not in this step, left for the Overview page of stage 2: the outbound and
inbound domain summaries the mockup shows under the cards.

Tests: the six cards from one reading of the checks (values, levels, links,
and the unverified case); the two modes of the page - Overview without the
tables and actions, Health with them, each polling its own fragment.

go test ./...  green except the two Windows-only failures in internal/domain
and internal/logtail.

Roadmap 15/27.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:07:10 +03:00
mixandClaude Opus 5.5 b6feac2200 panel-redesign: the application form is one POST, under its domain
Stage 1, step 4 (plan § Routes).

Service (internal/app)
  - Settings{Mode, Addresses, AuthIPRestrict, AuthAllowedIPs, Limit} is what
    the form holds besides the login. CreateWithSettings creates the
    application with all of it and removes it again if a later write fails;
    SaveSettings validates the sender rule first and, if a later write
    fails, puts back what it had already changed. The form produces a whole
    application or leaves things as they were.
  - Limit.Mode "" is "use the domain limit"; auto takes its ceiling from a
    recalculation the caller supplies.

Routes and handlers
  - GET, POST /outbound/domains/{id}/applications/new
    GET, POST /outbound/domains/{id}/applications/{aid}
    POST .../{aid}/password, .../{aid}/ratelimit/recalc, .../{aid}/delete
    replace POST /domains/{id}/applications and the six /applications/{aid}/*
    routes. lookupDomainApplication answers 404 when the application is not
    in the domain named by the path - also when the caller may open that
    domain.
  - A generated password is the response to its POST only (no GET path) and
    is sent with Cache-Control: no-store.
  - The login is not renamed by the form; a changed login is a 400.

Old domain page: the three per-application forms (mode, IP restriction,
limit) are one form with one Save; "Remove limit" became the "Use the domain
limit" choice. The e2e client follows (edited, not run here).

Tests: service - create and save in every limit mode, and all-or-nothing on
a refused sender rule, a failed recalculation and a failed sender-map
rebuild; handlers - the form's parsing, one POST creating sender + IPs +
limit, four kinds of refused form creating nothing, save, the no-store
header on both password pages, and five application routes against another
tenant's application addressed through its own domain and through the
caller's.

With @routes removed by hand TestRoutesFollowNavigation now reports only
GET /server/health (step 5).

go test ./...  green except the two Windows-only failures in internal/domain
and internal/logtail.

Roadmap 14/27.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:02:26 +03:00
mixandClaude Opus 5.5 bd746c61d7 panel-redesign: paths follow the menu; Account and Server settings split
Stage 1, step 3 (plan § Routes). BREAKING: every panel URL changes and the
old paths are removed without redirects.

Routes (web.go)
  - /status -> /overview; /domains -> /outbound/domains with the domain's
    settings actions under .../settings/ (reports, ratelimit, export);
    /deliveries -> /outbound/log; /mail-queue -> /outbound/queue; /dmarc ->
    /outbound/dmarc; /inbound -> /inbound/domains; /system-log ->
    /server/log; /backup -> /server/backup (import under it); /users ->
    /server/users; recheck and reload under /server/health/.
  - One fragment name: <page>/fragment everywhere.
  - Everything under /server/ is registered through server(), which wraps
    the handler in globalOnly: the subtree is guarded once, where a new
    route cannot forget it. Handlers keep their own requireGlobal.
  - GET / leads to /overview, /outbound/domains or /inbound/domains.
  - The /account -> /settings redirect is gone with /settings.

Account and Server > Settings
  - handlers_account.go: GET /account and three POSTs - profile (username,
    e-mail; no password asked, as designed), password (current password,
    rate-limited like a sign-in, signs out other sessions), dmarc (the
    user's default: hosted / account / custom / none, each validated).
    This replaces step 1's bridge through the old Settings form.
  - handlers_settings.go is the instance's page only: log retention and the
    read-only rate limits, global role.
  - One legacy template serves both pages by a flag until stage 2 gives each
    its own: a new legacy page cannot be added to the ratchet.

Still to come inside stage 1, and why @routes stays on the ratchet:
  - step 4: the application routes (one POST, under the domain);
  - step 5: GET /server/health.
With @routes removed by hand TestRoutesFollowNavigation reports exactly
those and nothing else - no template links to a path the router lacks.

Old templates, tests and the e2e client are retargeted. The e2e module was
edited but not run here (no Docker, and its dependency could not be
downloaded on this machine).

Tests: Account (page, profile, password, DMARC default in every mode, a
domain administrator's own row only), Server settings (saved without a
password, range, global only), the /server/ subtree answered 404 by the
router for a domain administrator with All on both lists and for no
principal.

go test ./...  green except the two Windows-only failures in internal/domain
and internal/logtail.

Roadmap 13/27.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:51:55 +03:00
mixandClaude Opus 5.5 3b86f6c3b6 panel-redesign: inbound is delegated per user, separately from outbound
Stage 1, step 2 (plan § Who sees what).

Store
  - Reach{AllDomains, DomainIDs, AllInbound, InboundDomainIDs} is what a
    domain user is assigned. CreateUser takes it; SetUserReach / ClearUserReach
    replace SetUserDomains / ClearUserDomains and write both directions in one
    transaction. A domain user with nothing on either list is refused
    (ErrEmptyReach). With All set the rows of that direction are not stored,
    so clearing All later brings nothing stale back.
  - ListDomainsForUser honours all_domains; ListInboundDomainsForUser is its
    inbound twin. Both filter in the query.

Authorization
  - auth.Principal carries the whole reach: CanAccessDomain (now also true
    with All), CanAccessInboundDomain, HasOutbound, HasInbound. An id or a
    name on one list opens nothing on the other.
  - Inbound handlers in three strengths: requireInbound (the list, filtered),
    requireInboundDomain (one domain: page, DNS re-check, upstream,
    recipients), requireInboundGlobal (add, delete confirm, delete). All
    answer the same 404.
  - domainIDSet and the DMARC report pages go through the principal instead
    of looking at p.Domains themselves.

Panel (old templates, until stage 2)
  - User form: two lists with All; inbound is offered only where the feature
    is on, and saving the form with it off keeps the user's inbound reach.
    Promotion to global drops the reach.
  - Menu: a group with nothing assigned is absent; / sends an inbound-only
    administrator to Inbound. The inbound pages hide add / delete from a
    domain administrator.

Tests: every route of one inbound domain against five kinds of refused
request (another tenant's id, a missing id, outbound-only reach, an outbound
domain with the same id, one with the same name, no principal) with the 404
bodies compared; add/delete as every non-global user; the filtered list
including a domain added later; thirteen outbound routes against another
tenant's domain and application id; the user form; the reach in the store;
the principal matrix; the menu; the home redirect. Removing the access check
or the query filter makes the tests fail (checked by hand).

go test ./...  green except the two Windows-only failures in internal/domain
and internal/logtail that predate this work.

Roadmap 12/27.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:33:37 +03:00
mixandClaude Opus 5.5 8d1af89e1a panel-redesign: the 2.0 schema from zero - one baseline, no 1.x chain
Stage 1, step 1. BREAKING: a 1.x database is not migrated.

Schema
  - internal/store/migrations/ holds one file, 0001_init.sql, equal to the
    2.0 schema; 0002..0010 are deleted (they stay in history at 1.9.5). No
    admin table created and dropped, no ALTERs.
  - users.email replaces users.dmarc_report_email: the address is the
    user's own. The default DMARC report address is a user's setting
    (dmarc_default_mode hosted/account/custom/none, dmarc_default_address);
    the instance setting that mirrored the global user's field is gone.
  - domains.dmarc_rua_user_id: a domain follows a named user's default, or
    has an address of its own in dmarc_rua ('' = none). ON DELETE SET NULL
    leaves it at "no reports", never at someone else's default.
  - user_inbound_domains, users.all_domains, users.all_inbound_domains: in
    the schema for the inbound delegation of the next step.
  - users.role is 'global' / 'domain' (was 'domain_admin');
    rate_limits.allowed_ips, unused since 1.9.0, is dropped.

Opening a database
  - The baseline sets PRAGMA application_id ("SP20"). store.Open refuses a
    file that has a schema version without it (ErrForeignSchema) and one
    newer than the build. A 1.x file at user_version 10 would otherwise read
    as already migrated. The refused file is left untouched.

Code
  - Store.DomainDMARCRua is the one place a domain's report address is
    resolved; dnscheck.ResolveDMARCRua and Store.GlobalDMARCReportEmail are
    gone. dmarc.AllowedRecipients, the domain page, the dashboard and the
    DNS re-check go through it.
  - The old templates keep working on the new model until their pages are
    restyled: the Settings form's address field is the account e-mail and
    switches the user's default between it and none; a domain's "Same as
    Settings" makes it follow the user who chose it; a new or imported
    domain follows its creator.
  - Domain export/import: a domain that follows a user exports no address.

Tests: the baseline pinned table by table (TestBaselineSchema), refusal of a
1.x and of a newer database, constraints and cascades of the new columns and
table, resolution in every default mode, the deleted-user case, and the three
bridged handlers.

docs/schema-migrations.md is rewritten for the baseline; roadmap 11/27,
schema-squash marked built.

go test ./...   (Windows)
  ok for every package except two that fail the same way before this commit:
  internal/domain  TestWriteLoadPrivateKeyRoundtrip (file mode on NTFS)
  internal/logtail TestFollowTailsAndRotates (rename of an open file)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:31:43 +03:00
mixandClaude Opus 5.5 53c83f690c panel-redesign: Sign out takes the menu's hover tint
Follows the design: commit before it. panel.css gains the same four rules
theme.css did (TestPanelCSSIsTheMockupStylesheet holds them equal): the form
stretches the button across the user menu and the button is handed Bulma's
hover / focus / active variables. layout.html already had the form; only its
comment changes.

Checked in the browser on the kit page at 1440: Sign out is as wide as
Account and Help (147 px, 36 px tall), shows the pointer cursor, and is
tinted under the pointer like they are.

go test ./internal/web/...
ok  	github.com/mixeme/selfpost/internal/web
ok  	github.com/mixeme/selfpost/internal/web/auth
ok  	github.com/mixeme/selfpost/internal/web/handlers
ok  	github.com/mixeme/selfpost/internal/web/validate
ok  	github.com/mixeme/selfpost/internal/web/view

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:13:24 +03:00
mixandClaude Opus 5.5 5be1504cfc design: Sign out is a POST form in the mockups, tinted like the links
Owner's instruction (2026-10-09): in the kit Sign out had no hover tint and
stood out among Account and Help. The cause was in the design, not in the
implementation: the mockups drew Sign out as a link, the panel signs out
with a POST, and Bulma tints only a.navbar-item.

  - build.py: Sign out is a form with a submit button carrying navbar-item,
    as the real shell has to write it; all pages rebuilt.
  - theme.css: the form stretches the button across the menu and the button
    is handed Bulma's own hover / focus / active variables, so it answers
    like the entries above it. No width, no colour of its own.

check.py: 32 screens, 48 sp- classes, 0 violation(s); outlines.json unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:10:45 +03:00
mixandClaude Sonnet 5.5 f861d78114 panel-redesign: the component kit - panel.css, partials, shell, kit page
Stage 0, steps 3-5, in one commit: the three were built together (the kit
page is what exercises the partials and the shell) and do not split into
states that build on their own. Written by Sonnet, reviewed by Opus.

  - static/panel.css: shared/brand.css + panel/theme.css, rule for rule.
    wordmark.svg and perf-edge.svg beside it; the stamp is logo.svg.
  - templates/components.html: every partial of the plan's Three layers,
    with its typed input in components.go. Wrappers are open/close pairs
    (box_open/box_close, body_open/body_close, record_open/record_close).
    Rich text is a Text value built from escaped runs - no field is
    trusted HTML. status_tag and wbr_at are template functions; copy_field
    is a partial whose button panel.js wires through data-copy.
  - templates/layout.html: the shell as build.py draws it, the menu paths
    written out literally, visibility from a typed Shell (role, feature
    flags, the user's reach); the signed-out screen as a second define.
    Not in the 2.0 menu: Inbound Log / Queue / Quarantine / Filter lists
    and Preflight.
  - GET /server/components (global role, 404 otherwise): the mockup
    components page rebuilt from fixtures (kit.go) by the real templates.
  - The old panel steps aside unchanged: panel.css -> legacy.css,
    layout.html -> layout_legacy.html; a page uses the new layout iff it
    is off legacy_pages.txt.
  - legacy_pages.txt: @kit removed - the kit is under the guards for good.

Differences from the mockup components page, for the owner:
  - the head button is Help, not "Cover sheet"; two "Example" links are
    dropped (no fixed URL), two point at /outbound/domains and /login;
  - the page sits in the Server group, so the sibling strip shows;
  - Sign out is a POST form whose button carries navbar-item: same size
    and type as the links, but without Bulma's hover tint;
  - a head's actions are always wrapped in div.buttons.

Evidence
  - docs/assets/panel-redesign/evidence/components.png: 1440 px wide, full
    height, headless Edge. Taken from the real templates served locally,
    NOT from the container (no Docker on this machine); the page reads
    only its own fixtures and embedded assets.
  - panel/audit.js on the kit page, 1440 and 1280:
    {"pages":1,"widths":[1440,1280],"textsChecked":516,"problems":[]}
  - go test ./internal/web/... (with @kit removed)
    ok  	github.com/mixeme/selfpost/internal/web
    ok  	github.com/mixeme/selfpost/internal/web/auth
    ok  	github.com/mixeme/selfpost/internal/web/handlers
    ok  	github.com/mixeme/selfpost/internal/web/validate
    ok  	github.com/mixeme/selfpost/internal/web/view
    TestRoutesFollowNavigation stays skipped behind @routes (stage 1).

Roadmap 10/27.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:46:13 +03:00
mixandClaude Opus 5.5 92678c4157 panel-redesign: the design contract as guard tests and a design-first CI step
Stage 0, step 2. The guards land before any template is restyled.

Guard tests (guard_*_test.go under internal/web) read their rules from the
accepted mockups instead of copying them - the Bulma subset and WIDTH_OK
from check.py, the shell's classes from build.py, the stylesheet from
theme.css + brand.css, the skeletons from outlines.json:

  - TestPanelClassVocabulary, TestTemplatesUseComponents,
    TestPanelPageStructure, TestPanelOutlinesMatchMockups,
    TestRedesignedPagesLoadOnlyTheKit: hold every page that has left
    legacy_pages.txt to its mockup, in source and as rendered.
  - TestPanelCSSIsTheMockupStylesheet, TestPanelCSSContract: panel.css is
    brand.css + theme.css rule for rule.
  - TestOutlinePortMatchesCheckPy: the Go port of outline() agrees with
    outlines.json on all 33 screens.
  - TestLegacyRatchetMatchesTheEngine, TestVendoredAssetsArePinned,
    TestStaticHoldsOnlyTheKnownAssets.
  - TestRoutesFollowNavigation: the 2.0 route table, no old path answered,
    no template link the router does not serve.

The kit tests are skipped while legacy_pages.txt lists @kit and the route
test while it lists @routes; both were run with the entry removed and fail
where expected (no kit yet, old route table).

.github/scripts/design-first.sh, run per commit in test.yml: a commit that
touches the mockups or a guard touches nothing else under internal/web;
legacy_pages.txt only shrinks; a changed template is not, whitespace aside,
any template from history. Exercised on a scratch clone for each case.

Plan: Enforcement and Against resurrected pages describe the guards as
built (the ratchet is a file beside the guards, not a list inside them).
Roadmap 7/27.

go test ./internal/web/...
ok  	github.com/mixeme/selfpost/internal/web
ok  	github.com/mixeme/selfpost/internal/web/auth
ok  	github.com/mixeme/selfpost/internal/web/handlers
ok  	github.com/mixeme/selfpost/internal/web/validate
ok  	github.com/mixeme/selfpost/internal/web/view

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:19:42 +03:00
mixandClaude Haiku 5.5 2fa0bef6c2 panel-redesign: vendor Bulma 1.0.4 and the Tabler icon subset
Stage 0, step 1. Nothing links the new files yet; the old panel is unchanged.

  - static/bulma.min.css: css/bulma.min.css of npm bulma@1.0.4, byte for
    byte (tarball integrity checked against the registry;
    sha256 67fa26df1ca9e95d8f2adc7c04fa1b15fa3d24257470ebc10cc68b9aab914bee).
  - static/tabler-icons.css + tabler-icons.woff2: @tabler/icons-webfont
    3.49.0 subset with fontTools pyftsubset to the 57 icons the mockups use
    (the plan said 58; the count is 57).
  - NOTICE and development.md § Vendored front-end list both, MIT.
  - Roadmap: panel-redesign is agreed (owner, 2026-10-09), 6/27; the entry no
    longer says 29 screens or that Health is undecided.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-09 17:19:23 +03:00
mixandClaude Opus 5.5 049d8c781c panel-redesign: ratchet list, fixture registry and a route seam for the guards
Groundwork the guard tests of stage 0 stand on; no behaviour changes.

  - internal/web/view/legacy_pages.txt: the redesign ratchet. Every page of
    today's panel is listed as still old, plus @kit and @routes for the two
    pieces of work whose tests stay quiet until they land. The list only
    shrinks (enforced by CI in the next commits).
  - internal/web/view/fixtures_test.go: the registry of fixtures the guards
    render redesigned pages with. Empty until the kit page exists.
  - web.go: route registration moves out of Handler() into muxes(), so a
    test can ask the real muxes which pattern answers a path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:19:22 +03:00
mixandClaude Opus 5.5 57c6fddfc3 release: 1.9.5: GHCR release fix: keep per-arch images, sweep orphaned versions (CI only)
test / test (push) Canceled after 0s
CI-only cut. The release workflow deleted the X.Y.Z-amd64 / X.Y.Z-arm64
package versions after the manifest merge; those versions are the images the
X.Y.Z manifest points at, so pulling 1.3.0 failed with a missing manifest.
No product code changes.

Fixed:
  - release.yml: the per-arch tags stay in GHCR. GHCR cannot drop a tag
    without deleting the image behind it.
  - release.yml: the merge job now removes only untagged package versions
    that no tagged manifest references (leftovers of publishing a version
    again); any failed lookup aborts the step before anything is deleted.
  - development.md release description and version table, image pin 1.9.5
    in compose, README, guide.

Full detail: CHANGELOG.md (1.9.5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:25:34 +03:00
mixandClaude Fable 5.1 1e92c5b2a2 release: 1.9.4: Panel-redesign plan: component kit, stages, reorder (docs only)
Plan-only cut, owner-requested. The redesign goes first as 2.0.0 and
inbound-antispam-panel follows as 2.1.0 on the 2.0 baseline (migration
0002_inbound_spam_log.sql), built from the redesign's component kit so its
two screens are styled once. No product code changes.

Changed:
  - panel-redesign plan: Component kit section and stage 0 (partials with
    typed inputs in components.go, one panel.css, /server/components kit
    page, acceptance before any page is restyled); staged checklist (5/27);
    Health decided inside 2.0, queue pages left as old content in the new
    shell.
  - panel-redesign plan: Against resurrected pages - shrink-only
    legacyPages ratchet in the guard tests enforced by CI, the outline
    golden, the old-path test and a history-blob check, so an old template
    from git history cannot pass as a redesigned page.
  - roadmap: new candidate delivery-log-storage - the Delivery log box on a
    message page is empty for anything older than the last daily rotation
    because the lines are grepped from the current mail.log on each view
    and never stored.
  - development.md version table, image pin 1.9.4 in compose, README, guide.

Full detail: CHANGELOG.md (1.9.4).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 00:15:48 +03:00
306 changed files with 21003 additions and 6393 deletions
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# GUARD FILE — docs/plans/panel-redesign.md § Enforcement, "design-first".
#
# Checks every commit of a push or pull request, one by one:
#
# 1. Design first. A commit that touches the accepted design — the mockups
# under docs/assets/panel-redesign/panel/, the guard tests, this script —
# touches nothing else under internal/web/. The guards are not adjusted
# in the commit they are supposed to judge.
# 2. The ratchet. internal/web/view/legacy_pages.txt only shrinks: a commit's
# list is a subset of its parent's. A page that was restyled stays held
# to its mockup; its name cannot be put back.
# 3. No blob from the past. A template a commit adds or changes is not,
# whitespace aside, a template that already existed anywhere in the
# history before it. Taking an old page out of git is not a redesign.
#
# Usage: design-first.sh <base> <head> (commits base..head are checked)
# Needs the full history: actions/checkout with fetch-depth: 0.
set -euo pipefail
base="${1:?usage: design-first.sh <base> <head>}"
head="${2:?usage: design-first.sh <base> <head>}"
RATCHET=internal/web/view/legacy_pages.txt
TEMPLATES=internal/web/view/templates/
ZERO=0000000000000000000000000000000000000000
# A new branch, or a force push whose old tip is gone, has no usable base:
# check the tip commit alone rather than nothing.
if [ "$base" = "$ZERO" ] || ! git cat-file -e "$base^{commit}" 2>/dev/null; then
base="$(git rev-parse "$head^" 2>/dev/null || true)"
fi
if [ -n "$base" ]; then
commits="$(git rev-list --reverse --no-merges "$base..$head")"
else
commits="$(git rev-list --reverse --no-merges "$head")"
fi
is_guard() {
case "$1" in
docs/assets/panel-redesign/panel/*) return 0 ;;
.github/scripts/design-first.sh) return 0 ;;
internal/web/guard_*_test.go | internal/web/*/guard_*_test.go) return 0 ;;
esac
return 1
}
entries() { # the ratchet's entries at a commit, sorted; nothing if the file is absent
git show "$1:$RATCHET" 2>/dev/null | tr -d '\r' | sed -e 's/#.*//' -e 's/[[:space:]]//g' -e '/^$/d' | sort -u
}
normalised() { # a blob's hash with all whitespace removed
git cat-file blob "$1" | tr -d ' \t\r\n' | git hash-object --stdin
}
cache="$(mktemp -d)"
trap 'rm -rf "$cache"' EXIT
past_hash() { # normalised hash of a historical blob, computed once
if [ ! -f "$cache/$1" ]; then
normalised "$1" > "$cache/$1"
fi
cat "$cache/$1"
}
fail=0
say() {
echo "::error::design-first: $*"
fail=1
}
for c in $commits; do
short="$(git rev-parse --short "$c")"
parent="$(git rev-parse --verify --quiet "$c^" || true)"
if [ -n "$parent" ]; then
changed="$(git diff --name-only --no-renames "$parent" "$c")"
else
changed="$(git ls-tree -r --name-only "$c")"
fi
# 1. design first
guards="" impl=""
while IFS= read -r f; do
[ -n "$f" ] || continue
if is_guard "$f"; then
guards="$guards $f"
elif [ "${f#internal/web/}" != "$f" ]; then
impl="$impl $f"
fi
done <<< "$changed"
if [ -n "$guards" ] && [ -n "$impl" ]; then
say "$short changes the design or its guards ($(echo $guards | cut -c1-200)) together with the implementation ($(echo $impl | cut -c1-200)). A design change is its own 'design:' commit, made and accepted first."
fi
# 2. the ratchet
if [ -n "$parent" ] && grep -qx "$RATCHET" <<< "$changed"; then
# Once the file has existed, a parent without it means an empty list.
if git cat-file -e "$parent:$RATCHET" 2>/dev/null || [ -n "$(git log -1 --format=%h "$parent" -- "$RATCHET")" ]; then
added="$(comm -13 <(entries "$parent") <(entries "$c"))"
if [ -n "$added" ]; then
say "$short adds to $RATCHET: $(echo $added). The list only shrinks — what was held to the design stays held."
fi
fi
fi
# 3. no blob from the past
if [ -n "$parent" ]; then
touched="$(git diff --name-only --no-renames --diff-filter=AM "$parent" "$c" -- "$TEMPLATES")"
if [ -n "$touched" ]; then
past="$(git log --format= --raw --no-abbrev --no-renames "$parent" -- "$TEMPLATES" | awk '$4 != "'$ZERO'" {print $4}' | sort -u)"
while IFS= read -r f; do
[ -n "$f" ] || continue
now="$(normalised "$(git rev-parse "$c:$f")")"
for blob in $past; do
if [ "$(past_hash "$blob")" = "$now" ]; then
say "$short: $f is, whitespace aside, a template that already existed in history (blob ${blob:0:12}). An old template is not a redesigned page."
break
fi
done
done <<< "$touched"
fi
fi
done
if [ "$fail" -ne 0 ]; then
exit 1
fi
echo "design-first: $(echo "$commits" | grep -c . || true) commit(s) checked, nothing to report"
+25 -18
View File
@@ -11,9 +11,9 @@ name: release
# own native runner; a merge job then combines them into the one manifest tag # own native runner; a merge job then combines them into the one manifest tag
# documented in deploy/docker-compose.yml. "test, then push" (not push-by-digest # documented in deploy/docker-compose.yml. "test, then push" (not push-by-digest
# then test) is deliberate: it means the bytes that get tagged are exactly the # then test) is deliberate: it means the bytes that get tagged are exactly the
# bytes that passed e2e. Per-arch tags are pushed only so imagetools can # bytes that passed e2e. The per-arch tags stay in GHCR next to the version
# assemble the multi-arch manifest; merge removes them from GHCR so operators # tag: they are the images the multi-arch manifest points at, and GHCR cannot
# see a single version tag. # drop a tag without deleting the image behind it.
on: on:
release: release:
types: [published] types: [published]
@@ -177,28 +177,35 @@ jobs:
backoff=$((backoff * 2)) backoff=$((backoff * 2))
done done
- name: Remove per-arch tags from GHCR - name: Remove orphaned untagged versions from GHCR
# Side-effect tags for imagetools assembly only — not part of the public # Re-publishing a version moves its tags to new manifests and leaves the
# version surface (deploy/docker-compose.yml pins X.Y.Z, not X.Y.Z-amd64). # old ones behind as untagged "sha256:..." package versions. Delete only
# imagetools has no "rm" subcommand; delete via the GitHub Packages API. # those that no tagged manifest references: the images behind a
# multi-arch tag are package versions too, and deleting one breaks pulls
# of that tag. Any failed lookup aborts the step before anything is
# deleted.
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
version="${{ needs.prepare.outputs.version }}" image="ghcr.io/${{ github.repository }}"
owner="${{ github.repository_owner }}" owner="${{ github.repository_owner }}"
pkg="${{ github.event.repository.name }}" pkg="${{ github.event.repository.name }}"
api="/users/${owner}/packages/container/${pkg}/versions" api="/users/${owner}/packages/container/${pkg}/versions"
for suffix in amd64 arm64; do versions=$(gh api "${api}?per_page=100" --paginate \
tag="${version}-${suffix}" --jq '.[] | [.id, .name, (.metadata.container.tags | length)] | @tsv')
mapfile -t ids < <(gh api "$api" --paginate \ keep=$(mktemp)
--jq ".[] | select([.metadata.container.tags[]] | index(\"${tag}\")) | .id") while IFS=$'\t' read -r id digest ntags; do
if [ "${#ids[@]}" -eq 0 ]; then [ "$ntags" -gt 0 ] || continue
echo "no GHCR package version for tag ${tag}" echo "$digest" >> "$keep"
raw=$(docker buildx imagetools inspect --raw "${image}@${digest}")
jq -r '.manifests[]?.digest' <<<"$raw" >> "$keep"
done <<<"$versions"
while IFS=$'\t' read -r id digest ntags; do
[ "$ntags" -eq 0 ] || continue
if grep -qxF "$digest" "$keep"; then
continue continue
fi fi
for id in "${ids[@]}"; do echo "deleting orphaned GHCR package version ${id} (${digest})"
echo "deleting GHCR package version ${id} (tag ${tag})"
gh api -X DELETE "${api}/${id}" gh api -X DELETE "${api}/${id}"
done done <<<"$versions"
done
+9
View File
@@ -10,6 +10,15 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
# design-first.sh reads each commit's parent and the template history.
fetch-depth: 0
- name: Design first
env:
BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
HEAD: ${{ github.event.pull_request.head.sha || github.sha }}
run: bash .github/scripts/design-first.sh "$BASE" "$HEAD"
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
+328
View File
@@ -5,6 +5,334 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [Unreleased]
## [2.0.0] - 2026-10-11
The panel redesign. A breaking release: every panel URL changed and the
database starts from zero - a 1.x `/data` is not upgraded (see Changed and
Removed).
### Added
- panel-redesign (stage 0): Bulma 1.0.4 and a 57-icon subset of Tabler Icons
3.49.0 are vendored under `/static` (MIT, listed in NOTICE). Nothing links
them yet; the old panel is unchanged.
- panel-redesign (stage 0): the design contract as guard tests
(`guard_*_test.go` under `internal/web`) and a `design-first` CI step. The
tests read their rules from the accepted mockups — class vocabulary,
stylesheet, page structure, component outlines, the 2.0 route table — and
pin the vendored assets. `internal/web/view/legacy_pages.txt` lists what is
still pre-2.0 and may only shrink; CI also rejects a commit that changes a
guard together with the implementation, or brings back a template from
history. The kit and route tests stay skipped until their stages land.
- panel-redesign (stage 0): the component kit. `panel.css` is the mockups'
stylesheet rule for rule; `components.html` holds every partial with its
typed input in `components.go`; the new `layout.html` is the shell (brick
navbar with the wordmark, perforated edge, sibling strip, user menu,
footer) and the signed-out screen; `GET /server/components` (global role)
renders every partial in every state. `@kit` is off the ratchet: the kit
is held to the design contract from here on.
- panel-redesign (design): Sign out in the user menu is a POST form in the
mockups too, and its button takes the same hover, focus and press tint as
Account and Help (owner, 2026-10-09).
- panel-redesign (stage 1): the 2.0 schema. `users.email` is the user's own
address; the default DMARC report address is a user's setting
(`dmarc_default_mode` / `dmarc_default_address`) and a domain either has an
address of its own or follows a named user's default
(`domains.dmarc_rua_user_id`) — a new domain follows whoever created it,
and deleting that user leaves it with no report address (replaced before
release — see the entry of 2026-10-10 below).
`user_inbound_domains` and the per-user `all_domains` /
`all_inbound_domains` flags are in the schema for the inbound delegation
that follows. Details: `docs/schema-migrations.md`.
- panel-redesign (stage 1): inbound domains are delegated to domain
administrators, separately from outbound. The user form has two lists, each
with *All* (which includes domains added later; with it ticked the rows
under it are ignored); a domain administrator needs at least one assignment
on either. Such a user sees the inbound domains assigned to them — the list
is filtered in the query — and can change their upstream and recipients;
adding and deleting an inbound domain stays with the global role. Another
tenant's domain, a missing one and a user with no inbound reach all get the
same 404. A menu group with nothing assigned is absent, and `/` leads an
inbound-only administrator to Inbound.
- panel-redesign (stage 1): **Account** (`/account`, every role) and
**Server › Settings** (`/server/settings`, global role) replace the one
Settings page. Account has three forms, each its own POST: profile
(username, e-mail), password, and the default DMARC report address
(SelfPost hosted, my account e-mail, another address, none) (replaced before
release — see the entry of 2026-10-10 below). Saving the profile no longer
asks for the password; changing the password still does.
- panel-redesign (stage 1): the application form is one POST. Who the
application may send as, its client-IP allow-list and its rate limit
("use the domain limit", manual or auto) are validated together and saved
together or not at all — also at creation, so an application can be added
with its limit already in place. The page that shows a generated password
is the response to its POST only and is sent with `Cache-Control: no-store`.
- panel-redesign (stage 1): **Server › Health** (`/server/health`) holds the
tables of the old Status page and its two actions, *Re-check DNS* and
*Reload configuration*. **Overview** (`/overview`) is reduced to one
verdict per check — machine, processes, TLS certificate, queue, milter
sockets, reverse DNS — each leading to its table on Health; a check that
could not run shows as a warning, never as fine. Both read the same checks.
- panel-redesign (stage 2): **Sign in** and **Create administrator** are the
first pages in the new design - the split brand / form screen, built from
the component kit. Sign in names the mail host above its title; a refused
attempt is shown as a notice above the fields. `login` and `setup` are off
the ratchet.
- panel-redesign (design): the sign-in page before any administrator exists
is a drawn screen (`login-no-admin`): the heading *No administrator yet* and
the pointer to the one-time setup link, in the same column as the form. The
signed-out column is sized through `.sp-form`, not through `form` (owner,
2026-10-09).
- panel-redesign (design): the Notifications boxes are out of the Account
and Server › Settings mockups. Event notifications are not built
(panel-notifications is a candidate), and 2.0 carries no box for a feature
that does not exist; they are drawn again as the first step of that
feature (owner, 2026-10-10).
- panel-redesign (stage 2): **Overview**, **Server › Health**, **Account**
and **Server › Settings** are in the new design. Overview shows the verdict
as a stamp with one sentence, the six checks as cards and both domain
lists; Health holds the tables behind the cards. Both refresh themselves.
The Queue card reads "Empty" or "N queued". `status` and `settings` are
off the ratchet.
- panel-redesign (stage 2): the **Outbound** domain pages are in the new
design: the domain list, the domain page (DNS records with what to
publish and what is in DNS now, applications, connection), **Domain
settings** as a page of its own (report address, rate limit, export,
delete), the application form, the password shown once and the delete
confirmation. `dashboard`, `domain_detail` and `domain_delete` are off
the ratchet.
- panel-redesign (stage 2): the **Outbound log**, the message page (facts,
history, the lines of `mail.log`) and the three **DMARC report** pages are
in the new design. The log refreshes its rows without touching the filter
being chosen. `deliveries`, `delivery`, `dmarc`, `dmarc_domain` and
`dmarc_report` are off the ratchet.
- panel-redesign (stage 2): the **Inbound** pages are in the new design: the
domain list, the domain page (MX record, upstream, valid recipients) and
the delete confirmation. Every inbound domain page has a **Spam filter**
box and the list names the filter's state: the panel reads
`INBOUND_ANTISPAM_MILTER` and `INBOUND_ANTISPAM_MILTER_ACTION` and says
whether inbound mail is filtered and what happens while the filter is
down. The filter's address is shown to the global role only. `inbound`,
`inbound_domain` and `inbound_delete` are off the ratchet.
- panel-redesign (stage 2): the **Server** pages - System log, Backup,
Users, the user form and its delete confirmation - and **Help** are in the
new design. Help is a page of topics that the `?` in a box's head opens;
the help drawer is gone. The user form sets the user's e-mail. The backup
download is encrypted unless the box is unticked. Only the queue page is
left on the ratchet.
- panel-redesign (design, stage 3): **Help** covers every section -
seventeen topics for Overview, Outbound, Inbound, Server and Account - and
the boxes of nearly every page carry a `?` that opens their topic (owner,
2026-10-10). A reader sees the topics of the sections their menu shows.
- panel-redesign (design, stage 3): **a domain's DMARC report address is one
of three choices** - no reports (a new domain), the SelfPost hosted
address, or an address typed for the domain - set in Domain settings, with
a button that fills in one's own profile e-mail. The per-user default
report address, a domain "following" a user and the DMARC box on Account
are gone, and with them `users.dmarc_default_mode`,
`users.dmarc_default_address` and `domains.dmarc_rua_user_id` (owner,
2026-10-10; replaces the design of 2026-09-21 described above).
- panel-redesign (stage 3): with the inbound spam filter on, its socket is one
of the milter sockets on **Server › Health** and counts in Overview's card.
The panel connects to the configured address (`inet:host:port` or
`unix:/path`, one second at most, nothing sent). A filter that does not
answer is an error with `INBOUND_ANTISPAM_MILTER_ACTION=tempfail` - inbound
mail is deferred - and a warning otherwise - mail goes through unfiltered.
- **Port 25 no longer accepts `dmarc-reports@<hostname>`**, the report
mailbox of the server as a whole. Only an address a domain has chosen is
accepted: its hosted address `dmarc-reports+<domain>@<hostname>`, or a
typed address that is on this host. A domain whose published `rua=` still
names the general mailbox must be set to *SelfPost hosted* and its DMARC
record republished (owner, 2026-10-10).
- panel-redesign (design, stage 3): **New password** and **Delete** are in
each row of a domain's Applications table again, as POST forms that look
like the links beside them.
- panel-redesign (stage 2): the **Outbound queue** page is in the new shell
with its content unchanged - the listing of `postqueue -p` and how
retries work. With it every page is built from the component kit: the old
stylesheet, the old layout and the engine's second rendering path are
deleted, and the ratchet is empty.
### Changed
- **Breaking — every panel URL changed.** A path now reads like the menu
(`/overview`, `/outbound/…`, `/inbound/…`, `/server/…`, `/account`); the
old paths are removed, not redirected — see *Removed*. Polled fragments all
answer `<page>/fragment`. Everything under `/server/` is guarded for the
global role where the routes are registered, in addition to each handler's
own check. An application is addressed under its domain
(`/outbound/domains/{id}/applications/{aid}`), and an application id that
does not belong to the domain in the path is a 404.
- **Breaking — the database starts from zero.** The 1.x migration chain
(`0001`–`0010`) is replaced by one baseline, `0001_init.sql`. A 1.x
`/data/selfpost.db` is refused at start-up with an explicit error and left
untouched; nothing is migrated. A domain can be carried over with its
export file.
- The panel role `domain_admin` is stored as `domain`.
- On the old domain page, "Same as Settings" makes the domain follow the
default report address of the user who chose it (replaced before release —
see the entry of 2026-10-10 below).
- The old panel is untouched on screen but steps aside in the tree: its
stylesheet is `legacy.css` and its layout `layout_legacy.html` until the
last old page is restyled. The 2.0 menu paths in the new shell do not
exist before stage 1; only the kit page uses that shell so far (a stage-0
state: both files are deleted and every page is on the shell — see
*Removed*).
- panel-redesign is `agreed` (owner, 2026-10-09); work starts with stage 0,
the component kit.
- panel-redesign (stage 3): the operator guide, `architecture.md` (Panel HTTP
surface, Persistence) and `security.md` (roles, sessions, errors shown to
users) describe the 2.0 panel. `security.md` records two accepted risks from
the 2.0 review: a delegated inbound upstream may be any host and port, and
Account's profile is saved without the password.
### Security
- A panel session belongs to a user by id and ends with them. It used to be
keyed by user name: the cookie of a deleted or renamed user stayed valid
and became a session of whoever was next created under that name. Signing
in again is required after upgrading (there is no upgrade from 1.x; this
concerns development builds of 2.0).
- Changing one's password ends one's own other sessions only; it used to
sign every other user out. A password set by an administrator on the user
form ends that user's sessions.
- Internal errors (database, `saslpasswd2`, Postfix reload) are no longer
shown on the application, inbound upstream / recipients and limit forms;
they are logged and the page says so.
- `/static/` no longer answers with a listing of the embedded files.
- The DMARC reports hub is open to a domain administrator for the domains
assigned to them, without the server's ingest statistics; the list of
reports no longer treats "no domains" as "all domains".
### Fixed
- Saving a domain's report-address form untouched no longer moves a domain
that follows another user's default to whoever saved it: that user's
default is its own choice, and "My default instead" is the change
(replaced before release — see the entry of 2026-10-10 below).
- The auto rate-limit fields follow the limit mode on the domain and
application forms (the script applied only the first matching rule).
- The DMARC page of a domain no longer answers 500 once the domain has a
reported source.
- **The panel can read the mail queue.** The container's entrypoint left the
queue's `public/` and `maildrop/` directories in group `postfix` on every
start; Postfix keeps them in `postdrop`, the group `postqueue` works
through, so the panel's `postqueue -p` was refused ("Connect to the Postfix
showq service: Permission denied"), the Queue card said "Could not be read"
and the queue page had no listing. Present since the persistent queue of
1.3.0; mail flow was not affected. The e2e suite opens the queue page now.
- **Port 25 answers again with DMARC report ingest on.** The generated
`master.cf` gave the port-25 service `-o smtpd_recipient_restrictions=
check_recipient_access texthash:…,…` with a space in the value; Postfix
cuts a service line at whitespace, so `smtpd` was started with a stray
argument and died with "fatal: unexpected command-line argument" on every
connection - no inbound relay and no DMARC reports were accepted while
`DMARC_REPORTS_ENABLE=true`, although `postfix check` and the suite were
silent. The list is written with commas only, the generator refuses a
value with whitespace, and the e2e check asks Postfix what it reads.
Present since DMARC ingest was fitted into the 1.9.1 fix of the same
stanza.
- **DMARC reports are accepted with the inbound relay on.** With both
features enabled every report address was refused as "User unknown in
relay recipient table": the relay's unlisted-recipient check runs after the
restriction list, and the report addresses - on the server's own name, a
relay domain - were not in `relay_recipient_maps`. Their table is a
recipient table now. Hidden until now by the port-25 failure above.
- **DMARC reports reach the panel.** A report accepted on port 25 stayed in
the queue: the generated delivery service was a bare `pipe` ("fatal:
missing user= command-line attribute"), because the generator addressed it
under a type Postfix does not have and set `flags=`, `user=` and `argv=`
as `-o` parameters, which they are not. Behind that, the command would
have refused to run (Postfix starts it without the container's
environment) and was never told the recipient, so the check that a report
came to the address of its own domain was skipped. The service line
carries its arguments, the recipient is one of them, and
`export_environment` names what the command reads. No report had been
delivered since the feature shipped in 1.7.0; the e2e check asks Postfix
how it reads the service.
- Postfix no longer logs "warning: not owned by root" for the queue
directory and its `pid/` at every start: the entrypoint hands the two back
to root after its recursive change of owner.
- Overview's Processes card counts the programs that are meant to stay up
("6 of 6 running"); the idle one-shot `postfix-reload` was counted as
running. The Milter sockets card no longer clips its second line with three
sockets, and a DKIM value is shown whole instead of scrolling inside its
field.
- Server › Health shows the idle reload helper (`postfix-reload`) as
**on demand**, with when it last ran, instead of `stopped` beside the
running programs (owner, 2026-10-10).
- The confirmation before deleting a user names the domains that follow
that user's default report address and will be left without one; it used
to say the domains were not affected (replaced before release — see the
entry of 2026-10-10 below).
### Removed
- **2.0 starts from an empty data directory.** A 1.x `/data` is not upgraded:
its database is refused at start and left untouched, and a 1.x backup is not
restored; a domain is carried over with its export file. Gone with it, as
built: the 1.x migration chain, every pre-2.0 panel path (listed next), the
help drawer (Help is a page of topics), the default DMARC report address —
the panel-wide one of 1.x (`dmarc_report_email`, "Same as Settings") and the
per-user one with a domain following a user that earlier entries of this
release describe (`users.dmarc_default_*`, `domains.dmarc_rua_user_id`) —
together with the DMARC box on Account, the old stylesheet `legacy.css`, the
old layout `layout_legacy.html`, and the unused `rate_limits.allowed_ips`
column.
- Old panel paths, GET and POST alike, without redirects: `/status`,
`/status/fragment`, `/status/recheck`, `/reload`, `/domains` and everything
under it (`/domains/import`, `/domains/{id}`, `…/dns-recheck`, `…/delete`,
`…/ratelimit`, `…/ratelimit/recalc`, `…/dmarc`, `…/export`), `/inbound` and
`/inbound/{id}/…`, `/dmarc`, `/dmarc/reports/{id}`, `/dmarc/domains/{id}`,
`/deliveries`, `/deliveries/rows`, `/deliveries/{id}`, `/mail-queue`,
`/mail-queue/body`, `/system-log`, `/system-log/body`, `/backup`, `/users`
and everything under it, `/settings`, and the `/account` → `/settings`
redirect; `POST /domains/{id}/applications` and `/applications/{aid}/mode`,
`/authips`, `/ratelimit`, `/ratelimit/recalc`, `/password`, `/delete` — the
three separate saves of an application are one form now.
- The instance setting `dmarc_report_email` (a mirror of the global user's
profile field) and the unused `rate_limits.allowed_ips` column.
## [1.9.5] - 2026-10-09
CI-only cut: the release workflow no longer breaks the multi-arch image it
just published. No product code changes.
### Fixed
- ci (GHCR): the release no longer deletes the `X.Y.Z-amd64` / `X.Y.Z-arm64`
package versions after the manifest merge. Those versions are the images the
`X.Y.Z` manifest points at, so `docker pull ghcr.io/mixeme/selfpost:1.3.0`
failed with a missing manifest. The per-arch tags now stay; the merge job
instead removes untagged versions that no tagged manifest references.
## [1.9.4] - 2026-10-09
Plan-only cut, owner-requested: the redesign goes first as `2.0.0` with a
component-kit stage and guards against resurrected pages; antispam moves to
`2.1.0`; a new candidate explains the empty delivery log. No product code
changes.
### Changed
- Roadmap order (owner, 2026-10-09): the panel redesign ships first as
`2.0.0`; inbound-antispam-panel follows as `2.1.0` on the 2.0 baseline
(its migration becomes `0002_inbound_spam_log.sql`), built from the
redesign's component kit so its two screens are styled once. The redesign
plan gains a **Component kit** section and stage (partials with typed
inputs, one `panel.css`, a `/server/components` kit page, acceptance before
any page is restyled), a staged checklist (5/27), Health decided inside 2.0
and the queue pages left as old content in the new shell, and an **Against
resurrected pages** section: a shrink-only `legacyPages` ratchet in the
guard tests, the outline golden, the old-path test and a history-blob
check in CI, so an old template from git history cannot pass as a
redesigned page.
- New roadmap candidate `delivery-log-storage`: the *Delivery log* box on a
message's page is empty for anything older than the last daily rotation,
because the lines are grepped from the current `mail.log` on each view and
never stored; the item proposes keeping them with the send-log row.
## [1.9.3] - 2026-09-22 ## [1.9.3] - 2026-09-22
Panel-redesign prep, docs only: stage 1-2 mockups on all 33 screens, the Panel-redesign prep, docs only: stage 1-2 mockups on all 33 screens, the
+16
View File
@@ -21,6 +21,22 @@ htmx 2.0.4 (internal/web/view/static/htmx.min.js)
Licence: Zero-Clause BSD (0BSD) Licence: Zero-Clause BSD (0BSD)
https://github.com/bigskysoftware/htmx https://github.com/bigskysoftware/htmx
Bulma 1.0.4 (internal/web/view/static/bulma.min.css)
Copyright (c) 2023 Jeremy Thomas
Licence: MIT
https://github.com/jgthms/bulma
Distributed unmodified.
Tabler Icons 3.49.0
Copyright (c) 2020-2026 Paweł Kuna
Licence: MIT
https://github.com/tabler/tabler-icons
Distributed as two files:
internal/web/view/static/tabler-icons.css
internal/web/view/static/tabler-icons.woff2
Webfont subset to the 57 icons the panel uses, made with fontTools
pyftsubset.
IBM Plex Sans / IBM Plex Mono IBM Plex Sans / IBM Plex Mono
Copyright © 2017 IBM Corp., with Reserved Font Name "Plex" Copyright © 2017 IBM Corp., with Reserved Font Name "Plex"
Licence: SIL Open Font License 1.1 Licence: SIL Open Font License 1.1
+3 -3
View File
@@ -26,9 +26,9 @@ send log and DNS checks in the panel, encrypted backups.
- Outbound SMTP (465/smtps; optional 587 submission) with per-domain DKIM signing - Outbound SMTP (465/smtps; optional 587 submission) with per-domain DKIM signing
- Optional inbound relay on port 25 (backup-MX / forwarder; off by default) - Optional inbound relay on port 25 (backup-MX / forwarder; off by default)
- Optional DMARC aggregate report ingest on port 25 (off by default) - Optional DMARC aggregate report ingest on port 25 (off by default)
- In-panel Help drawer and `/help` page for Status and domain controls - 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 SMTP AUTH (optional)
- Web panel — domains, applications, deliveries, mail queue, system log, backup - Web panel grouped by mail direction — Overview, Outbound (domains, log, queue, DMARC reports), Inbound, Server (health, system log, backup, users, settings)
- Multi-domain relay — each SASL application is bound to one sending domain - Multi-domain relay — each SASL application is bound to one sending domain
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check - DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
- Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides - Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides
@@ -112,7 +112,7 @@ docker run --rm -d --name selfpost-try \
-e SELFPOST_HOSTNAME=mail.local.test \ -e SELFPOST_HOSTNAME=mail.local.test \
-e PANEL_COOKIE_SECURE=false \ -e PANEL_COOKIE_SECURE=false \
-v selfpost-try-data:/data \ -v selfpost-try-data:/data \
ghcr.io/mixeme/selfpost:1.9.3 ghcr.io/mixeme/selfpost:2.0.0
``` ```
**Get the setup URL** (pick one): **Get the setup URL** (pick one):
+11
View File
@@ -157,5 +157,16 @@ if [ ! -d /data/postfix/queue/active ]; then
postfix set-permissions postfix set-permissions
fi fi
chown -R postfix:postfix /data/postfix/queue chown -R postfix:postfix /data/postfix/queue
# The recursive chown above would leave public/ and maildrop/ in group postfix.
# Postfix keeps those two in its setgid group: postqueue and postdrop are setgid
# postdrop and reach the showq socket and the maildrop directory through that
# group alone. With the wrong group `postqueue -p` run by the panel answers
# "Connect to the Postfix showq service: Permission denied" and the panel cannot
# read the queue (the Queue card and the Outbound queue page).
chgrp postdrop /data/postfix/queue/public /data/postfix/queue/maildrop
# It would also leave the queue directory itself and pid/ owned by postfix.
# Postfix keeps those two owned by root (master, which runs as root, is the only
# writer) and logs "warning: not owned by root" for each at every start.
chown root /data/postfix/queue /data/postfix/queue/pid
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
+51 -11
View File
@@ -13,12 +13,25 @@
# a fixed literal or a container environment variable. # a fixed literal or a container environment variable.
set -eu set -eu
# postconf -P refuses master.cf -o values that contain whitespace; inbound port # Sets one -o option of the smtp/inet (port 25) service by patching its stanza.
# 25 needs restriction lists and milter specs that include spaces. Patch the #
# smtp/inet service stanza directly instead. # A master.cf -o value must not contain whitespace: master splits the line on
# it, so "-o name=check_recipient_access texthash:/x,reject" reaches smtpd as
# the option "name=check_recipient_access" plus a stray argument, and smtpd dies
# at start with "fatal: unexpected command-line argument" - port 25 then
# answers nobody. (postconf -P refuses such values for the same reason.) Postfix
# splits a restriction list on commas as well as on whitespace, so every value
# passed here is written with commas only; a value with whitespace is a bug in
# this script and stops the container.
master_cf_set_smtp_inet_o() { master_cf_set_smtp_inet_o() {
param="$1" param="$1"
value="$2" value="$2"
case "$value" in
*[[:space:]]*)
echo "FATAL: master.cf -o ${param} would contain whitespace: ${value}" >&2
exit 1
;;
esac
awk -v p="$param" -v v="$value" ' awk -v p="$param" -v v="$value" '
/^smtp[ \t]+inet[ \t]/ && !done { /^smtp[ \t]+inet[ \t]/ && !done {
print print
@@ -272,11 +285,21 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
[ -e "$f" ] || : > "$f" [ -e "$f" ] || : > "$f"
done done
ln -sf /usr/local/bin/panel /usr/local/bin/dmarc-ingest ln -sf /usr/local/bin/panel /usr/local/bin/dmarc-ingest
postconf -M "dmarc-ingest/unix-pipe=dmarc-ingest unix - n n - - pipe" # flags=, user= and argv= are arguments of pipe(8), not -o parameters:
postconf -P \ # postconf -P refuses them ("unmatched service_name/type") and leaves a
"dmarc-ingest/unix-pipe/flags=Rq" \ # bare "pipe", which dies with "fatal: missing user= command-line
"dmarc-ingest/unix-pipe/user=panel" \ # attribute" on every report. They go into the service line itself.
"dmarc-ingest/unix-pipe/argv=/usr/local/bin/dmarc-ingest" # ${recipient} is expanded by pipe(8), not by the shell: the command
# learns which report address the message came to from its argument.
postconf -M 'dmarc-ingest/unix=dmarc-ingest unix - n n - - pipe flags=Rq user=panel argv=/usr/local/bin/dmarc-ingest ${recipient}'
# One recipient per run, so ${recipient} is one argument.
postconf -e "dmarc-ingest_destination_recipient_limit=1"
# pipe(8) starts the command with export_environment only, so what the
# command reads from the container's environment is named there.
DMARC_INGEST_ENV="DMARC_REPORTS_ENABLE=true"
[ -z "${SELFPOST_DATA_DIR:-}" ] || DMARC_INGEST_ENV="${DMARC_INGEST_ENV} SELFPOST_DATA_DIR=${SELFPOST_DATA_DIR}"
[ -z "${SELFPOST_DB_PATH:-}" ] || DMARC_INGEST_ENV="${DMARC_INGEST_ENV} SELFPOST_DB_PATH=${SELFPOST_DB_PATH}"
postconf -e "export_environment=TZ MAIL_CONFIG LANG ${DMARC_INGEST_ENV}"
if [ -n "$RELAY_DOMAINS_SETTING" ]; then if [ -n "$RELAY_DOMAINS_SETTING" ]; then
RELAY_DOMAINS_SETTING="${RELAY_DOMAINS_SETTING} texthash:${DMARC_RELAY_DOMAINS_MAP}" RELAY_DOMAINS_SETTING="${RELAY_DOMAINS_SETTING} texthash:${DMARC_RELAY_DOMAINS_MAP}"
TRANSPORT_SETTING="${TRANSPORT_SETTING} texthash:${DMARC_TRANSPORT_MAP}" TRANSPORT_SETTING="${TRANSPORT_SETTING} texthash:${DMARC_TRANSPORT_MAP}"
@@ -284,15 +307,31 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
RELAY_DOMAINS_SETTING="texthash:${DMARC_RELAY_DOMAINS_MAP}" RELAY_DOMAINS_SETTING="texthash:${DMARC_RELAY_DOMAINS_MAP}"
TRANSPORT_SETTING="texthash:${DMARC_TRANSPORT_MAP}" TRANSPORT_SETTING="texthash:${DMARC_TRANSPORT_MAP}"
fi fi
RECIPIENT_RESTRICTIONS="check_recipient_access texthash:${DMARC_RECIPIENTS_MAP},${RECIPIENT_RESTRICTIONS}" # Comma, not a space, between check_recipient_access and its table: see
# master_cf_set_smtp_inet_o.
RECIPIENT_RESTRICTIONS="check_recipient_access,texthash:${DMARC_RECIPIENTS_MAP},${RECIPIENT_RESTRICTIONS}"
else
postconf -MX "dmarc-ingest/unix" 2>/dev/null || true
postconf -X dmarc-ingest_destination_recipient_limit export_environment 2>/dev/null || true
fi fi
postconf -e \ postconf -e \
"relay_domains=${RELAY_DOMAINS_SETTING}" \ "relay_domains=${RELAY_DOMAINS_SETTING}" \
"transport_maps=${TRANSPORT_SETTING}" \ "transport_maps=${TRANSPORT_SETTING}" \
"smtpd_reject_unlisted_recipient=yes" "smtpd_reject_unlisted_recipient=yes"
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
# With the relay on, Postfix refuses every recipient of a relay domain
# that relay_recipient_maps does not list (smtpd_reject_unlisted_recipient
# above) - and it does so after the restriction list, whatever
# check_recipient_access answered. The DMARC report addresses live on a
# relay domain too (the server's own name), so their table is a recipient
# table as well; without it they are "User unknown in relay recipient
# table" and no report is ever accepted while both features are on.
RELAY_RECIPIENT_SETTING="texthash:${RELAY_RECIPIENTS_MAP}"
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
RELAY_RECIPIENT_SETTING="${RELAY_RECIPIENT_SETTING} texthash:${DMARC_RECIPIENTS_MAP}"
fi
postconf -e \ postconf -e \
"relay_recipient_maps=texthash:${RELAY_RECIPIENTS_MAP}" \ "relay_recipient_maps=${RELAY_RECIPIENT_SETTING}" \
"smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}" "smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}"
else else
postconf -e \ postconf -e \
@@ -355,7 +394,8 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
master_cf_set_smtp_inet_o smtpd_milters "${INBOUND_MILTERS}" master_cf_set_smtp_inet_o smtpd_milters "${INBOUND_MILTERS}"
else else
postconf -MX "smtp/inet" 2>/dev/null || true postconf -MX "smtp/inet" 2>/dev/null || true
postconf -MX "dmarc-ingest/unix-pipe" 2>/dev/null || true postconf -MX "dmarc-ingest/unix" 2>/dev/null || true
postconf -X dmarc-ingest_destination_recipient_limit export_environment 2>/dev/null || true
postconf -e \ postconf -e \
"relay_domains=" \ "relay_domains=" \
"transport_maps=" \ "transport_maps=" \
+34
View File
@@ -0,0 +1,34 @@
package main
import "testing"
// The panel reads the inbound filter's two variables with the semantics of
// build/postfix-config.sh: the action is accept unless it says tempfail, and a
// value the script would refuse is not guessed at.
func TestAntispamActionMirrorsTheBuildScript(t *testing.T) {
for raw, want := range map[string]string{
"": "accept", "accept": "accept", "tempfail": "tempfail",
"reject": "", "Accept": "", "TEMPFAIL": "", " accept": "",
} {
if got := antispamAction(raw); got != want {
t.Errorf("antispamAction(%q) = %q, want %q", raw, got, want)
}
}
}
func TestLoadConfigReadsTheInboundFilter(t *testing.T) {
t.Setenv("INBOUND_RELAY_ENABLE", "true")
t.Setenv("INBOUND_ANTISPAM_MILTER", "inet:antispam:11332")
t.Setenv("INBOUND_ANTISPAM_MILTER_ACTION", "tempfail")
cfg := loadConfig()
if !cfg.inboundEnabled || cfg.inboundAntispamMilter != "inet:antispam:11332" || cfg.inboundAntispamAction != "tempfail" {
t.Errorf("config = %+v", cfg)
}
t.Setenv("INBOUND_ANTISPAM_MILTER", "")
t.Setenv("INBOUND_ANTISPAM_MILTER_ACTION", "")
cfg = loadConfig()
if cfg.inboundAntispamMilter != "" || cfg.inboundAntispamAction != "accept" {
t.Errorf("unset filter: milter %q, action %q", cfg.inboundAntispamMilter, cfg.inboundAntispamAction)
}
}
+16 -4
View File
@@ -26,10 +26,7 @@ func runDMARCIngestMode() error {
} }
defer st.Close() defer st.Close()
recipient := strings.ToLower(strings.TrimSpace(os.Getenv("RECIPIENT"))) recipient := ingestRecipient(os.Args[1:])
if recipient == "" {
recipient = strings.ToLower(strings.TrimSpace(os.Getenv("ORIGINAL_RECIPIENT")))
}
if err := dmarc.IngestMessage(st, io.LimitReader(os.Stdin, maxIngestStdin), recipient, time.Now().UTC()); err != nil { if err := dmarc.IngestMessage(st, io.LimitReader(os.Stdin, maxIngestStdin), recipient, time.Now().UTC()); err != nil {
if incrErr := st.IncrDMARCParseFailures(); incrErr != nil { if incrErr := st.IncrDMARCParseFailures(); incrErr != nil {
log.Printf("dmarc ingest: record failure: %v", incrErr) log.Printf("dmarc ingest: record failure: %v", incrErr)
@@ -39,6 +36,21 @@ func runDMARCIngestMode() error {
return nil return nil
} }
// ingestRecipient is the report address the message was delivered to. Postfix's
// pipe(8) hands it over as an argument (${recipient} in the service's argv,
// build/postfix-config.sh) - it sets no RECIPIENT variable, that is local(8).
// Without it the check that a report came to its own domain's address is
// skipped.
func ingestRecipient(args []string) string {
for _, a := range args {
if a == "-dmarc-ingest" {
continue
}
return strings.ToLower(strings.TrimSpace(a))
}
return ""
}
func isDMARCIngestInvocation() bool { func isDMARCIngestInvocation() bool {
if len(os.Args) > 1 && os.Args[1] == "-dmarc-ingest" { if len(os.Args) > 1 && os.Args[1] == "-dmarc-ingest" {
return true return true
+20
View File
@@ -0,0 +1,20 @@
package main
import "testing"
func TestIngestRecipient(t *testing.T) {
for _, tc := range []struct {
args []string
want string
}{
// As pipe(8) starts it: the symlink name, then ${recipient}.
{[]string{"dmarc-reports+Example.org@mail.example.org"}, "dmarc-reports+example.org@mail.example.org"},
{[]string{"-dmarc-ingest", "dmarc-reports+example.org@mail.example.org"}, "dmarc-reports+example.org@mail.example.org"},
{[]string{"-dmarc-ingest"}, ""},
{nil, ""},
} {
if got := ingestRecipient(tc.args); got != tc.want {
t.Errorf("ingestRecipient(%q) = %q, want %q", tc.args, got, tc.want)
}
}
}
+2
View File
@@ -89,6 +89,8 @@ var loadConfigKeys = []string{
"POSTFIX_DIR", "POSTFIX_DIR",
"SELFPOST_DEPLOY_ROOT", "SELFPOST_DEPLOY_ROOT",
"INBOUND_RELAY_ENABLE", "INBOUND_RELAY_ENABLE",
"INBOUND_ANTISPAM_MILTER",
"INBOUND_ANTISPAM_MILTER_ACTION",
"DMARC_REPORTS_ENABLE", "DMARC_REPORTS_ENABLE",
} }
+2
View File
@@ -110,6 +110,8 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds, RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
RetryPolicy: retryPolicy, RetryPolicy: retryPolicy,
InboundEnabled: cfg.inboundEnabled, InboundEnabled: cfg.inboundEnabled,
InboundAntispamMilter: cfg.inboundAntispamMilter,
InboundAntispamAction: cfg.inboundAntispamAction,
DMARCEnabled: cfg.dmarcEnabled, DMARCEnabled: cfg.dmarcEnabled,
SendLogRetentionEnvDefault: cfg.retentionDays, SendLogRetentionEnvDefault: cfg.retentionDays,
}, cfg.setupTokenPath) }, cfg.setupTokenPath)
+21
View File
@@ -91,6 +91,11 @@ type config struct {
deployRoot string deployRoot string
inboundEnabled bool inboundEnabled bool
// The optional milter on the inbound listener (INBOUND_ANTISPAM_MILTER) and
// what Postfix does when it is down. Read-only: build/postfix-config.sh is
// what attaches it; the panel only says so. See antispamAction.
inboundAntispamMilter string
inboundAntispamAction string
dmarcEnabled bool dmarcEnabled bool
} }
@@ -155,11 +160,27 @@ func loadConfig() config {
deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"), deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
// Optional inbound relay (backup-MX / forwarder). Off unless exactly "true". // Optional inbound relay (backup-MX / forwarder). Off unless exactly "true".
inboundEnabled: os.Getenv("INBOUND_RELAY_ENABLE") == "true", inboundEnabled: os.Getenv("INBOUND_RELAY_ENABLE") == "true",
inboundAntispamMilter: os.Getenv("INBOUND_ANTISPAM_MILTER"),
inboundAntispamAction: antispamAction(os.Getenv("INBOUND_ANTISPAM_MILTER_ACTION")),
// Optional DMARC aggregate ingest on port 25. Off unless exactly "true". // Optional DMARC aggregate ingest on port 25. Off unless exactly "true".
dmarcEnabled: os.Getenv("DMARC_REPORTS_ENABLE") == "true", dmarcEnabled: os.Getenv("DMARC_REPORTS_ENABLE") == "true",
} }
} }
// antispamAction reads INBOUND_ANTISPAM_MILTER_ACTION the way
// build/postfix-config.sh does: empty means accept, and accept and tempfail are
// the only other values it lets the container start with. Anything else is
// returned as "" — the panel says nothing about the action rather than guess.
func antispamAction(raw string) string {
switch raw {
case "", "accept":
return "accept"
case "tempfail":
return "tempfail"
}
return ""
}
// saslRealm chooses the realm new SASL accounts live under. It mirrors the // saslRealm chooses the realm new SASL accounts live under. It mirrors the
// hostname Postfix's SASL layer uses so a client authenticating with a bare // hostname Postfix's SASL layer uses so a client authenticating with a bare
// login resolves to the right account. // login resolves to the right account.
+4 -4
View File
@@ -74,7 +74,7 @@ func restoreFromOwnBackup(t *testing.T, password string) restored {
func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) { func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
r := restoreFromOwnBackup(t, "") r := restoreFromOwnBackup(t, "")
body := getPage(t, r.panel, "/deliveries", signIn(t, r.panel)) body := getPage(t, r.panel, "/outbound/log", signIn(t, r.panel))
for _, want := range []string{restoreDomain, restoreSubject} { for _, want := range []string{restoreDomain, restoreSubject} {
if !strings.Contains(body, want) { if !strings.Contains(body, want) {
t.Errorf("the restored panel's send log does not show %q:\n%s", want, body) t.Errorf("the restored panel's send log does not show %q:\n%s", want, body)
@@ -128,7 +128,7 @@ func TestARestoreDoesNotReopenTheSetupLink(t *testing.T) {
func TestARestoredPanelHonoursSessionsFromTheArchive(t *testing.T) { func TestARestoredPanelHonoursSessionsFromTheArchive(t *testing.T) {
r := restoreFromOwnBackup(t, "") r := restoreFromOwnBackup(t, "")
rec := request(t, r.panel, http.MethodGet, "/deliveries", nil, r.session) rec := request(t, r.panel, http.MethodGet, "/outbound/log", nil, r.session)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Errorf("a session from before the backup = %d on the restored panel, want 200", rec.Code) t.Errorf("a session from before the backup = %d on the restored panel, want 200", rec.Code)
} }
@@ -141,7 +141,7 @@ func TestARestoredPanelHonoursSessionsFromTheArchive(t *testing.T) {
func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) { func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) {
r := restoreFromOwnBackup(t, "a-long-enough-password") r := restoreFromOwnBackup(t, "a-long-enough-password")
body := getPage(t, r.panel, "/deliveries", signIn(t, r.panel)) body := getPage(t, r.panel, "/outbound/log", signIn(t, r.panel))
if !strings.Contains(body, restoreSubject) { if !strings.Contains(body, restoreSubject) {
t.Errorf("the panel restored from an encrypted backup lost the send log:\n%s", body) t.Errorf("the panel restored from an encrypted backup lost the send log:\n%s", body)
} }
@@ -384,7 +384,7 @@ func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password
form.Set("password", password) form.Set("password", password)
form.Set("password_confirm", password) form.Set("password_confirm", password)
} }
rec := request(t, h, http.MethodPost, "/backup", strings.NewReader(form.Encode()), session) rec := request(t, h, http.MethodPost, "/server/backup", strings.NewReader(form.Encode()), session)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("download a backup = %d, want 200:\n%s", rec.Code, rec.Body.String()) t.Fatalf("download a backup = %d, want 200:\n%s", rec.Code, rec.Body.String())
} }
+1 -1
View File
@@ -21,7 +21,7 @@
services: services:
selfpost: selfpost:
image: ghcr.io/mixeme/selfpost:1.9.3 image: ghcr.io/mixeme/selfpost:2.0.0
restart: unless-stopped restart: unless-stopped
environment: environment:
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}" SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
+272 -59
View File
@@ -114,10 +114,10 @@ One process, four roles:
parameters (`queue_run_delay`, `minimal_backoff_time`, parameters (`queue_run_delay`, `minimal_backoff_time`,
`maximal_backoff_time`, `maximal_queue_lifetime`, `bounce_queue_lifetime`, `maximal_backoff_time`, `maximal_queue_lifetime`, `bounce_queue_lifetime`,
`delay_warning_time`) and caches the snapshot on the handlers config. The `delay_warning_time`) and caches the snapshot on the handlers config. The
Mail queue card and a delivery's `deferred` / `bounced` history print those Outbound queue box and a delivery's `deferred` / `bounced` history print those
numbers; they never call `postconf` per request. If `postconf` is missing, numbers; they never call `postconf` per request. If `postconf` is missing,
the panel logs a warning and uses Postfix 3.x compiled-in defaults the panel logs a warning and uses Postfix 3.x compiled-in defaults
(`300s` / `4000s` / `5d` / `0`) with a muted note on the card. (`300s` / `4000s` / `5d` / `0`) with a muted note on the box.
2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/ 2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open** Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open**
(`default_action=accept`) so milter failure does not stop mail. Domain (`default_action=accept`) so milter failure does not stop mail. Domain
@@ -133,8 +133,8 @@ One process, four roles:
3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by 3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by
queue-id. Send-log `queued → sent` transitions depend on this goroutine alone queue-id. Send-log `queued → sent` transitions depend on this goroutine alone
(`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)). (`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)).
4. **rate-limit recalc** — every six hours (and on demand from the domain 4. **rate-limit recalc** — every six hours (and on demand from Domain settings and
page), recomputes level-2 **Auto** rate limits from send-log statistics. the application form), recomputes level-2 **Auto** rate limits from send-log statistics.
Milter chain in Postfix: OpenDKIM (tempfail) then journal (accept on failure). Milter chain in Postfix: OpenDKIM (tempfail) then journal (accept on failure).
@@ -159,7 +159,7 @@ panel user cannot read). `follow()` drains the old inode once more before
switching descriptors; the panel treats a missing log file as an empty tail, not switching descriptors; the panel treats a missing log file as an empty tail, not
an error. an error.
**Read offset is persisted** (`logtail_state` table, migration `0003`): the **Read offset is persisted** (`logtail_state` table, in the baseline schema): the
tailer stores its position plus a fingerprint of the log's first 512 bytes, and tailer stores its position plus a fingerprint of the log's first 512 bytes, and
on start resumes from it, parsing the tail written while the panel was down. If on start resumes from it, parsing the tail written while the panel was down. If
the fingerprint no longer matches (rotated or recreated in the meantime) it reads the fingerprint no longer matches (rotated or recreated in the meantime) it reads
@@ -185,8 +185,8 @@ rotated past fourteen files while the panel was down, or deleted.
**Two one-shot reads** sit beside the follow loop and are unrelated to it, both **Two one-shot reads** sit beside the follow loop and are unrelated to it, both
serving panel pages on request: `TailLines` (the last *n* lines, for serving panel pages on request: `TailLines` (the last *n* lines, for
`/system-log`) and `QueueLines` (the lines carrying one queue-id, for `/server/log`) and `QueueLines` (the lines carrying one queue-id, for
`/deliveries/{id}`). `QueueLines` scans a bounded tail of the current file — `/outbound/log/{id}`). `QueueLines` scans a bounded tail of the current file —
finding a message's lines means reading rather than seeking — and matches the id finding a message's lines means reading rather than seeking — and matches the id
anchored on the character before it, since queue ids are hexadecimal runs and a anchored on the character before it, since queue ids are hexadecimal runs and a
shorter one is regularly the tail of a longer one. Send-log rows outlive the log shorter one is regularly the tail of a longer one. Send-log rows outlive the log
@@ -197,61 +197,227 @@ state for an older message and the page reports it as such, not as a failure.
## Panel HTTP surface ## Panel HTTP surface
Canonical routes: [internal/web/web.go](../internal/web/web.go). Authenticated Canonical routes: [internal/web/web.go](../internal/web/web.go). The router is
unless noted. Routes marked **global** return **404** for domain administrators two flat `http.ServeMux`es built by `muxes()` — one pattern per route, method
(`requireGlobal()` in included (`GET /outbound/log/{id}`), nothing mounted as a sub-router, and
[internal/web/handlers/authz.go](../internal/web/handlers/authz.go)). The table building them only takes method values, so the route guard test can run it on
below is a summary — HTMX fragment endpoints a bare `Server` and ask which pattern answers a path:
(`/status/fragment`, `/deliveries/rows`, `/mail-queue/body`,
`/system-log/body`, …) and every POST variant live in `web.go`.
| Route | Purpose | - **public** — `/healthz` (liveness), `/license` (the embedded `LICENSE`),
`/static/`, `/setup/`, `/login`, `/logout`;
- **authenticated** — everything else, behind `RequireAuth`: a request without
a live session is redirected to `/login`.
Both sit inside `secure()`, which sets the security headers and runs the origin
check on every state-changing request (see below).
### Routes
A path reads like the menu: `/<group>/<page>`, an entity under its list, an
action under the thing it changes. Every state change is a `POST`; a `GET`
only reads (the delete confirmation at `GET …/delete` renders a form).
| Group | Routes |
|---|---| |---|---|
| `/healthz` | Liveness (no auth) | | Home | `GET /` → `/overview`, or for a user with the *domain* role to the first list they reach (`/outbound/domains`, or `/inbound/domains` when that is all) |
| `/license` | Embedded `LICENSE` text (no auth) | | Overview | `GET /overview`, `GET /overview/fragment` |
| `/setup/*` | One-time admin bootstrap | | Outbound › Domains | `GET, POST /outbound/domains` · `/outbound/domains/{id}` · `POST …/dns-recheck` · `GET, POST …/delete` · `GET …/settings` · `POST …/settings/reports`, `…/ratelimit`, `…/ratelimit/recalc`, `…/export` |
| `/login`, `/logout` | Session auth | | Outbound › Applications | `GET, POST /outbound/domains/{id}/applications/new` · `GET, POST …/applications/{aid}` · `POST …/applications/{aid}/password`, `…/{aid}/ratelimit/recalc`, `…/{aid}/delete` |
| `/account` | 308 redirect to `/settings` (pre-1.2.3 route, kept as a compat shim) | | Outbound › Log, Queue | `GET /outbound/log` · `/outbound/log/fragment` · `/outbound/log/{id}` · `GET /outbound/queue` · `/outbound/queue/fragment` |
| `/status`, `/status/*` | **Global.** Process, cert, socket, PTR checks; machine CPU/memory/network | | Outbound › DMARC reports | `GET /outbound/dmarc` · `…/dmarc/domains/{id}` · `…/dmarc/reports/{id}` — registered only with `DMARC_REPORTS_ENABLE=true` |
| `/domains` | Domain list; `POST /domains` (add domain) is **global** | | Inbound › Domains | `GET, POST /inbound/domains` · `/inbound/domains/{id}` · `POST …/dns-recheck`, `…/upstream`, `…/recipients` · `GET, POST …/delete` — registered only with `INBOUND_RELAY_ENABLE=true` |
| `/domains/{id}`, `/domains/{id}/*` | Assigned-domain detail for domain-admins; delete domain is **global** | | Server | `GET /server/health` (+ `/fragment`) · `POST /server/health/recheck`, `/server/health/reload` · `GET /server/log` (+ `/fragment`) · `GET, POST /server/backup` · `POST /server/backup/import` · `/server/users`, `…/new`, `…/{uid}`, `…/{uid}/delete` · `GET, POST /server/settings` · `GET /server/components` |
| `/domains/import` | **Global.** Domain import (`POST`; form on the Backup page) | | Account | `GET /account` · `POST /account/profile`, `/account/password` |
| `/deliveries`, `/deliveries/{id}` | Send log with filters; scoped to assigned domains for domain-admins | | User menu | `GET /help` · `POST /logout` (public mux) |
| `/mail-queue`, `/mail-queue/*` | **Global.** Postfix queue view; retry-policy card on the page (not the HTMX fragment) |
| `/system-log`, `/system-log/*` | **Global.** `mail.log` tail |
| `/reload` | **Global.** `POST` — reload OpenDKIM tables, Postfix sender map, and inbound relay maps when enabled (not DMARC maps — see § Persistence restore) |
| `/backup`, `/backup/*` | **Global.** Full backup download (page also hosts the import form) |
| `/help` | In-panel operator help (any authenticated user) |
| `/settings` | Username/password for any user; DMARC report default is **global** only |
| `/users`, `/users/*` | **Global.** Panel user CRUD |
| `/inbound`, `/inbound/{id}`, `/inbound/{id}/*` | **Global.** Inbound relay domains. Registered only when `INBOUND_RELAY_ENABLE=true`; otherwise 404. |
| `/dmarc`, `/dmarc/reports/{id}`, `/dmarc/domains/{id}` | **Global** list and report detail; domain roll-up scoped like deliveries. Registered only when `DMARC_REPORTS_ENABLE=true`; otherwise 404. |
HTMX polling refreshes monitoring fragments (5 s while the operator is active on An application is addressed under its domain, and an application id that does
the page, 30 s when the tab is visible but idle, none when hidden — scheduled in not belong to the domain in the path is a 404. The application form is one
`panel.js` via `data-poll`, not `hx-trigger="every …"`); polling does not extend `POST`: sender, client IPs and rate limit are validated and saved together or
session idle timeout (only non-`HX-Request` GET and mutating requests count as not at all, also at creation. The page that shows a new password is the
activity). The Mail queue retry-policy card is outside that fragment: it is the response to the `POST` that created or regenerated it; it has no `GET` path and
start-up `postconf -h` snapshot (see [Panel binary](#panel-binary-cmdpanel)), carries `Cache-Control: no-store`.
not a live re-read.
The pre-2.0 paths are not served and not redirected (`/status`, `/domains/…`,
`/inbound/{id}/…`, `/deliveries`, `/mail-queue`, `/system-log`, `/backup`,
`/users/…`, `/settings`, `/reload`, `/dmarc/…`, and `/account` as a redirect):
each answers 404 once signed in. `TestRoutesFollowNavigation`
([internal/web/guard_routes_test.go](../internal/web/guard_routes_test.go))
holds the table: every path is registered with its methods, every menu entry
points at a registered path, every old path answers 404, and no template
contains an old path.
### Who may open what
Authorization is by tree where the tree is the boundary, and by object where it
is not.
- **`/server/` is guarded once.** Routes under it are registered through
`server()` in `web.go`, which wraps the handler in `globalOnly` (404 for
anyone but the global role — the answer a missing page gets, so the panel does
not confirm the page exists) and panics at start-up for a pattern outside
`/server/`. The handlers also call `requireGlobal()` themselves.
- **Overview and the Outbound queue** are global-only but not under `/server/`,
so their handlers call `requireGlobal()`.
- **Outbound is checked per domain.** `lookupDomain` resolves `{id}` and answers
404 for a missing domain and for one the principal may not reach alike
([handlers/handlers_domains.go](../internal/web/handlers/handlers_domains.go));
application routes go on to check the application belongs to that domain.
Lists and the log are filtered by assignment in the query
(`ListDomainsForUser`), not in the template. Adding and deleting a domain is
`requireGlobal()`.
- **Inbound is checked per inbound domain**, separately from outbound
([handlers/handlers_inbound.go](../internal/web/handlers/handlers_inbound.go)):
`requireInbound` (the list), `requireInboundDomain` (one domain — page,
upstream, recipients, DNS re-check) and `requireInboundGlobal` (add, delete).
All three answer 404 alike.
- **The DMARC hub** is open to anyone with outbound reach for the reports of
their own domains; the *Ingest* box (server-wide statistics) is rendered for
the global role only.
- **Account and Help** are open to every signed-in user.
- The principal ([web/auth/principal.go](../internal/web/auth/principal.go))
carries the role, the assigned outbound and inbound domain ids and the two
*All* flags; `CanAccessDomain` and `CanAccessInboundDomain` are the only
predicates. The reach, not the role, decides which menu groups exist.
Routes marked global are tested per path for a user with the *domain* role,
including another tenant's domain id
([handlers/handlers_delegation_test.go](../internal/web/handlers/handlers_delegation_test.go),
[authz_test.go](../internal/web/handlers/authz_test.go)).
### Polled fragments
A polled page answers its refresh at `<page>/fragment` (`/overview/fragment`,
`/outbound/log/fragment`, `/server/health/fragment`, …). The fragment is the
same `{{define}}` block that renders the page's own box, so the first paint and
a refresh cannot differ. HTMX refreshes them every 5 s while the operator is
active on the page, every 30 s when the tab is visible but idle, and not at all
when it is hidden — scheduled in `panel.js` via `data-poll`, not
`hx-trigger="every …"`, because a trigger filter is evaluated with `new
Function`, which the CSP forbids. Polling does not extend the session idle
timeout (only non-`HX-Request` `GET`s and mutating requests count as
activity). The Outbound queue's retry-policy box is outside the polled region:
it is the start-up `postconf -h` snapshot (see [Panel
binary](#panel-binary-cmdpanel)), not a live re-read.
### View engine and component kit
Pages are rendered by [internal/web/view](../internal/web/view/view.go). There
is one layout and one way to render.
- **One layout.** Every page is parsed with `layout.html` (the shell: brick
navbar with the wordmark, the perforated edge, the strip of sibling pages,
user menu, footer) and `components.html` (the partials), then its own file.
`layout_signed_out` is the same document without navigation, used for sign-in
and setup; `Engine.Render` picks it when the page data names no user. The
page's file defines `content`; `pageFiles` in `view.go` lists the files of
each page, and fragments are listed in `fragmentFiles` and rendered by
`RenderFragment` with no layout.
- **Typed page data.** A page's data is a struct that embeds `Meta` (title,
user, the viewer's reach, the menu group and page it sits in) and carries
the page's boxes and rows as fields, built by a constructor in the `view`
package (`NewOutDomain`, `NewHelp`, …) that the handler fills. The shell is
derived from `Meta` and the feature flags (`SetInboundEnabled`,
`SetDMARCEnabled`) in one place, `Engine.shell`, so which entries the menu
has is decided once. No template reads a `map[string]any`; the one map left
in the handlers is the health sampler's, which is turned into a typed
`Health` before rendering.
- **The component kit.** [components.html](../internal/web/view/templates/components.html)
holds one `{{define}}` per partial (page head, postmark, box and its head and
foot, flash, health card, DNS record, facts, side menu, timeline, log pane,
empty state, confirm list, help topic, form rows) and is the only file that
writes their `sp-` markup. [components.go](../internal/web/view/components.go)
is the typed input of each (`Head`, `Box`, `Record`, `Fact`, `SideMenu`, …),
so a page passes a struct, never a `dict`. Template functions: `status_tag`
(a status → Bulma's tag classes, once), `wbr_at` (a break after the `@` in a
table cell). A page that needs a component the kit lacks is a design change
made in the mockups first
([plans/panel-redesign.md](plans/panel-redesign.md) § The contract).
- **Help.** [help.go](../internal/web/view/help.go) is the Help page's data:
topics grouped by section of the panel. A box's head links to its topic with
`Box.Help` (`/help#<topic>`), and the page lists only the
sections the viewer's menu shows.
- **Assets.** [static/](../internal/web/view/static) is embedded
(`//go:embed`): vendored `bulma.min.css` and a Tabler icon subset (checksummed),
`panel.css` (the mockups' stylesheet, rule for rule), `htmx.min.js`, `panel.js`,
the IBM Plex subset and the logo files. Served with a content-derived `ETag`,
and `/static/` does not list its files. The kit page
`GET /server/components` renders every partial in every state from fixtures
(global role only).
**Guard tests** hold the design in place; they run in `go test ./...` and CI.
They read their rules from the accepted mockups
([docs/assets/panel-redesign/panel/](assets/panel-redesign/index.html)) rather
than keep a copy: `TestPanelClassVocabulary` (only the Bulma subset and the
`sp-` classes of `panel.css`; every `sp-` class defined, used and shown on the
components page), `TestPanelCSSIsTheMockupStylesheet`, `TestPanelCSSContract`
(no `!important`, no id or page selector, type floor, widths),
`TestTemplatesUseComponents` (no hand-written component markup in a page),
`TestPanelOutlinesMatchMockups` and `TestPanelPageStructure` (each rendered page
has the component skeleton of its mockup), `TestRedesignedPagesLoadOnlyTheKit`,
`TestVendoredAssetsArePinned`, `TestStaticHoldsOnlyTheKnownAssets`,
`TestRoutesFollowNavigation`, `TestNoTemplateUsesInlineScriptOrStyle`. The
ratchet list `internal/web/view/legacy_pages.txt` is empty and can only shrink;
the CI step
[design-first](../.github/scripts/design-first.sh) rejects a commit that
changes a guard together with the code it judges, and a template that is a
byte-for-byte return of one from history. The design contract and the process
are in [plans/panel-redesign.md](plans/panel-redesign.md) § Enforcement.
### Security headers, CSP and scripts
`secure()` ([internal/web/security.go](../internal/web/security.go)) puts these
on every response: `Content-Security-Policy: default-src 'self'; object-src
'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'`,
`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`,
`Referrer-Policy: no-referrer`, and `Strict-Transport-Security` when the cookie
is `Secure`. Because the policy has no exemptions, no template carries an
inline `<script>`, an event-handler attribute or `style="…"` — the page's
behaviour is `htmx.min.js` plus `panel.js` (copy and *fill* buttons, the
`data-confirm` prompt on destructive forms, conditional field visibility,
adaptive polling), both optional: every form works as a native HTML `POST`
with scripts off. htmx is told not to inject its indicator stylesheet
(`htmx-config` meta), which the CSP would otherwise have to allow.
State-changing requests (every non-`GET`/`HEAD`/`OPTIONS`) pass `originAllowed`
first: `Sec-Fetch-Site` must say `same-origin`, or `Origin` must name the
panel's host; a request with neither header is let through (an accepted risk).
There are no CSRF tokens — see [security.md](security.md).
### Errors shown to users
A validation refusal is shown as the text the validator wrote (on the form,
next to the field, or in a notice above the first box). Anything else — a
database error, `saslpasswd2`, a Postfix reload — is logged
and the page gets a fixed sentence saying so; the error text never reaches the
browser. A template error renders a bare `500` rather than a half-written page
(`Engine.Render` buffers).
### Sessions ### Sessions
Stored in SQLite (`sessions` table, migration `0002`): cookie holds a random Stored in SQLite (`sessions` table of the baseline, `0001_init.sql`): the cookie
token; the database stores **SHA-256 of the token**, not the token itself — a holds a random token; the database stores **SHA-256 of the token**, not the
stolen DB or backup archive does not alone grant login, but a browser that still token itself — a stolen DB or backup archive does not alone grant login, but a
holds the cookie works after process restart, redeploy, or full backup restore. browser that still holds the cookie works after process restart, redeploy, or
full backup restore.
- **A session belongs to a user by id** (`sessions.user_id`, `ON DELETE
CASCADE`). Deleting a user ends their sessions; renaming one keeps them;
`RequireAuth` loads the user on every request, so a session whose user is
gone is a redirect to `/login`.
- **Idle timeout** — sliding window, `PANEL_SESSION_IDLE_DAYS` (default 7); no - **Idle timeout** — sliding window, `PANEL_SESSION_IDLE_DAYS` (default 7); no
absolute cap (regular use keeps the session alive indefinitely). absolute cap (regular use keeps the session alive indefinitely).
- **Renewal** — DB `last_seen` and cookie `Max-Age` update at most once per hour - **Renewal** — the DB `expires_at` and the cookie `Max-Age` update at most once per
(`renewThreshold` in [internal/web/auth/session.go](../internal/web/auth/session.go)). hour (`renewThreshold` in
- **Password change on `/settings`** — changing your own password deletes [internal/web/auth/session.go](../internal/web/auth/session.go)).
every other session for that user; the current session stays active - **Password change on Account** — deletes every other session of that user;
the current one stays active
([internal/store/sessions.go](../internal/store/sessions.go), ([internal/store/sessions.go](../internal/store/sessions.go),
[handlers_settings.go](../internal/web/handlers/handlers_settings.go)). [handlers_account.go](../internal/web/handlers/handlers_account.go)).
A global administrator resetting another user's password on `/users` updates - **A password set by a global user on the user form** deletes all of that
the hash but does not delete that user's existing sessions. user's sessions (`DestroyUserSessions`); set for oneself, it keeps the
current one
([handlers_users.go](../internal/web/handlers/handlers_users.go),
`endSessionsAfterPasswordSet`).
Restoring an **older** backup also restores session rows: a session removed Restoring an **older** backup also restores session rows: a session removed
after that backup was taken can become valid again if the browser still holds after that backup was taken can become valid again if the browser still holds
@@ -264,7 +430,7 @@ the cookie and the restored row's `expires_at` has not passed.
Multi-store writes that must land in more than one place (SQLite row, Multi-store writes that must land in more than one place (SQLite row,
`sasldb2` entry, Postfix map, OpenDKIM table) go through a service, which is `sasldb2` entry, Postfix map, OpenDKIM table) go through a service, which is
also where the rollback of a partial failure lives. Handlers may call also where the rollback of a partial failure lives. Handlers may call
`store` directly for single-table reads and simple writes (sessions, admin, `store` directly for single-table reads and simple writes (sessions, users,
send-log queries); the first-run setup-token file is read and written in send-log queries); the first-run setup-token file is read and written in
`web` itself. The adapters below the services are the only code that knows `web` itself. The adapters below the services are the only code that knows
about Postfix, OpenDKIM, DNS or the log file, which is what makes them about Postfix, OpenDKIM, DNS or the log file, which is what makes them
@@ -295,7 +461,7 @@ flowchart TB
dmarcSvc["internal/dmarc"] dmarcSvc["internal/dmarc"]
end end
subgraph persistence ["Persistence"] subgraph persistence ["Persistence"]
store["internal/store — SQLite, embedded migrations"] store["internal/store — SQLite, embedded schema"]
end end
subgraph adapters ["Adapters — the only infrastructure-aware code"] subgraph adapters ["Adapters — the only infrastructure-aware code"]
postfix["internal/postfix"] postfix["internal/postfix"]
@@ -344,7 +510,7 @@ single-connection trade-off that follows from it.
| Path | Contents | | Path | Contents |
|---|---| |---|---|
| `selfpost.db` | SQLite panel state (domains, apps, users, sessions, send log, L2 limits, log-tailer offset, inbound relay, DMARC reports) — see [schema-migrations.md](schema-migrations.md) | | `selfpost.db` | SQLite panel state (users, sessions, domains, apps, send log, L2 limits, log-tailer offset, inbound relay, DMARC reports) — see [Database](#database) and [schema-migrations.md](schema-migrations.md) |
| `setup-token` | First-run setup token file | | `setup-token` | First-run setup token file |
| `opendkim/` | DKIM keys + tables | | `opendkim/` | DKIM keys + tables |
| `sasl/sasldb2` | Application SASL credentials | | `sasl/sasldb2` | Application SASL credentials |
@@ -363,6 +529,49 @@ single-connection trade-off that follows from it.
Not in `/data`: TLS certificates for the panel (reverse-proxy mount) — though Not in `/data`: TLS certificates for the panel (reverse-proxy mount) — though
full backups also archive the operator's `./certs` PEM files when present. full backups also archive the operator's `./certs` PEM files when present.
### Database
The schema is one embedded baseline, `internal/store/migrations/0001_init.sql`,
applied by `store.Open` into an empty file; `PRAGMA user_version` is `1` and
`PRAGMA application_id` is `0x53503230` ("SP20"). There is no 1.x migration
chain. **2.0 starts from an empty data directory:** a database with a schema
version but without the 2.x `application_id` — what every 1.x file looks like —
is refused at start with `ErrForeignSchema` and left untouched, as is one whose
version is newer than the build knows. A restored 1.x backup fails the same
way. Domains are carried across with a domain export, whose format is not the
database. Later schema changes are new files (`0002_*.sql`, …); once `2.0.0` is
released the baseline is never edited. History and the per-table list:
[schema-migrations.md](schema-migrations.md).
The tables that carry the panel's model:
- **`users`** — `role` is `global` or `domain` (a reach, not a rank); `email` is
the user's own address, edited on Account and set by the global role on the
user form; `all_domains` / `all_inbound_domains` widen a *domain* user to
every domain of that direction, including ones added later (with a flag set
the assignment rows are ignored).
- **`user_domains`** and **`user_inbound_domains`** — the outbound and inbound
domains assigned to a *domain* user. Separate tables: the same name on both
is two assignments, and a sending domain gives no inbound access or the other
way round.
- **`sessions`** — `token_hash`, `user_id` (`ON DELETE CASCADE`), `expires_at`.
Keyed by user id, not by name.
- **`domains`** — `dmarc_rua` is the whole of a domain's report-address choice:
`''` is no reports (a new domain), the address SelfPost derives for the
domain and the server's hostname is the hosted choice, anything else is an
address typed for the domain. Nothing refers to a user, and deleting one
changes no domain.
- **`settings`** — key/value, what holds for the instance whoever is signed in
(send-log retention). Nothing that belongs to a user.
- `applications` (with `auth_ip_restrict` / `auth_allowed_ips`),
`application_addresses`, `rate_limits` (no `allowed_ips` column), `send_log`,
`logtail_state`, `inbound_domains`, `inbound_transports`,
`inbound_recipients`, `dmarc_reports`, `dmarc_report_records`.
The panel is the only writer; SQLite runs in WAL mode on one connection (see
[Panel binary](#panel-binary-cmdpanel)), which is why a full backup snapshots
with `VACUUM INTO` instead of copying the file.
**Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90); **Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90);
`mail.log` via logrotate (14 rotated files, check every 6h, rename + `mail.log` via logrotate (14 rotated files, check every 6h, rename +
`postfix reload` in `postrotate` — see § Log tailer above). `postfix reload` in `postrotate` — see § Log tailer above).
@@ -377,7 +586,8 @@ tables, Postfix's sender map, inbound relay maps (when
`INBOUND_RELAY_ENABLE=true`), and DMARC maps (when `DMARC_REPORTS_ENABLE=true`) `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 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 extracted archive and the database is healed before mail flows. Manual
`POST /reload` on the Status page resyncs OpenDKIM, the sender map, and `POST /server/health/reload` (the *Reload configuration* button on Server ›
Health) resyncs OpenDKIM, the sender map, and
inbound maps only — not DMARC maps. Stopped-container inbound maps only — not DMARC maps. Stopped-container
`tar` of `./data` alone remains possible for state-only copies (see guide). `tar` of `./data` alone remains possible for state-only copies (see guide).
@@ -386,9 +596,12 @@ inbound maps only — not DMARC maps. Stopped-container
scrypt → AES-256-GCM over 64 KiB chunks, each authenticated with the header, scrypt → AES-256-GCM over 64 KiB chunks, each authenticated with the header,
its counter and an end-of-stream flag (so truncation and reordering fail to its counter and an end-of-stream flag (so truncation and reordering fail to
open). Full backup `.tar.gz` → `.spbk` (SelfPost backup), domain export open). Full backup `.tar.gz` → `.spbk` (SelfPost backup), domain export
`.json` → `.spde` (SelfPost domain export); the plain forms remain the `.json` → `.spde` (SelfPost domain export). In the panel the *Encrypt with a
default. Domain import detects the envelope by magic bytes; an encrypted full password* box is ticked by default on both downloads and the plain form is
backup is converted back with `selfpost-backup -decrypt` before restore. what an unticked box gives; the `selfpost-backup` CLI stays plain unless it is
given a password. Domain import detects the envelope by magic bytes; an
encrypted full backup is converted back with `selfpost-backup -decrypt` before
restore.
--- ---
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

+2 -2
View File
@@ -13,7 +13,7 @@
<h1>The accepted direction, every screen</h1> <h1>The accepted direction, every screen</h1>
<p class="lead">Direction <strong>C</strong> (Bulma) was picked as the base: the important things in view, details one <p class="lead">Direction <strong>C</strong> (Bulma) was picked as the base: the important things in view, details one
or two clicks away. It takes the postal signature of <strong>A</strong> — the postmark verdict, numbered mono section or two clicks away. It takes the postal signature of <strong>A</strong> — the postmark verdict, numbered mono section
heads, the stamp's perforated paper edge under a brick bar that carries the wordmark — and the icons of <strong>B</strong>. Thirty-three screens and a components page live in heads, the stamp's perforated paper edge under a brick bar that carries the wordmark — and the icons of <strong>B</strong>. Thirty-four screens and a components page live in
<a href="panel/overview.html">panel/</a>. Serve this folder from the repository root: the pages load the panel's own <a href="panel/overview.html">panel/</a>. Serve this folder from the repository root: the pages load the panel's own
fonts and mark by relative path.</p> fonts and mark by relative path.</p>
@@ -21,7 +21,7 @@
<div class="scroll"> <div class="scroll">
<table> <table>
<tbody> <tbody>
<tr><th>Signed out</th><td><a href="panel/login.html">Sign in</a> · <a href="panel/setup.html">Create administrator</a></td></tr> <tr><th>Signed out</th><td><a href="panel/login.html">Sign in</a> · <a href="panel/login-no-admin.html">sign in before an administrator exists</a> · <a href="panel/setup.html">Create administrator</a></td></tr>
<tr><th>Overview</th><td><a href="panel/overview.html">Overview</a> — postmark verdict, six health cards, both domain lists</td></tr> <tr><th>Overview</th><td><a href="panel/overview.html">Overview</a> — postmark verdict, six health cards, both domain lists</td></tr>
<tr><th>Outbound</th><td><a href="panel/out-domains.html">Domains</a> · <a href="panel/out-domain.html">domain</a> · <tr><th>Outbound</th><td><a href="panel/out-domains.html">Domains</a> · <a href="panel/out-domain.html">domain</a> ·
<a href="panel/out-domain-settings.html">domain settings</a> · <a href="panel/out-app.html">application form</a> · <a href="panel/out-domain-settings.html">domain settings</a> · <a href="panel/out-app.html">application form</a> ·
+6 -53
View File
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a> <form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div> </div>
</div> </div>
</div> </div>
@@ -69,24 +69,26 @@
<div class="sp-grow"> <div class="sp-grow">
<p class="sp-kicker">Signed in as admin · global</p> <p class="sp-kicker">Signed in as admin · global</p>
<h1 class="title is-3">Account</h1> <h1 class="title is-3">Account</h1>
<p class="sp-lead">Who you are to this panel, what it should tell you about, and where reports about your domains go. Applications keep their own logins and passwords and are not affected.</p> <p class="sp-lead">Who you are to this panel. Applications keep their own logins and passwords and are not affected.</p>
</div> </div>
</div> </div>
<div class="columns"> <div class="columns">
<div class="column is-5"> <div class="column is-6">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Profile</h2></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Profile</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
<form class="sp-form" method="post" action="#"> <form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="admin" autocomplete="username" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div> <div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="admin" autocomplete="username" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div>
<div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div> <div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
<p class="help">Where the panel writes to you: the notifications on the right and a password-reset link. It can also receive DMARC reports — your choice, under <a href="#dmarc">DMARC reports</a>.</p></div> <p class="help">Your own address. A domain's report address can be filled with it from Domain settings.</p></div>
<button type="submit" class="button is-primary">Save profile</button> <button type="submit" class="button is-primary">Save profile</button>
</form> </form>
</div> </div>
</div> </div>
</div>
<div class="column is-6">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Password</h2></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>Password</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
@@ -102,55 +104,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="column is-7">
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Notifications</h2><div class="sp-end">Sent to mix@example.org</div></div>
<div class="sp-box-body">
<form class="sp-form" method="post" action="#">
<p class="sp-kicker mb-2">Your domains</p>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev" checked> A DNS record stops matching — DKIM, SPF, DMARC or an inbound MX</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev" checked> A domain or an application reaches its rate limit and mail is being refused</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev" checked> An inbound domain's upstream cannot be reached and mail is piling up</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev"> DMARC reports start showing failures for a domain</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev"> Mail is held in quarantine</label></div>
<p class="sp-kicker mt-4 mb-2">The server · global role</p>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev" checked> The TLS certificate is within 14 days of expiry</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev" checked> A process or a milter socket stops answering</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev"> Messages sit in a queue for more than a day</label></div>
<p class="help mb-3">One message when a problem starts and one when it clears — never a stream. A domain administrator is told only about the domains assigned to them.</p>
<button type="submit" class="button is-primary">Save notifications</button>
</form>
</div>
</div>
<div class="box" id="dmarc">
<div class="sp-box-head"><span class="sp-no">04</span><h2>DMARC reports</h2><div class="sp-end"><span class="tag is-success is-light">authorized</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-record">
<form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="rua">My default report address</label>
<div class="control"><div class="select is-fullwidth"><select id="rua" name="dmarc_default">
<option>SelfPost hosted — read them under DMARC reports</option>
<option selected>My account e-mail — mix@example.org</option>
<option>Another address…</option>
<option>No reports</option>
</select></div></div>
<p class="help">Used by every outbound domain whose report address is set to <em>admin's default</em> — 2 of your 3 now. Changing it changes the DMARC record those domains must publish. The hosted address is parsed by SelfPost; any other just receives the raw XML.</p></div>
<button type="submit" class="button is-primary">Save report address</button>
</form>
</div>
<div class="sp-record">
<div class="sp-record-head"><h3>Report authorization</h3><span class="tag is-success is-light">published</span><small>Needed because the address is on another domain than the senders</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="*._report._dmarc.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div>
<div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=DMARC1"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
</div>
</div>
</div>
</div> </div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer> <footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
+4 -4
View File
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a> <form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div> </div>
</div> </div>
</div> </div>
@@ -76,7 +76,7 @@
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-6">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Full backup</h2></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Full backup</h2><a class="sp-help" href="help.html#backup" title="Backups, encryption and restore"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<p class="mb-3"><code>data/</code> (database, DKIM keys, credentials, the queue), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code> in one archive. Restore into an empty project directory and start the <strong>same SelfPost version</strong>. The reverse-proxy vhost is not included.</p> <p class="mb-3"><code>data/</code> (database, DKIM keys, credentials, the queue), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code> in one archive. Restore into an empty project directory and start the <strong>same SelfPost version</strong>. The reverse-proxy vhost is not included.</p>
<form class="sp-form" method="post" action="#"> <form class="sp-form" method="post" action="#">
@@ -94,9 +94,9 @@
<div class="column is-6"> <div class="column is-6">
<div class="box" id="import"> <div class="box" id="import">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Import a domain</h2></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>Import a domain</h2><a class="sp-help" href="help.html#backup" title="Moving a domain to another instance"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<p class="mb-3">A domain export from another instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS needs no change. The export itself is made on the <a href="out-domain-settings.html#export">domain's settings</a>.</p> <p class="mb-3">A domain export from another instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS needs no change. The export itself is made on the <a href="out-domain-settings.html">domain's settings</a>.</p>
<form class="sp-form" method="post" action="out-domain.html" enctype="multipart/form-data"> <form class="sp-form" method="post" action="out-domain.html" enctype="multipart/form-data">
<div class="field"><label class="label">Domain export file</label> <div class="field"><label class="label">Domain export file</label>
<div class="file has-name is-fullwidth"><label class="file-label"><input class="file-input" type="file" name="file" accept=".json,.spde"><span class="file-cta"><i class="ti ti-upload"></i><span class="file-label ml-2">Choose a file…</span></span><span class="file-name">example.org.spde</span></label></div></div> <div class="file has-name is-fullwidth"><label class="file-label"><input class="file-input" type="file" name="file" accept=".json,.spde"><span class="file-cta"><i class="ti ti-upload"></i><span class="file-label ml-2">Choose a file…</span></span><span class="file-name">example.org.spde</span></label></div></div>
+3 -1
View File
@@ -81,7 +81,9 @@ def navbar(section, page):
' <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>', ' <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>',
' <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>', ' <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>',
' <hr class="navbar-divider">', ' <hr class="navbar-divider">',
' <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>', # Signing out changes state, so it is a POST: a form with a button, not a link.
' <form method="post" action="login.html"><button type="submit" class="navbar-item">'
'<i class="ti ti-logout"></i>Sign out</button></form>',
' </div>', ' </div>', ' </div>', ' </div>', ' </div>', '</nav>', ' </div>', ' </div>', ' </div>', ' </div>', ' </div>', '</nav>',
'<div class="sp-perf" aria-hidden="true"></div>'] '<div class="sp-perf" aria-hidden="true"></div>']
return "\n".join(out) return "\n".join(out)
+1 -1
View File
@@ -27,7 +27,7 @@ box button buttons checkbox column columns control field file file-cta file-inpu
file-name has-addons has-icons-left has-name has-text-danger help icon input label menu menu-label file-name has-addons has-icons-left has-name has-text-danger help icon input label menu menu-label
menu-list notification progress select table table-container tag textarea title menu-list notification progress select table table-container tag textarea title
is-3 is-4 is-5 is-6 is-7 is-9 is-active is-danger is-expanded is-fullwidth is-gapless is-half is-3 is-4 is-5 is-6 is-7 is-9 is-active is-danger is-expanded is-fullwidth is-gapless is-half
is-hoverable is-left is-light is-primary is-small is-success is-text is-warning is-hidden is-hoverable is-left is-light is-primary is-small is-success is-text is-warning
mb-1 mb-2 mb-3 mb-4 ml-2 mt-4 mt-5 mb-1 mb-2 mb-3 mb-4 ml-2 mt-4 mt-5
""".split()) """.split())
# Classes the shell (build.py, later layout.html) owns; screens never write them. # Classes the shell (build.py, later layout.html) owns; screens never write them.
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a> <form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div> </div>
</div> </div>
</div> </div>
@@ -172,7 +172,7 @@
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Time, UTC</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead> <thead><tr><th>Time, UTC</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
<tbody> <tbody>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td><a href="#"><strong>the row's link</strong></a></td><td class="sp-clip">A long subject is clipped with an ellipsis instead of wrapping the row</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="#">Details</a><a class="has-text-danger" href="#">Delete</a></td></tr> <tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td><a href="#"><strong>the row's link</strong></a></td><td class="sp-clip">A long subject is clipped with an ellipsis instead of wrapping the row</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="#">Details</a><form method="post" action="#"><button type="submit" class="has-text-danger">Delete</button></form></td></tr>
</tbody> </tbody>
</table></div> </table></div>
<div class="sp-box-foot"><span>Page 1 of 9</span><a href="#">Older →</a></div> <div class="sp-box-foot"><span>Page 1 of 9</span><a href="#">Older →</a></div>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a> <form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div> </div>
</div> </div>
</div> </div>
@@ -78,7 +78,7 @@
<div class="columns"> <div class="columns">
<div class="column is-5"> <div class="column is-5">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Sources · 7 days</h2></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Sources · 7 days</h2><a class="sp-help" href="help.html#dmarc" title="How to read pass, fail and sources"><i class="ti ti-help-circle"></i></a></div>
<div class="table-container"><table class="table is-fullwidth"> <div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Source</th><th>Pass</th><th>Fail</th><th>Disposition</th></tr></thead> <thead><tr><th>Source</th><th>Pass</th><th>Fail</th><th>Disposition</th></tr></thead>
<tbody> <tbody>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a> <form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div> </div>
</div> </div>
</div> </div>
@@ -76,7 +76,7 @@
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-6">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Report</h2></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Report</h2><a class="sp-help" href="help.html#dmarc" title="What an aggregate report is"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Reporter</dt><dd>Outlook.com</dd></div> <div><dt>Reporter</dt><dd>Outlook.com</dd></div>
+3 -3
View File
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a> <form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div> </div>
</div> </div>
</div> </div>
@@ -74,12 +74,12 @@
</div> </div>
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Ingest</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Ingest</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#dmarc" title="What the reports are and where they go"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Last report</dt><dd>2026-09-21 06:12 UTC</dd></div> <div><dt>Last report</dt><dd>2026-09-21 06:12 UTC</dd></div>
<div><dt>This week</dt><dd>14 kept · 0 parse failures</dd></div> <div><dt>This week</dt><dd>14 kept · 0 parse failures</dd></div>
<div><dt>Hosted address</dt><dd class="sp-mono">dmarc@mail.example.org</dd></div> <div><dt>Hosted addresses</dt><dd class="sp-mono sp-small">dmarc-reports+&lt;domain&gt;@mail.example.org</dd></div>
<div><dt>Retention</dt><dd>500 reports or 90 days</dd></div> <div><dt>Retention</dt><dd>500 reports or 90 days</dd></div>
</dl> </dl>
</div> </div>
+4 -4
View File
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a> <form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div> </div>
</div> </div>
</div> </div>
@@ -77,7 +77,7 @@
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-6">
<div class="box" id="machine"> <div class="box" id="machine">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Machine</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Machine</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#checks" title="What these readings mean"><i class="ti ti-help-circle"></i></a></div>
<div class="table-container"><table class="table is-fullwidth"> <div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead> <thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
<tbody> <tbody>
@@ -108,7 +108,7 @@
<div class="columns"> <div class="columns">
<div class="column is-4"> <div class="column is-4">
<div class="box" id="certificate"> <div class="box" id="certificate">
<div class="sp-box-head"><span class="sp-no">03</span><h2>TLS certificate</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div></div> <div class="sp-box-head"><span class="sp-no">03</span><h2>TLS certificate</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div><a class="sp-help" href="help.html#certificate" title="When it warns and what to do"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Expires</dt><dd class="sp-mono">2026-10-03 09:14 UTC</dd></div> <div><dt>Expires</dt><dd class="sp-mono">2026-10-03 09:14 UTC</dd></div>
@@ -134,7 +134,7 @@
</div> </div>
<div class="column is-4"> <div class="column is-4">
<div class="box" id="hostname"> <div class="box" id="hostname">
<div class="sp-box-head"><span class="sp-no">05</span><h2>Hostname and reverse DNS</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div> <div class="sp-box-head"><span class="sp-no">05</span><h2>Hostname and reverse DNS</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#rdns" title="Forward and reverse DNS"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Hostname</dt><dd class="sp-mono">mail.example.org</dd></div> <div><dt>Hostname</dt><dd class="sp-mono">mail.example.org</dd></div>
+42 -8
View File
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a> <form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div> </div>
</div> </div>
</div> </div>
@@ -69,7 +69,7 @@
<div class="sp-grow"> <div class="sp-grow">
<p class="sp-kicker">Reference</p> <p class="sp-kicker">Reference</p>
<h1 class="title is-3">Help</h1> <h1 class="title is-3">Help</h1>
<p class="sp-lead">What each screen checks and what to do when it is not green. Every numbered section on a page links to its topic here.</p> <p class="sp-lead">What each screen checks or does, and what to do when it is not green. The ? in a box's head opens its topic here.</p>
</div> </div>
</div> </div>
@@ -87,27 +87,61 @@
<li><a href="#dns"><i class="ti ti-world-check"></i>DNS records</a></li> <li><a href="#dns"><i class="ti ti-world-check"></i>DNS records</a></li>
<li><a href="#apps"><i class="ti ti-apps"></i>Applications</a></li> <li><a href="#apps"><i class="ti ti-apps"></i>Applications</a></li>
<li><a href="#limits"><i class="ti ti-gauge"></i>Rate limits</a></li> <li><a href="#limits"><i class="ti ti-gauge"></i>Rate limits</a></li>
<li><a href="#log"><i class="ti ti-list-details"></i>Outbound log</a></li>
<li><a href="#queue"><i class="ti ti-stack-2"></i>Queue and retries</a></li> <li><a href="#queue"><i class="ti ti-stack-2"></i>Queue and retries</a></li>
<li><a href="#dmarc"><i class="ti ti-report-analytics"></i>DMARC reports</a></li> <li><a href="#dmarc"><i class="ti ti-report-analytics"></i>DMARC reports</a></li>
</ul> </ul>
<p class="menu-label">Inbound</p> <p class="menu-label">Inbound</p>
<ul class="menu-list"> <ul class="menu-list">
<li><a href="#inbound"><i class="ti ti-inbox"></i>Backup-MX and forwarding</a></li> <li><a href="#inbound"><i class="ti ti-inbox"></i>Backup-MX and forwarding</a></li>
<li><a href="#upstream"><i class="ti ti-arrow-forward-up"></i>Upstream</a></li>
<li><a href="#recipients"><i class="ti ti-address-book"></i>Valid recipients</a></li>
<li><a href="#filter"><i class="ti ti-shield-check"></i>Spam filter</a></li>
</ul>
<p class="menu-label">Server</p>
<ul class="menu-list">
<li><a href="#backup"><i class="ti ti-archive"></i>Backup and moving a domain</a></li>
<li><a href="#users"><i class="ti ti-users"></i>Users and roles</a></li>
<li><a href="#settings"><i class="ti ti-settings"></i>Settings</a></li>
</ul>
<p class="menu-label">Account</p>
<ul class="menu-list">
<li><a href="#account"><i class="ti ti-user-circle"></i>Your account</a></li>
</ul> </ul>
</aside> </aside>
</div> </div>
<div class="column is-9"> <div class="column is-9">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Overview</h2></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Overview</h2></div>
<div class="sp-help-topic" id="checks"><h3>Health checks</h3><p>Six checks, 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.</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.</p></div>
<div class="sp-help-topic" id="certificate"><h3>TLS certificate</h3><p>The certificate Postfix presents on 465 and 587. It warns 14 days before expiry and fails once expired — clients that verify certificates then refuse to connect.</p></div> <div class="sp-help-topic" id="certificate"><h3>TLS certificate</h3><p>The certificate Postfix presents on 465 and 587. The reverse proxy or the image mounts it, the panel does not issue it. It warns 14 days before expiry and fails once expired, or when the file is missing or unreadable — clients that verify certificates then refuse to connect.</p></div>
<div class="sp-help-topic" id="rdns"><h3>Hostname and reverse DNS</h3><p>The hostname 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="rdns"><h3>Hostname and reverse DNS</h3><p>The hostname (<code>SELFPOST_HOSTNAME</code>) must resolve to this server's address, and that address must resolve back to the hostname. Large receivers reject or spam-folder mail from hosts where the two disagree. The PTR record is set at your hosting provider, not in your DNS zone.</p></div>
</div> </div>
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Outbound</h2></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>Outbound</h2></div>
<div class="sp-help-topic" id="dns"><h3>DNS records</h3><p>DKIM proves the message was signed by this relay, SPF lists the addresses allowed to send for the domain, DMARC tells receivers what to do when both fail and where to report. Publish all three exactly as shown; merge SPF into an existing record rather than adding a second one.</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. 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. It may send as any address of its domain or only as listed addresses. The password is shown once at creation or regeneration and is not stored in readable form.</p></div> <div class="sp-help-topic" id="apps"><h3>Applications</h3><p>An application is one SASL login, unique across all domains. It may send as any address of its domain or only as listed addresses, and, with a list of client IPs set, only from those addresses. The password is shown once at creation or regeneration and is not stored in readable form.</p></div>
<div class="sp-help-topic" id="limits"><h3>Rate limits</h3><p>Level 1 is a per-IP ceiling set in <code>.env</code>. Level 2 is optional, per domain and per application, manual or computed from 30-day statistics, and can never exceed level 1.</p></div> <div class="sp-help-topic" id="limits"><h3>Rate limits</h3><p>Level 1 is a per-IP ceiling set in <code>.env</code>. Level 2 is optional, per domain and per application, entered by hand or computed from the last 30 days of sending, and can never exceed level 1. An application's own limit replaces its domain's.</p></div>
<div class="sp-help-topic" id="log"><h3>Outbound log</h3><p>One row per message and recipient, with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out) and rejected (refused before queueing under a level-2 rate limit, so it never reached Postfix). A message's page lists these steps and the lines Postfix wrote about it in <code>mail.log</code>, found by queue id; that file is rotated daily and about two weeks are kept, so an old message may show none. Rows are deleted after the retention set in Server › Settings, 90 days by default. A domain administrator sees only the rows of their own domains.</p></div>
<div class="sp-help-topic" id="queue"><h3>Queue and retries</h3><p>The page lists, as <code>postqueue -p</code> prints it, the messages Postfix has accepted and not yet delivered: mail being sent right now and mail deferred for a later attempt. Only the global role can open it, and it only shows; nothing here deletes or forces a message. A deferred message is tried again after the first retry delay, then at gaps that double up to a cap, until the receiving server takes it or the queue lifetime runs out and it bounces; there is no attempt counter, and the box on the page shows this server's values. A queue that keeps growing means a receiver is refusing or not answering: open the message from the Outbound log to read the reason Postfix logged.</p></div>
<div class="sp-help-topic" id="dmarc"><h3>DMARC reports</h3><p>An aggregate report is the summary a receiving provider sends, usually daily, to the <code>rua=</code> address of your DMARC record: which servers sent mail as your domain and whether SPF or DKIM passed for it. A domain's report address is one of three choices, set in its Domain settings: no reports (the default for a new domain), the SelfPost hosted address (an address on this server whose reports are parsed and shown under DMARC reports; offered only while hosted reports are enabled on the server), or an address typed for the domain (an ordinary mailbox that receives the raw XML; a button fills in your own profile e-mail). When the typed address is on another domain than the one sending, that domain must publish a report-authorization record, or providers will not send; the domain's page shows it with the other DNS records and checks it. In the tables pass means SPF or DKIM passed and aligned, fail means neither did, and your own server shows as <em>this relay</em>. A fail from any other source is someone else sending as your domain — a forwarder, an application you forgot, or abuse — so do not tighten <code>p=</code> until that sender is fixed or removed. A domain administrator reads the reports of their own domains.</p></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Inbound</h2></div>
<div class="sp-help-topic" id="inbound"><h3>Backup-MX and forwarding</h3><p>An optional backup-MX or forwarder, switched on in the server's <code>.env</code>; the Inbound menu exists only then. Postfix accepts mail on port 25 for the listed domains only and hands each message on to that domain's upstream; there are no mailboxes. For each domain publish the MX record its page shows, and keep a primary MX the domain already has if this is a backup. The MX tag is green when at least one MX of the domain points at this server, and a domain accepts no mail until an upstream is saved on its page. Adding and deleting domains is for the global role.</p></div>
<div class="sp-help-topic" id="upstream"><h3>Upstream</h3><p>The upstream is the server that actually holds the mailboxes. Give its host and port — used exactly as written, with no MX lookup — and choose the TLS for the hop to it: Opportunistic uses TLS when the upstream offers it and otherwise sends in the clear, Required holds the mail until a TLS session is possible, Off never uses TLS. While the upstream does not answer, Postfix keeps the mail and retries as for any other mail.</p></div>
<div class="sp-help-topic" id="recipients"><h3>Valid recipients</h3><p>Choose whom this domain accepts. Listed addresses only: the relay accepts exactly the addresses you list, one per line or comma-separated, and refuses any other while the sender is still connected, so it never has to send a bounce for them. Any recipient: everything at the domain is accepted and passed on, and an address the upstream then rejects comes back as a bounce from this server to a sender who is often forged — backscatter. Prefer a list, and keep it in step with the mailboxes upstream.</p></div>
<div class="sp-help-topic" id="filter"><h3>Spam filter</h3><p>The spam filter belongs to the server, not to a domain. The server's administrator attaches it in the server's <code>.env</code>, and from then on every inbound domain is screened by it before mail is handed on; the box shows on or off and has nothing to switch. Off means mail goes to the upstream unfiltered. While the filter is down, mail is either accepted unfiltered (the default) or deferred, so the sending server tries again later, as the server's setting says; the box states which.</p></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div>
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys, application credentials and the queue), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
<div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out of their other sessions.</p></div>
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it.</p></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">05</span><h2>Account</h2></div>
<div class="sp-help-topic" id="account"><h3>Your account</h3><p>Account is your own, whatever your role: your username and e-mail, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected.</p></div>
</div> </div>
</div> </div>
</div> </div>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a> <form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div> </div>
</div> </div>
</div> </div>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a> <form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div> </div>
</div> </div>
</div> </div>
@@ -116,7 +116,7 @@
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-6">
<div class="box" id="upstream"> <div class="box" id="upstream">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Upstream</h2><div class="sp-end">Not a mailbox</div></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>Upstream</h2><div class="sp-end">Not a mailbox</div><a class="sp-help" href="help.html#upstream" title="Host, port and TLS"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<form class="sp-form" method="post" action="#"> <form class="sp-form" method="post" action="#">
<div class="sp-row"> <div class="sp-row">
@@ -132,7 +132,7 @@
</div> </div>
<div class="column is-6"> <div class="column is-6">
<div class="box" id="recipients"> <div class="box" id="recipients">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Valid recipients</h2></div> <div class="sp-box-head"><span class="sp-no">03</span><h2>Valid recipients</h2><a class="sp-help" href="help.html#recipients" title="Listed addresses or any address"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<form class="sp-form" method="post" action="#"> <form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="rmode">Who to accept</label> <div class="field"><label class="label" for="rmode">Who to accept</label>
@@ -151,7 +151,7 @@ security@lists.example.org</textarea></div>
</div> </div>
<div class="box" id="filter"> <div class="box" id="filter">
<div class="sp-box-head"><span class="sp-no">04</span><h2>Spam filter</h2><div class="sp-end"><span class="tag is-success is-light">on</span></div></div> <div class="sp-box-head"><span class="sp-no">04</span><h2>Spam filter</h2><div class="sp-end"><span class="tag is-success is-light">on</span></div><a class="sp-help" href="help.html#filter" title="A server setting"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Engine</dt><dd>rspamd, for every inbound domain of this server</dd></div> <div><dt>Engine</dt><dd>rspamd, for every inbound domain of this server</dd></div>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a> <form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div> </div>
</div> </div>
</div> </div>
@@ -87,7 +87,7 @@
</div> </div>
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Domains</h2><div class="sp-end">Spam filter <span class="tag is-success is-light">on</span> · 2 domains</div></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>Domains</h2><div class="sp-end">Spam filter <span class="tag is-success is-light">on</span> · 2 domains</div><a class="sp-help" href="help.html#inbound" title="Backup-MX and forwarding"><i class="ti ti-help-circle"></i></a></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Domain</th><th>DNS</th><th>Upstream</th><th>Recipients</th><th>TLS to upstream</th><th></th></tr></thead> <thead><tr><th>Domain</th><th>DNS</th><th>Upstream</th><th>Recipients</th><th>TLS to upstream</th><th></th></tr></thead>
<tbody> <tbody>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a> <form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div> </div>
</div> </div>
</div> </div>
+1 -1
View File
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a> <form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div> </div>
</div> </div>
</div> </div>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a> <form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div> </div>
</div> </div>
</div> </div>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a> <form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div> </div>
</div> </div>
</div> </div>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a> <form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<div class="columns is-gapless sp-signin">
<div class="column is-half sp-signin-brand">
<div>
<img src="../../selfpost-stamp.svg" alt="SelfPost">
<p>YOUR SMTP. YOUR RULES.</p>
</div>
</div>
<div class="column is-half sp-signin-form">
<div class="sp-form">
<p class="sp-kicker">mail.example.org</p>
<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>
</div>
</div>
</body>
</html>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a> <form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div> </div>
</div> </div>
</div> </div>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a> <form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div> </div>
</div> </div>
</div> </div>
@@ -97,7 +97,7 @@
<div class="column is-4"> <div class="column is-4">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Client IP allow-list</h2><div class="sp-end"><span class="tag is-light">off</span></div></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>Client IP allow-list</h2><div class="sp-end"><span class="tag is-light">off</span></div><a class="sp-help" href="help.html#apps" title="What an application is"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<div class="field"><label class="checkbox"><input type="checkbox" name="auth_ip_restrict"> Restrict to listed IPs</label></div> <div class="field"><label class="checkbox"><input type="checkbox" name="auth_ip_restrict"> Restrict to listed IPs</label></div>
<div class="field"><label class="label" for="ips">Allowed client IPs</label> <div class="field"><label class="label" for="ips">Allowed client IPs</label>
@@ -109,7 +109,7 @@
<div class="column is-4"> <div class="column is-4">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Rate limit</h2><div class="sp-end"><span class="tag is-success is-light">active</span></div></div> <div class="sp-box-head"><span class="sp-no">03</span><h2>Rate limit</h2><div class="sp-end"><span class="tag is-success is-light">active</span></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"> <div class="sp-box-body">
<div class="field"><label class="label" for="rmode">Limit mode</label> <div class="field"><label class="label" for="rmode">Limit mode</label>
<div class="control"><div class="select is-fullwidth"><select id="rmode" name="rl_mode"><option>Use the domain limit</option><option selected>Manual</option><option>Auto (from statistics)</option></select></div></div></div> <div class="control"><div class="select is-fullwidth"><select id="rmode" name="rl_mode"><option>Use the domain limit</option><option selected>Manual</option><option>Auto (from statistics)</option></select></div></div></div>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a> <form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div> </div>
</div> </div>
</div> </div>
@@ -97,7 +97,7 @@
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-file-export"></i></span><h2>Moving it instead?</h2></div> <div class="sp-box-head"><span class="sp-no"><i class="ti ti-file-export"></i></span><h2>Moving it instead?</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
<p><a href="out-domain-settings.html#export">Export the domain</a> first. The file carries the key and the application passwords, so the new instance needs no DNS change.</p> <p><a href="out-domain-settings.html">Export the domain</a> first. The file carries the key and the application passwords, so the new instance needs no DNS change.</p>
</div> </div>
</div> </div>
</div> </div>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a> <form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div> </div>
</div> </div>
</div> </div>
@@ -76,25 +76,25 @@
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-6">
<div class="box" id="reports"> <div class="box" id="reports">
<div class="sp-box-head"><span class="sp-no">01</span><h2>DMARC report address</h2></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>DMARC report address</h2><a class="sp-help" href="help.html#dmarc" title="Where DMARC reports go"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<form class="sp-form" method="post" action="#"> <form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="rua">Aggregate reports (rua=)</label> <div class="field"><label class="label" for="rua">Aggregate reports (rua=)</label>
<div class="control"><div class="select is-fullwidth"><select id="rua" name="dmarc_rua_mode"> <div class="control"><div class="select is-fullwidth"><select id="rua" name="dmarc_rua_mode">
<option selected>admin's default — mix@example.org</option> <option>No reports</option>
<option>SelfPost hosted (example.org@dmarc.mail.example.org)</option> <option>SelfPost hosted — example.org@dmarc.mail.example.org</option>
<option>My default instead — (you are admin)</option> <option selected>A specific address</option>
<option>No aggregate reports</option>
<option>Custom address</option>
</select></div></div> </select></div></div>
<p class="help">A default belongs to a user and is set under their <a href="account.html#dmarc">Account</a>; a domain follows one named user, so two people sharing a domain never pull it two ways. Changing this changes the DMARC record to publish.</p></div> <p class="help">No reports leaves <code>rua=</code> out of the DMARC record. SelfPost hosted sends them to an address on this server, which parses them for DMARC reports. A specific address is any mailbox, and it receives the raw XML. Changing this changes the DMARC record to publish.</p></div>
<div class="field"><label class="label" for="rua_email">Report address</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" id="rua_email" name="dmarc_rua_email" type="email" value="reports@acme.io" placeholder="reports@your-mail-domain.com" autocomplete="off" spellcheck="false"></div><div class="control"><button type="button" class="button" data-fill="rua_email" data-fill-value="mix@example.org"><i class="ti ti-mail"></i>Use my e-mail</button></div></div></div>
<button type="submit" class="button is-primary">Save report address</button> <button type="submit" class="button is-primary">Save report address</button>
</form> </form>
</div> </div>
</div> </div>
<div class="box" id="export"> <div class="box" id="export">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Export domain</h2></div> <div class="sp-box-head"><span class="sp-no">03</span><h2>Export domain</h2><a class="sp-help" href="help.html#backup" title="Moving a domain to another instance"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<p class="mb-3">One file with the DKIM key and every application's password hash — import it on another SelfPost and the published DNS needs no change. <strong>The file is a secret.</strong></p> <p class="mb-3">One file with the DKIM key and every application's password hash — import it on another SelfPost and the published DNS needs no change. <strong>The file is a secret.</strong></p>
<form class="sp-form" method="post" action="#"> <form class="sp-form" method="post" action="#">
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a> <form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div> </div>
</div> </div>
</div> </div>
@@ -94,9 +94,9 @@
</ul> </ul>
<p class="menu-label">Rarely changed</p> <p class="menu-label">Rarely changed</p>
<ul class="menu-list"> <ul class="menu-list">
<li><a href="out-domain-settings.html#reports"><i class="ti ti-mail-forward"></i>Report address</a></li> <li><a href="out-domain-settings.html"><i class="ti ti-mail-forward"></i>Report address</a></li>
<li><a href="out-domain-settings.html#limit"><i class="ti ti-gauge"></i>Rate limit <span class="tag is-success is-light">auto</span></a></li> <li><a href="out-domain-settings.html"><i class="ti ti-gauge"></i>Rate limit <span class="tag is-success is-light">auto</span></a></li>
<li><a href="out-domain-settings.html#export"><i class="ti ti-file-export"></i>Export</a></li> <li><a href="out-domain-settings.html"><i class="ti ti-file-export"></i>Export</a></li>
<li><a class="has-text-danger" href="out-domain-delete.html"><i class="ti ti-trash has-text-danger"></i>Delete domain</a></li> <li><a class="has-text-danger" href="out-domain-delete.html"><i class="ti ti-trash has-text-danger"></i>Delete domain</a></li>
</ul> </ul>
</aside> </aside>
@@ -132,14 +132,14 @@
</div> </div>
<div class="sp-record"> <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#reports">admin's default</a></small></div> <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>
<div class="sp-row"> <div class="sp-row">
<div class="field"><label class="label">Host / name</label> <div class="field"><label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="_dmarc.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div> <div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="_dmarc.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div> <div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div> </div>
<div class="field"><label class="label">Value to publish</label> <div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div> <div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=DMARC1; p=none; rua=mailto:example.org@dmarc.mail.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div>
<p class="help"><code>p=none</code> does not affect delivery. Tighten to <code>quarantine</code>, then <code>reject</code>, once the <a href="dmarc-domain.html">reports</a> look clean.</p></div> <p class="help"><code>p=none</code> does not affect delivery. Tighten to <code>quarantine</code>, then <code>reject</code>, once the <a href="dmarc-domain.html">reports</a> look clean.</p></div>
</div> </div>
</div> </div>
@@ -149,10 +149,10 @@
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th></th></tr></thead> <thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th></th></tr></thead>
<tbody> <tbody>
<tr><td class="sp-mono"><strong>prod-server</strong></td><td>*@example.org</td><td class="sp-muted">1 102 msg · peak 96/h</td><td><span class="tag is-success is-light">200 / h</span></td> <tr><td class="sp-mono sp-nowrap"><strong>prod-server</strong></td><td>*@example.org</td><td class="sp-muted">1 102 msg · peak 96/h</td><td><span class="tag is-success is-light">200 / h</span></td>
<td class="sp-actions"><a href="out-app.html">Edit</a><a href="out-app-created.html">New password</a><a class="has-text-danger" href="#">Delete</a></td></tr> <td class="sp-actions"><a href="out-app.html">Edit</a><form method="post" action="out-app-created.html"><button type="submit">New password</button></form><form method="post" action="out-domain.html"><button type="submit" class="has-text-danger">Delete</button></form></td></tr>
<tr><td class="sp-mono"><strong>alerts</strong></td><td>alerts@example.org, noc@example.org</td><td class="sp-muted">182 msg · peak 14/h</td><td><span class="tag is-light">domain</span> <span class="tag is-success is-light">2 IPs</span></td> <tr><td class="sp-mono sp-nowrap"><strong>alerts</strong></td><td>alerts@example.org, noc@example.org</td><td class="sp-muted">182 msg · peak 14/h</td><td><span class="tag is-light">domain</span> <span class="tag is-success is-light">2 IPs</span></td>
<td class="sp-actions"><a href="out-app.html">Edit</a><a href="out-app-created.html">New password</a><a class="has-text-danger" href="#">Delete</a></td></tr> <td class="sp-actions"><a href="out-app.html">Edit</a><form method="post" action="out-app-created.html"><button type="submit">New password</button></form><form method="post" action="out-domain.html"><button type="submit" class="has-text-danger">Delete</button></form></td></tr>
</tbody> </tbody>
</table></div> </table></div>
<div class="sp-box-foot">New password and Delete ask for confirmation; the old password stops working at once.</div> <div class="sp-box-foot">New password and Delete ask for confirmation; the old password stops working at once.</div>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a> <form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div> </div>
</div> </div>
</div> </div>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a> <form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div> </div>
</div> </div>
</div> </div>
@@ -82,6 +82,7 @@
<div class="select"><select name="status"><option>Any status</option><option>sent</option><option>deferred</option><option>bounced</option><option>rate-limited</option></select></div> <div class="select"><select name="status"><option>Any status</option><option>sent</option><option>deferred</option><option>bounced</option><option>rate-limited</option></select></div>
<button type="submit" class="button"><i class="ti ti-filter"></i>Filter</button> <button type="submit" class="button"><i class="ti ti-filter"></i>Filter</button>
</form> </form>
<a class="sp-help" href="help.html#log" title="What the statuses mean"><i class="ti ti-help-circle"></i></a>
</div> </div>
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr></thead> <thead><tr><th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr></thead>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a> <form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div> </div>
</div> </div>
</div> </div>
@@ -91,7 +91,7 @@
</div> </div>
<div class="column is-5"> <div class="column is-5">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>History</h2></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>History</h2><a class="sp-help" href="help.html#log" title="What the statuses mean"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<ol class="sp-timeline"> <ol class="sp-timeline">
<li><time>13:58:07 UTC</time><strong>Accepted</strong> from <span class="sp-mono">alerts</span> at 198.51.100.7</li> <li><time>13:58:07 UTC</time><strong>Accepted</strong> from <span class="sp-mono">alerts</span> at 198.51.100.7</li>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a> <form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div> </div>
</div> </div>
</div> </div>
@@ -74,7 +74,7 @@
</div> </div>
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Waiting</h2><div class="sp-end">1 message · 3.1 kB</div></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Waiting</h2><div class="sp-end">1 message · 3.1 kB</div><a class="sp-help" href="help.html#queue" title="What waits here and how Postfix retries"><i class="ti ti-help-circle"></i></a></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Queue id</th><th>Arrived, UTC</th><th>From</th><th>To</th><th>Last reply</th><th></th></tr></thead> <thead><tr><th>Queue id</th><th>Arrived, UTC</th><th>From</th><th>To</th><th>Last reply</th><th></th></tr></thead>
<tbody> <tbody>
@@ -4,15 +4,13 @@
{ {
"columns": [ "columns": [
{ {
"is-5": [ "is-6": [
"box[Profile]:body", "box[Profile]:body"
"box[Password]:body"
] ]
}, },
{ {
"is-7": [ "is-6": [
"box[Notifications]:body", "box[Password]:body"
"box[DMARC reports]:record×2"
] ]
} }
] ]
@@ -124,7 +122,10 @@
{ {
"is-9": [ "is-9": [
"box[Overview]:help×3", "box[Overview]:help×3",
"box[Outbound]:help×3" "box[Outbound]:help×6",
"box[Inbound]:help×4",
"box[Server]:help×3",
"box[Account]:help"
] ]
} }
] ]
@@ -405,8 +406,7 @@
"columns": [ "columns": [
{ {
"is-6": [ "is-6": [
"box[Log retention]:body", "box[Log retention]:body"
"box[Notifications]:body"
] ]
}, },
{ {
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a> <form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div> </div>
</div> </div>
</div> </div>
@@ -78,12 +78,12 @@
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Server health</h2><div class="sp-end"><a href="health.html">All details</a></div><a class="sp-help" href="help.html#checks" title="What these checks mean"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Server health</h2><div class="sp-end"><a href="health.html">All details</a></div><a class="sp-help" href="help.html#checks" title="What these checks mean"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-health"> <div class="sp-health">
<a href="health.html#machine"><span class="sp-h-icon"><i class="ti ti-cpu"></i></span><span><span class="sp-h-name">Machine</span><span class="sp-h-value">CPU 12 %</span><span class="sp-h-sub">RAM 41 % · 62 kB/s</span></span></a> <a href="health.html"><span class="sp-h-icon"><i class="ti ti-cpu"></i></span><span><span class="sp-h-name">Machine</span><span class="sp-h-value">CPU 12 %</span><span class="sp-h-sub">RAM 41 % · 62 kB/s</span></span></a>
<a href="health.html#processes"><span class="sp-h-icon"><i class="ti ti-server-cog"></i></span><span><span class="sp-h-name">Processes</span><span class="sp-h-value">5 of 5 running</span><span class="sp-h-sub">up 18 days</span></span></a> <a href="health.html"><span class="sp-h-icon"><i class="ti ti-server-cog"></i></span><span><span class="sp-h-name">Processes</span><span class="sp-h-value">5 of 5 running</span><span class="sp-h-sub">up 18 days</span></span></a>
<a href="health.html#certificate" class="sp-warn"><span class="sp-h-icon"><i class="ti ti-certificate"></i></span><span><span class="sp-h-name">TLS certificate</span><span class="sp-h-value">Expires in 12 days</span><span class="sp-h-sub">renewal has not run</span></span></a> <a href="health.html" class="sp-warn"><span class="sp-h-icon"><i class="ti ti-certificate"></i></span><span><span class="sp-h-name">TLS certificate</span><span class="sp-h-value">Expires in 12 days</span><span class="sp-h-sub">renewal has not run</span></span></a>
<a href="out-queue.html"><span class="sp-h-icon"><i class="ti ti-stack-2"></i></span><span><span class="sp-h-name">Queues</span><span class="sp-h-value">Both empty</span><span class="sp-h-sub">outbound and inbound</span></span></a> <a href="out-queue.html"><span class="sp-h-icon"><i class="ti ti-stack-2"></i></span><span><span class="sp-h-name">Queues</span><span class="sp-h-value">Both empty</span><span class="sp-h-sub">outbound and inbound</span></span></a>
<a href="health.html#sockets"><span class="sp-h-icon"><i class="ti ti-plug-connected"></i></span><span><span class="sp-h-name">Milter sockets</span><span class="sp-h-value">3 of 3 answering</span><span class="sp-h-sub">incl. the spam filter</span></span></a> <a href="health.html"><span class="sp-h-icon"><i class="ti ti-plug-connected"></i></span><span><span class="sp-h-name">Milter sockets</span><span class="sp-h-value">3 of 3 answering</span><span class="sp-h-sub">incl. the spam filter</span></span></a>
<a href="health.html#hostname"><span class="sp-h-icon"><i class="ti ti-arrows-exchange"></i></span><span><span class="sp-h-name">Reverse DNS</span><span class="sp-h-value">Forward = reverse</span><span class="sp-h-sub">203.0.113.25</span></span></a> <a href="health.html"><span class="sp-h-icon"><i class="ti ti-arrows-exchange"></i></span><span><span class="sp-h-name">Reverse DNS</span><span class="sp-h-value">Forward = reverse</span><span class="sp-h-sub">203.0.113.25</span></span></a>
</div> </div>
</div> </div>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a> <form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div> </div>
</div> </div>
</div> </div>
+5 -13
View File
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a> <form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div> </div>
</div> </div>
</div> </div>
@@ -69,7 +69,7 @@
<div class="sp-grow"> <div class="sp-grow">
<p class="sp-kicker">Server</p> <p class="sp-kicker">Server</p>
<h1 class="title is-3">Settings</h1> <h1 class="title is-3">Settings</h1>
<p class="sp-lead">What holds for the whole instance, whoever is signed in. Your own name, e-mail, notifications and DMARC report address are under <a href="account.html">Account</a>.</p> <p class="sp-lead">What holds for the whole instance, whoever is signed in. Your own name, e-mail and password are under <a href="account.html">Account</a>.</p>
</div> </div>
</div> </div>
@@ -77,30 +77,22 @@
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-6">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Log retention</h2></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Log retention</h2><a class="sp-help" href="help.html#settings" title="How long rows are kept"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<div class="field"><label class="label" for="ret">Keep outbound log rows, days</label> <div class="field"><label class="label" for="ret">Keep outbound log rows, days</label>
<div class="control"><input class="input" id="ret" name="send_log_retention_days" type="number" min="7" max="365" value="30" required></div> <div class="control"><input class="input" id="ret" name="send_log_retention_days" type="number" min="7" max="365" value="30" required></div>
<p class="help">The main driver of <code>/data</code> growth. Does not affect <code>mail.log</code> rotation (fourteen daily files).</p></div> <p class="help">The main driver of <code>/data</code> growth. Does not affect <code>mail.log</code> rotation (fourteen daily files).</p></div>
</div> </div>
</div> </div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Notifications</h2></div>
<div class="sp-box-body">
<div class="field"><label class="checkbox"><input type="checkbox" name="notify" checked> Send notification e-mails</label></div>
<p class="help">Sent through this relay from <span class="sp-mono">postmaster@mail.example.org</span>. Each user picks the events under <a href="account.html">Account</a>; a user without an e-mail gets none.</p>
</div>
</div>
</div> </div>
<div class="column is-6"> <div class="column is-6">
<div class="box" id="rate-limits"> <div class="box" id="rate-limits">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Sending rate limits</h2><div class="sp-end">Read-only</div></div> <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"> <div class="sp-box-body">
<dl class="sp-facts"> <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 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#limit">domain</a> and <a href="out-app.html">application</a>.</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> </dl>
</div> </div>
</div> </div>
@@ -3,24 +3,26 @@
<div class="sp-grow"> <div class="sp-grow">
<p class="sp-kicker">Signed in as admin · global</p> <p class="sp-kicker">Signed in as admin · global</p>
<h1 class="title is-3">Account</h1> <h1 class="title is-3">Account</h1>
<p class="sp-lead">Who you are to this panel, what it should tell you about, and where reports about your domains go. Applications keep their own logins and passwords and are not affected.</p> <p class="sp-lead">Who you are to this panel. Applications keep their own logins and passwords and are not affected.</p>
</div> </div>
</div> </div>
<div class="columns"> <div class="columns">
<div class="column is-5"> <div class="column is-6">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Profile</h2></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Profile</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
<form class="sp-form" method="post" action="#"> <form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="admin" autocomplete="username" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div> <div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="admin" autocomplete="username" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div>
<div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div> <div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
<p class="help">Where the panel writes to you: the notifications on the right and a password-reset link. It can also receive DMARC reports — your choice, under <a href="#dmarc">DMARC reports</a>.</p></div> <p class="help">Your own address. A domain's report address can be filled with it from Domain settings.</p></div>
<button type="submit" class="button is-primary">Save profile</button> <button type="submit" class="button is-primary">Save profile</button>
</form> </form>
</div> </div>
</div> </div>
</div>
<div class="column is-6">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Password</h2></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>Password</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
@@ -36,53 +38,4 @@
</div> </div>
</div> </div>
</div> </div>
<div class="column is-7">
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Notifications</h2><div class="sp-end">Sent to mix@example.org</div></div>
<div class="sp-box-body">
<form class="sp-form" method="post" action="#">
<p class="sp-kicker mb-2">Your domains</p>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev" checked> A DNS record stops matching — DKIM, SPF, DMARC or an inbound MX</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev" checked> A domain or an application reaches its rate limit and mail is being refused</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev" checked> An inbound domain's upstream cannot be reached and mail is piling up</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev"> DMARC reports start showing failures for a domain</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev"> Mail is held in quarantine</label></div>
<p class="sp-kicker mt-4 mb-2">The server · global role</p>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev" checked> The TLS certificate is within 14 days of expiry</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev" checked> A process or a milter socket stops answering</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev"> Messages sit in a queue for more than a day</label></div>
<p class="help mb-3">One message when a problem starts and one when it clears — never a stream. A domain administrator is told only about the domains assigned to them.</p>
<button type="submit" class="button is-primary">Save notifications</button>
</form>
</div>
</div>
<div class="box" id="dmarc">
<div class="sp-box-head"><span class="sp-no">04</span><h2>DMARC reports</h2><div class="sp-end"><span class="tag is-success is-light">authorized</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-record">
<form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="rua">My default report address</label>
<div class="control"><div class="select is-fullwidth"><select id="rua" name="dmarc_default">
<option>SelfPost hosted — read them under DMARC reports</option>
<option selected>My account e-mail — mix@example.org</option>
<option>Another address…</option>
<option>No reports</option>
</select></div></div>
<p class="help">Used by every outbound domain whose report address is set to <em>admin's default</em> — 2 of your 3 now. Changing it changes the DMARC record those domains must publish. The hosted address is parsed by SelfPost; any other just receives the raw XML.</p></div>
<button type="submit" class="button is-primary">Save report address</button>
</form>
</div>
<div class="sp-record">
<div class="sp-record-head"><h3>Report authorization</h3><span class="tag is-success is-light">published</span><small>Needed because the address is on another domain than the senders</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="*._report._dmarc.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div>
<div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=DMARC1"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
</div>
</div>
</div>
</div> </div>
@@ -10,7 +10,7 @@
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-6">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Full backup</h2></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Full backup</h2><a class="sp-help" href="help.html#backup" title="Backups, encryption and restore"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<p class="mb-3"><code>data/</code> (database, DKIM keys, credentials, the queue), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code> in one archive. Restore into an empty project directory and start the <strong>same SelfPost version</strong>. The reverse-proxy vhost is not included.</p> <p class="mb-3"><code>data/</code> (database, DKIM keys, credentials, the queue), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code> in one archive. Restore into an empty project directory and start the <strong>same SelfPost version</strong>. The reverse-proxy vhost is not included.</p>
<form class="sp-form" method="post" action="#"> <form class="sp-form" method="post" action="#">
@@ -28,9 +28,9 @@
<div class="column is-6"> <div class="column is-6">
<div class="box" id="import"> <div class="box" id="import">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Import a domain</h2></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>Import a domain</h2><a class="sp-help" href="help.html#backup" title="Moving a domain to another instance"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<p class="mb-3">A domain export from another instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS needs no change. The export itself is made on the <a href="out-domain-settings.html#export">domain's settings</a>.</p> <p class="mb-3">A domain export from another instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS needs no change. The export itself is made on the <a href="out-domain-settings.html">domain's settings</a>.</p>
<form class="sp-form" method="post" action="out-domain.html" enctype="multipart/form-data"> <form class="sp-form" method="post" action="out-domain.html" enctype="multipart/form-data">
<div class="field"><label class="label">Domain export file</label> <div class="field"><label class="label">Domain export file</label>
<div class="file has-name is-fullwidth"><label class="file-label"><input class="file-input" type="file" name="file" accept=".json,.spde"><span class="file-cta"><i class="ti ti-upload"></i><span class="file-label ml-2">Choose a file…</span></span><span class="file-name">example.org.spde</span></label></div></div> <div class="file has-name is-fullwidth"><label class="file-label"><input class="file-input" type="file" name="file" accept=".json,.spde"><span class="file-cta"><i class="ti ti-upload"></i><span class="file-label ml-2">Choose a file…</span></span><span class="file-name">example.org.spde</span></label></div></div>
@@ -106,7 +106,7 @@
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Time, UTC</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead> <thead><tr><th>Time, UTC</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
<tbody> <tbody>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td><a href="#"><strong>the row's link</strong></a></td><td class="sp-clip">A long subject is clipped with an ellipsis instead of wrapping the row</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="#">Details</a><a class="has-text-danger" href="#">Delete</a></td></tr> <tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td><a href="#"><strong>the row's link</strong></a></td><td class="sp-clip">A long subject is clipped with an ellipsis instead of wrapping the row</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="#">Details</a><form method="post" action="#"><button type="submit" class="has-text-danger">Delete</button></form></td></tr>
</tbody> </tbody>
</table></div> </table></div>
<div class="sp-box-foot"><span>Page 1 of 9</span><a href="#">Older →</a></div> <div class="sp-box-foot"><span>Page 1 of 9</span><a href="#">Older →</a></div>
@@ -12,7 +12,7 @@
<div class="columns"> <div class="columns">
<div class="column is-5"> <div class="column is-5">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Sources · 7 days</h2></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Sources · 7 days</h2><a class="sp-help" href="help.html#dmarc" title="How to read pass, fail and sources"><i class="ti ti-help-circle"></i></a></div>
<div class="table-container"><table class="table is-fullwidth"> <div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Source</th><th>Pass</th><th>Fail</th><th>Disposition</th></tr></thead> <thead><tr><th>Source</th><th>Pass</th><th>Fail</th><th>Disposition</th></tr></thead>
<tbody> <tbody>
@@ -10,7 +10,7 @@
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-6">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Report</h2></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Report</h2><a class="sp-help" href="help.html#dmarc" title="What an aggregate report is"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Reporter</dt><dd>Outlook.com</dd></div> <div><dt>Reporter</dt><dd>Outlook.com</dd></div>
@@ -8,12 +8,12 @@
</div> </div>
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Ingest</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Ingest</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#dmarc" title="What the reports are and where they go"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Last report</dt><dd>2026-09-21 06:12 UTC</dd></div> <div><dt>Last report</dt><dd>2026-09-21 06:12 UTC</dd></div>
<div><dt>This week</dt><dd>14 kept · 0 parse failures</dd></div> <div><dt>This week</dt><dd>14 kept · 0 parse failures</dd></div>
<div><dt>Hosted address</dt><dd class="sp-mono">dmarc@mail.example.org</dd></div> <div><dt>Hosted addresses</dt><dd class="sp-mono sp-small">dmarc-reports+&lt;domain&gt;@mail.example.org</dd></div>
<div><dt>Retention</dt><dd>500 reports or 90 days</dd></div> <div><dt>Retention</dt><dd>500 reports or 90 days</dd></div>
</dl> </dl>
</div> </div>
@@ -11,7 +11,7 @@
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-6">
<div class="box" id="machine"> <div class="box" id="machine">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Machine</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Machine</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#checks" title="What these readings mean"><i class="ti ti-help-circle"></i></a></div>
<div class="table-container"><table class="table is-fullwidth"> <div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead> <thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
<tbody> <tbody>
@@ -42,7 +42,7 @@
<div class="columns"> <div class="columns">
<div class="column is-4"> <div class="column is-4">
<div class="box" id="certificate"> <div class="box" id="certificate">
<div class="sp-box-head"><span class="sp-no">03</span><h2>TLS certificate</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div></div> <div class="sp-box-head"><span class="sp-no">03</span><h2>TLS certificate</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div><a class="sp-help" href="help.html#certificate" title="When it warns and what to do"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Expires</dt><dd class="sp-mono">2026-10-03 09:14 UTC</dd></div> <div><dt>Expires</dt><dd class="sp-mono">2026-10-03 09:14 UTC</dd></div>
@@ -68,7 +68,7 @@
</div> </div>
<div class="column is-4"> <div class="column is-4">
<div class="box" id="hostname"> <div class="box" id="hostname">
<div class="sp-box-head"><span class="sp-no">05</span><h2>Hostname and reverse DNS</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div> <div class="sp-box-head"><span class="sp-no">05</span><h2>Hostname and reverse DNS</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#rdns" title="Forward and reverse DNS"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Hostname</dt><dd class="sp-mono">mail.example.org</dd></div> <div><dt>Hostname</dt><dd class="sp-mono">mail.example.org</dd></div>
+41 -7
View File
@@ -3,7 +3,7 @@
<div class="sp-grow"> <div class="sp-grow">
<p class="sp-kicker">Reference</p> <p class="sp-kicker">Reference</p>
<h1 class="title is-3">Help</h1> <h1 class="title is-3">Help</h1>
<p class="sp-lead">What each screen checks and what to do when it is not green. Every numbered section on a page links to its topic here.</p> <p class="sp-lead">What each screen checks or does, and what to do when it is not green. The ? in a box's head opens its topic here.</p>
</div> </div>
</div> </div>
@@ -21,27 +21,61 @@
<li><a href="#dns"><i class="ti ti-world-check"></i>DNS records</a></li> <li><a href="#dns"><i class="ti ti-world-check"></i>DNS records</a></li>
<li><a href="#apps"><i class="ti ti-apps"></i>Applications</a></li> <li><a href="#apps"><i class="ti ti-apps"></i>Applications</a></li>
<li><a href="#limits"><i class="ti ti-gauge"></i>Rate limits</a></li> <li><a href="#limits"><i class="ti ti-gauge"></i>Rate limits</a></li>
<li><a href="#log"><i class="ti ti-list-details"></i>Outbound log</a></li>
<li><a href="#queue"><i class="ti ti-stack-2"></i>Queue and retries</a></li> <li><a href="#queue"><i class="ti ti-stack-2"></i>Queue and retries</a></li>
<li><a href="#dmarc"><i class="ti ti-report-analytics"></i>DMARC reports</a></li> <li><a href="#dmarc"><i class="ti ti-report-analytics"></i>DMARC reports</a></li>
</ul> </ul>
<p class="menu-label">Inbound</p> <p class="menu-label">Inbound</p>
<ul class="menu-list"> <ul class="menu-list">
<li><a href="#inbound"><i class="ti ti-inbox"></i>Backup-MX and forwarding</a></li> <li><a href="#inbound"><i class="ti ti-inbox"></i>Backup-MX and forwarding</a></li>
<li><a href="#upstream"><i class="ti ti-arrow-forward-up"></i>Upstream</a></li>
<li><a href="#recipients"><i class="ti ti-address-book"></i>Valid recipients</a></li>
<li><a href="#filter"><i class="ti ti-shield-check"></i>Spam filter</a></li>
</ul>
<p class="menu-label">Server</p>
<ul class="menu-list">
<li><a href="#backup"><i class="ti ti-archive"></i>Backup and moving a domain</a></li>
<li><a href="#users"><i class="ti ti-users"></i>Users and roles</a></li>
<li><a href="#settings"><i class="ti ti-settings"></i>Settings</a></li>
</ul>
<p class="menu-label">Account</p>
<ul class="menu-list">
<li><a href="#account"><i class="ti ti-user-circle"></i>Your account</a></li>
</ul> </ul>
</aside> </aside>
</div> </div>
<div class="column is-9"> <div class="column is-9">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Overview</h2></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Overview</h2></div>
<div class="sp-help-topic" id="checks"><h3>Health checks</h3><p>Six checks, 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.</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.</p></div>
<div class="sp-help-topic" id="certificate"><h3>TLS certificate</h3><p>The certificate Postfix presents on 465 and 587. It warns 14 days before expiry and fails once expired — clients that verify certificates then refuse to connect.</p></div> <div class="sp-help-topic" id="certificate"><h3>TLS certificate</h3><p>The certificate Postfix presents on 465 and 587. The reverse proxy or the image mounts it, the panel does not issue it. It warns 14 days before expiry and fails once expired, or when the file is missing or unreadable — clients that verify certificates then refuse to connect.</p></div>
<div class="sp-help-topic" id="rdns"><h3>Hostname and reverse DNS</h3><p>The hostname 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="rdns"><h3>Hostname and reverse DNS</h3><p>The hostname (<code>SELFPOST_HOSTNAME</code>) must resolve to this server's address, and that address must resolve back to the hostname. Large receivers reject or spam-folder mail from hosts where the two disagree. The PTR record is set at your hosting provider, not in your DNS zone.</p></div>
</div> </div>
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Outbound</h2></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>Outbound</h2></div>
<div class="sp-help-topic" id="dns"><h3>DNS records</h3><p>DKIM proves the message was signed by this relay, SPF lists the addresses allowed to send for the domain, DMARC tells receivers what to do when both fail and where to report. Publish all three exactly as shown; merge SPF into an existing record rather than adding a second one.</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. 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. It may send as any address of its domain or only as listed addresses. The password is shown once at creation or regeneration and is not stored in readable form.</p></div> <div class="sp-help-topic" id="apps"><h3>Applications</h3><p>An application is one SASL login, unique across all domains. It may send as any address of its domain or only as listed addresses, and, with a list of client IPs set, only from those addresses. The password is shown once at creation or regeneration and is not stored in readable form.</p></div>
<div class="sp-help-topic" id="limits"><h3>Rate limits</h3><p>Level 1 is a per-IP ceiling set in <code>.env</code>. Level 2 is optional, per domain and per application, manual or computed from 30-day statistics, and can never exceed level 1.</p></div> <div class="sp-help-topic" id="limits"><h3>Rate limits</h3><p>Level 1 is a per-IP ceiling set in <code>.env</code>. Level 2 is optional, per domain and per application, entered by hand or computed from the last 30 days of sending, and can never exceed level 1. An application's own limit replaces its domain's.</p></div>
<div class="sp-help-topic" id="log"><h3>Outbound log</h3><p>One row per message and recipient, with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out) and rejected (refused before queueing under a level-2 rate limit, so it never reached Postfix). A message's page lists these steps and the lines Postfix wrote about it in <code>mail.log</code>, found by queue id; that file is rotated daily and about two weeks are kept, so an old message may show none. Rows are deleted after the retention set in Server › Settings, 90 days by default. A domain administrator sees only the rows of their own domains.</p></div>
<div class="sp-help-topic" id="queue"><h3>Queue and retries</h3><p>The page lists, as <code>postqueue -p</code> prints it, the messages Postfix has accepted and not yet delivered: mail being sent right now and mail deferred for a later attempt. Only the global role can open it, and it only shows; nothing here deletes or forces a message. A deferred message is tried again after the first retry delay, then at gaps that double up to a cap, until the receiving server takes it or the queue lifetime runs out and it bounces; there is no attempt counter, and the box on the page shows this server's values. A queue that keeps growing means a receiver is refusing or not answering: open the message from the Outbound log to read the reason Postfix logged.</p></div>
<div class="sp-help-topic" id="dmarc"><h3>DMARC reports</h3><p>An aggregate report is the summary a receiving provider sends, usually daily, to the <code>rua=</code> address of your DMARC record: which servers sent mail as your domain and whether SPF or DKIM passed for it. A domain's report address is one of three choices, set in its Domain settings: no reports (the default for a new domain), the SelfPost hosted address (an address on this server whose reports are parsed and shown under DMARC reports; offered only while hosted reports are enabled on the server), or an address typed for the domain (an ordinary mailbox that receives the raw XML; a button fills in your own profile e-mail). When the typed address is on another domain than the one sending, that domain must publish a report-authorization record, or providers will not send; the domain's page shows it with the other DNS records and checks it. In the tables pass means SPF or DKIM passed and aligned, fail means neither did, and your own server shows as <em>this relay</em>. A fail from any other source is someone else sending as your domain — a forwarder, an application you forgot, or abuse — so do not tighten <code>p=</code> until that sender is fixed or removed. A domain administrator reads the reports of their own domains.</p></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Inbound</h2></div>
<div class="sp-help-topic" id="inbound"><h3>Backup-MX and forwarding</h3><p>An optional backup-MX or forwarder, switched on in the server's <code>.env</code>; the Inbound menu exists only then. Postfix accepts mail on port 25 for the listed domains only and hands each message on to that domain's upstream; there are no mailboxes. For each domain publish the MX record its page shows, and keep a primary MX the domain already has if this is a backup. The MX tag is green when at least one MX of the domain points at this server, and a domain accepts no mail until an upstream is saved on its page. Adding and deleting domains is for the global role.</p></div>
<div class="sp-help-topic" id="upstream"><h3>Upstream</h3><p>The upstream is the server that actually holds the mailboxes. Give its host and port — used exactly as written, with no MX lookup — and choose the TLS for the hop to it: Opportunistic uses TLS when the upstream offers it and otherwise sends in the clear, Required holds the mail until a TLS session is possible, Off never uses TLS. While the upstream does not answer, Postfix keeps the mail and retries as for any other mail.</p></div>
<div class="sp-help-topic" id="recipients"><h3>Valid recipients</h3><p>Choose whom this domain accepts. Listed addresses only: the relay accepts exactly the addresses you list, one per line or comma-separated, and refuses any other while the sender is still connected, so it never has to send a bounce for them. Any recipient: everything at the domain is accepted and passed on, and an address the upstream then rejects comes back as a bounce from this server to a sender who is often forged — backscatter. Prefer a list, and keep it in step with the mailboxes upstream.</p></div>
<div class="sp-help-topic" id="filter"><h3>Spam filter</h3><p>The spam filter belongs to the server, not to a domain. The server's administrator attaches it in the server's <code>.env</code>, and from then on every inbound domain is screened by it before mail is handed on; the box shows on or off and has nothing to switch. Off means mail goes to the upstream unfiltered. While the filter is down, mail is either accepted unfiltered (the default) or deferred, so the sending server tries again later, as the server's setting says; the box states which.</p></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div>
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys, application credentials and the queue), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
<div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out of their other sessions.</p></div>
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it.</p></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">05</span><h2>Account</h2></div>
<div class="sp-help-topic" id="account"><h3>Your account</h3><p>Account is your own, whatever your role: your username and e-mail, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected.</p></div>
</div> </div>
</div> </div>
</div> </div>
@@ -50,7 +50,7 @@
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-6">
<div class="box" id="upstream"> <div class="box" id="upstream">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Upstream</h2><div class="sp-end">Not a mailbox</div></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>Upstream</h2><div class="sp-end">Not a mailbox</div><a class="sp-help" href="help.html#upstream" title="Host, port and TLS"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<form class="sp-form" method="post" action="#"> <form class="sp-form" method="post" action="#">
<div class="sp-row"> <div class="sp-row">
@@ -66,7 +66,7 @@
</div> </div>
<div class="column is-6"> <div class="column is-6">
<div class="box" id="recipients"> <div class="box" id="recipients">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Valid recipients</h2></div> <div class="sp-box-head"><span class="sp-no">03</span><h2>Valid recipients</h2><a class="sp-help" href="help.html#recipients" title="Listed addresses or any address"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<form class="sp-form" method="post" action="#"> <form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="rmode">Who to accept</label> <div class="field"><label class="label" for="rmode">Who to accept</label>
@@ -85,7 +85,7 @@ security@lists.example.org</textarea></div>
</div> </div>
<div class="box" id="filter"> <div class="box" id="filter">
<div class="sp-box-head"><span class="sp-no">04</span><h2>Spam filter</h2><div class="sp-end"><span class="tag is-success is-light">on</span></div></div> <div class="sp-box-head"><span class="sp-no">04</span><h2>Spam filter</h2><div class="sp-end"><span class="tag is-success is-light">on</span></div><a class="sp-help" href="help.html#filter" title="A server setting"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Engine</dt><dd>rspamd, for every inbound domain of this server</dd></div> <div><dt>Engine</dt><dd>rspamd, for every inbound domain of this server</dd></div>
@@ -21,7 +21,7 @@
</div> </div>
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Domains</h2><div class="sp-end">Spam filter <span class="tag is-success is-light">on</span> · 2 domains</div></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>Domains</h2><div class="sp-end">Spam filter <span class="tag is-success is-light">on</span> · 2 domains</div><a class="sp-help" href="help.html#inbound" title="Backup-MX and forwarding"><i class="ti ti-help-circle"></i></a></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Domain</th><th>DNS</th><th>Upstream</th><th>Recipients</th><th>TLS to upstream</th><th></th></tr></thead> <thead><tr><th>Domain</th><th>DNS</th><th>Upstream</th><th>Recipients</th><th>TLS to upstream</th><th></th></tr></thead>
<tbody> <tbody>
@@ -0,0 +1,17 @@
<!-- title: Sign in | shell: bare -->
<div class="columns is-gapless sp-signin">
<div class="column is-half sp-signin-brand">
<div>
<img src="../../selfpost-stamp.svg" alt="SelfPost">
<p>YOUR SMTP. YOUR RULES.</p>
</div>
</div>
<div class="column is-half sp-signin-form">
<div class="sp-form">
<p class="sp-kicker">mail.example.org</p>
<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>
</div>
</div>
@@ -31,7 +31,7 @@
<div class="column is-4"> <div class="column is-4">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Client IP allow-list</h2><div class="sp-end"><span class="tag is-light">off</span></div></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>Client IP allow-list</h2><div class="sp-end"><span class="tag is-light">off</span></div><a class="sp-help" href="help.html#apps" title="What an application is"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<div class="field"><label class="checkbox"><input type="checkbox" name="auth_ip_restrict"> Restrict to listed IPs</label></div> <div class="field"><label class="checkbox"><input type="checkbox" name="auth_ip_restrict"> Restrict to listed IPs</label></div>
<div class="field"><label class="label" for="ips">Allowed client IPs</label> <div class="field"><label class="label" for="ips">Allowed client IPs</label>
@@ -43,7 +43,7 @@
<div class="column is-4"> <div class="column is-4">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Rate limit</h2><div class="sp-end"><span class="tag is-success is-light">active</span></div></div> <div class="sp-box-head"><span class="sp-no">03</span><h2>Rate limit</h2><div class="sp-end"><span class="tag is-success is-light">active</span></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"> <div class="sp-box-body">
<div class="field"><label class="label" for="rmode">Limit mode</label> <div class="field"><label class="label" for="rmode">Limit mode</label>
<div class="control"><div class="select is-fullwidth"><select id="rmode" name="rl_mode"><option>Use the domain limit</option><option selected>Manual</option><option>Auto (from statistics)</option></select></div></div></div> <div class="control"><div class="select is-fullwidth"><select id="rmode" name="rl_mode"><option>Use the domain limit</option><option selected>Manual</option><option>Auto (from statistics)</option></select></div></div></div>
@@ -31,7 +31,7 @@
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-file-export"></i></span><h2>Moving it instead?</h2></div> <div class="sp-box-head"><span class="sp-no"><i class="ti ti-file-export"></i></span><h2>Moving it instead?</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
<p><a href="out-domain-settings.html#export">Export the domain</a> first. The file carries the key and the application passwords, so the new instance needs no DNS change.</p> <p><a href="out-domain-settings.html">Export the domain</a> first. The file carries the key and the application passwords, so the new instance needs no DNS change.</p>
</div> </div>
</div> </div>
</div> </div>
@@ -10,25 +10,25 @@
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-6">
<div class="box" id="reports"> <div class="box" id="reports">
<div class="sp-box-head"><span class="sp-no">01</span><h2>DMARC report address</h2></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>DMARC report address</h2><a class="sp-help" href="help.html#dmarc" title="Where DMARC reports go"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<form class="sp-form" method="post" action="#"> <form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="rua">Aggregate reports (rua=)</label> <div class="field"><label class="label" for="rua">Aggregate reports (rua=)</label>
<div class="control"><div class="select is-fullwidth"><select id="rua" name="dmarc_rua_mode"> <div class="control"><div class="select is-fullwidth"><select id="rua" name="dmarc_rua_mode">
<option selected>admin's default — mix@example.org</option> <option>No reports</option>
<option>SelfPost hosted (example.org@dmarc.mail.example.org)</option> <option>SelfPost hosted — example.org@dmarc.mail.example.org</option>
<option>My default instead — (you are admin)</option> <option selected>A specific address</option>
<option>No aggregate reports</option>
<option>Custom address</option>
</select></div></div> </select></div></div>
<p class="help">A default belongs to a user and is set under their <a href="account.html#dmarc">Account</a>; a domain follows one named user, so two people sharing a domain never pull it two ways. Changing this changes the DMARC record to publish.</p></div> <p class="help">No reports leaves <code>rua=</code> out of the DMARC record. SelfPost hosted sends them to an address on this server, which parses them for DMARC reports. A specific address is any mailbox, and it receives the raw XML. Changing this changes the DMARC record to publish.</p></div>
<div class="field"><label class="label" for="rua_email">Report address</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" id="rua_email" name="dmarc_rua_email" type="email" value="reports@acme.io" placeholder="reports@your-mail-domain.com" autocomplete="off" spellcheck="false"></div><div class="control"><button type="button" class="button" data-fill="rua_email" data-fill-value="mix@example.org"><i class="ti ti-mail"></i>Use my e-mail</button></div></div></div>
<button type="submit" class="button is-primary">Save report address</button> <button type="submit" class="button is-primary">Save report address</button>
</form> </form>
</div> </div>
</div> </div>
<div class="box" id="export"> <div class="box" id="export">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Export domain</h2></div> <div class="sp-box-head"><span class="sp-no">03</span><h2>Export domain</h2><a class="sp-help" href="help.html#backup" title="Moving a domain to another instance"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<p class="mb-3">One file with the DKIM key and every application's password hash — import it on another SelfPost and the published DNS needs no change. <strong>The file is a secret.</strong></p> <p class="mb-3">One file with the DKIM key and every application's password hash — import it on another SelfPost and the published DNS needs no change. <strong>The file is a secret.</strong></p>
<form class="sp-form" method="post" action="#"> <form class="sp-form" method="post" action="#">
@@ -28,9 +28,9 @@
</ul> </ul>
<p class="menu-label">Rarely changed</p> <p class="menu-label">Rarely changed</p>
<ul class="menu-list"> <ul class="menu-list">
<li><a href="out-domain-settings.html#reports"><i class="ti ti-mail-forward"></i>Report address</a></li> <li><a href="out-domain-settings.html"><i class="ti ti-mail-forward"></i>Report address</a></li>
<li><a href="out-domain-settings.html#limit"><i class="ti ti-gauge"></i>Rate limit <span class="tag is-success is-light">auto</span></a></li> <li><a href="out-domain-settings.html"><i class="ti ti-gauge"></i>Rate limit <span class="tag is-success is-light">auto</span></a></li>
<li><a href="out-domain-settings.html#export"><i class="ti ti-file-export"></i>Export</a></li> <li><a href="out-domain-settings.html"><i class="ti ti-file-export"></i>Export</a></li>
<li><a class="has-text-danger" href="out-domain-delete.html"><i class="ti ti-trash has-text-danger"></i>Delete domain</a></li> <li><a class="has-text-danger" href="out-domain-delete.html"><i class="ti ti-trash has-text-danger"></i>Delete domain</a></li>
</ul> </ul>
</aside> </aside>
@@ -66,14 +66,14 @@
</div> </div>
<div class="sp-record"> <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#reports">admin's default</a></small></div> <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>
<div class="sp-row"> <div class="sp-row">
<div class="field"><label class="label">Host / name</label> <div class="field"><label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="_dmarc.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div> <div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="_dmarc.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div> <div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div> </div>
<div class="field"><label class="label">Value to publish</label> <div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div> <div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=DMARC1; p=none; rua=mailto:example.org@dmarc.mail.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div>
<p class="help"><code>p=none</code> does not affect delivery. Tighten to <code>quarantine</code>, then <code>reject</code>, once the <a href="dmarc-domain.html">reports</a> look clean.</p></div> <p class="help"><code>p=none</code> does not affect delivery. Tighten to <code>quarantine</code>, then <code>reject</code>, once the <a href="dmarc-domain.html">reports</a> look clean.</p></div>
</div> </div>
</div> </div>
@@ -83,10 +83,10 @@
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th></th></tr></thead> <thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th></th></tr></thead>
<tbody> <tbody>
<tr><td class="sp-mono"><strong>prod-server</strong></td><td>*@example.org</td><td class="sp-muted">1 102 msg · peak 96/h</td><td><span class="tag is-success is-light">200 / h</span></td> <tr><td class="sp-mono sp-nowrap"><strong>prod-server</strong></td><td>*@example.org</td><td class="sp-muted">1 102 msg · peak 96/h</td><td><span class="tag is-success is-light">200 / h</span></td>
<td class="sp-actions"><a href="out-app.html">Edit</a><a href="out-app-created.html">New password</a><a class="has-text-danger" href="#">Delete</a></td></tr> <td class="sp-actions"><a href="out-app.html">Edit</a><form method="post" action="out-app-created.html"><button type="submit">New password</button></form><form method="post" action="out-domain.html"><button type="submit" class="has-text-danger">Delete</button></form></td></tr>
<tr><td class="sp-mono"><strong>alerts</strong></td><td>alerts@example.org, noc@example.org</td><td class="sp-muted">182 msg · peak 14/h</td><td><span class="tag is-light">domain</span> <span class="tag is-success is-light">2 IPs</span></td> <tr><td class="sp-mono sp-nowrap"><strong>alerts</strong></td><td>alerts@example.org, noc@example.org</td><td class="sp-muted">182 msg · peak 14/h</td><td><span class="tag is-light">domain</span> <span class="tag is-success is-light">2 IPs</span></td>
<td class="sp-actions"><a href="out-app.html">Edit</a><a href="out-app-created.html">New password</a><a class="has-text-danger" href="#">Delete</a></td></tr> <td class="sp-actions"><a href="out-app.html">Edit</a><form method="post" action="out-app-created.html"><button type="submit">New password</button></form><form method="post" action="out-domain.html"><button type="submit" class="has-text-danger">Delete</button></form></td></tr>
</tbody> </tbody>
</table></div> </table></div>
<div class="sp-box-foot">New password and Delete ask for confirmation; the old password stops working at once.</div> <div class="sp-box-foot">New password and Delete ask for confirmation; the old password stops working at once.</div>
@@ -16,6 +16,7 @@
<div class="select"><select name="status"><option>Any status</option><option>sent</option><option>deferred</option><option>bounced</option><option>rate-limited</option></select></div> <div class="select"><select name="status"><option>Any status</option><option>sent</option><option>deferred</option><option>bounced</option><option>rate-limited</option></select></div>
<button type="submit" class="button"><i class="ti ti-filter"></i>Filter</button> <button type="submit" class="button"><i class="ti ti-filter"></i>Filter</button>
</form> </form>
<a class="sp-help" href="help.html#log" title="What the statuses mean"><i class="ti ti-help-circle"></i></a>
</div> </div>
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr></thead> <thead><tr><th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr></thead>
@@ -25,7 +25,7 @@
</div> </div>
<div class="column is-5"> <div class="column is-5">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>History</h2></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>History</h2><a class="sp-help" href="help.html#log" title="What the statuses mean"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<ol class="sp-timeline"> <ol class="sp-timeline">
<li><time>13:58:07 UTC</time><strong>Accepted</strong> from <span class="sp-mono">alerts</span> at 198.51.100.7</li> <li><time>13:58:07 UTC</time><strong>Accepted</strong> from <span class="sp-mono">alerts</span> at 198.51.100.7</li>
@@ -8,7 +8,7 @@
</div> </div>
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Waiting</h2><div class="sp-end">1 message · 3.1 kB</div></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Waiting</h2><div class="sp-end">1 message · 3.1 kB</div><a class="sp-help" href="help.html#queue" title="What waits here and how Postfix retries"><i class="ti ti-help-circle"></i></a></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Queue id</th><th>Arrived, UTC</th><th>From</th><th>To</th><th>Last reply</th><th></th></tr></thead> <thead><tr><th>Queue id</th><th>Arrived, UTC</th><th>From</th><th>To</th><th>Last reply</th><th></th></tr></thead>
<tbody> <tbody>
@@ -12,12 +12,12 @@
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Server health</h2><div class="sp-end"><a href="health.html">All details</a></div><a class="sp-help" href="help.html#checks" title="What these checks mean"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Server health</h2><div class="sp-end"><a href="health.html">All details</a></div><a class="sp-help" href="help.html#checks" title="What these checks mean"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-health"> <div class="sp-health">
<a href="health.html#machine"><span class="sp-h-icon"><i class="ti ti-cpu"></i></span><span><span class="sp-h-name">Machine</span><span class="sp-h-value">CPU 12 %</span><span class="sp-h-sub">RAM 41 % · 62 kB/s</span></span></a> <a href="health.html"><span class="sp-h-icon"><i class="ti ti-cpu"></i></span><span><span class="sp-h-name">Machine</span><span class="sp-h-value">CPU 12 %</span><span class="sp-h-sub">RAM 41 % · 62 kB/s</span></span></a>
<a href="health.html#processes"><span class="sp-h-icon"><i class="ti ti-server-cog"></i></span><span><span class="sp-h-name">Processes</span><span class="sp-h-value">5 of 5 running</span><span class="sp-h-sub">up 18 days</span></span></a> <a href="health.html"><span class="sp-h-icon"><i class="ti ti-server-cog"></i></span><span><span class="sp-h-name">Processes</span><span class="sp-h-value">5 of 5 running</span><span class="sp-h-sub">up 18 days</span></span></a>
<a href="health.html#certificate" class="sp-warn"><span class="sp-h-icon"><i class="ti ti-certificate"></i></span><span><span class="sp-h-name">TLS certificate</span><span class="sp-h-value">Expires in 12 days</span><span class="sp-h-sub">renewal has not run</span></span></a> <a href="health.html" class="sp-warn"><span class="sp-h-icon"><i class="ti ti-certificate"></i></span><span><span class="sp-h-name">TLS certificate</span><span class="sp-h-value">Expires in 12 days</span><span class="sp-h-sub">renewal has not run</span></span></a>
<a href="out-queue.html"><span class="sp-h-icon"><i class="ti ti-stack-2"></i></span><span><span class="sp-h-name">Queues</span><span class="sp-h-value">Both empty</span><span class="sp-h-sub">outbound and inbound</span></span></a> <a href="out-queue.html"><span class="sp-h-icon"><i class="ti ti-stack-2"></i></span><span><span class="sp-h-name">Queues</span><span class="sp-h-value">Both empty</span><span class="sp-h-sub">outbound and inbound</span></span></a>
<a href="health.html#sockets"><span class="sp-h-icon"><i class="ti ti-plug-connected"></i></span><span><span class="sp-h-name">Milter sockets</span><span class="sp-h-value">3 of 3 answering</span><span class="sp-h-sub">incl. the spam filter</span></span></a> <a href="health.html"><span class="sp-h-icon"><i class="ti ti-plug-connected"></i></span><span><span class="sp-h-name">Milter sockets</span><span class="sp-h-value">3 of 3 answering</span><span class="sp-h-sub">incl. the spam filter</span></span></a>
<a href="health.html#hostname"><span class="sp-h-icon"><i class="ti ti-arrows-exchange"></i></span><span><span class="sp-h-name">Reverse DNS</span><span class="sp-h-value">Forward = reverse</span><span class="sp-h-sub">203.0.113.25</span></span></a> <a href="health.html"><span class="sp-h-icon"><i class="ti ti-arrows-exchange"></i></span><span><span class="sp-h-name">Reverse DNS</span><span class="sp-h-value">Forward = reverse</span><span class="sp-h-sub">203.0.113.25</span></span></a>
</div> </div>
</div> </div>
@@ -3,7 +3,7 @@
<div class="sp-grow"> <div class="sp-grow">
<p class="sp-kicker">Server</p> <p class="sp-kicker">Server</p>
<h1 class="title is-3">Settings</h1> <h1 class="title is-3">Settings</h1>
<p class="sp-lead">What holds for the whole instance, whoever is signed in. Your own name, e-mail, notifications and DMARC report address are under <a href="account.html">Account</a>.</p> <p class="sp-lead">What holds for the whole instance, whoever is signed in. Your own name, e-mail and password are under <a href="account.html">Account</a>.</p>
</div> </div>
</div> </div>
@@ -11,30 +11,22 @@
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-6">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Log retention</h2></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Log retention</h2><a class="sp-help" href="help.html#settings" title="How long rows are kept"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<div class="field"><label class="label" for="ret">Keep outbound log rows, days</label> <div class="field"><label class="label" for="ret">Keep outbound log rows, days</label>
<div class="control"><input class="input" id="ret" name="send_log_retention_days" type="number" min="7" max="365" value="30" required></div> <div class="control"><input class="input" id="ret" name="send_log_retention_days" type="number" min="7" max="365" value="30" required></div>
<p class="help">The main driver of <code>/data</code> growth. Does not affect <code>mail.log</code> rotation (fourteen daily files).</p></div> <p class="help">The main driver of <code>/data</code> growth. Does not affect <code>mail.log</code> rotation (fourteen daily files).</p></div>
</div> </div>
</div> </div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Notifications</h2></div>
<div class="sp-box-body">
<div class="field"><label class="checkbox"><input type="checkbox" name="notify" checked> Send notification e-mails</label></div>
<p class="help">Sent through this relay from <span class="sp-mono">postmaster@mail.example.org</span>. Each user picks the events under <a href="account.html">Account</a>; a user without an e-mail gets none.</p>
</div>
</div>
</div> </div>
<div class="column is-6"> <div class="column is-6">
<div class="box" id="rate-limits"> <div class="box" id="rate-limits">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Sending rate limits</h2><div class="sp-end">Read-only</div></div> <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"> <div class="sp-box-body">
<dl class="sp-facts"> <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 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#limit">domain</a> and <a href="out-app.html">application</a>.</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> </dl>
</div> </div>
</div> </div>
@@ -12,7 +12,7 @@
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-6">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Account</h2></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Account</h2><a class="sp-help" href="help.html#users" title="Roles and domain assignments"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="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="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> <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>

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