Compare commits
20
Commits
2dded921f5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22bfcdb5e4 | ||
|
|
25813316b3 | ||
|
|
1fe90c40f8 | ||
|
|
7289aec60b | ||
|
|
0eaf69a830 | ||
|
|
2bf9056d20 | ||
|
|
9b33b805d8 | ||
|
|
de4274ccc9 | ||
|
|
1eec1c6011 | ||
|
|
9aebe1efe4 | ||
|
|
3d23c41ae7 | ||
|
|
b8e156c9f4 | ||
|
|
09edc0fac6 | ||
|
|
b7bbb2ca14 | ||
|
|
2f5f5b2b93 | ||
|
|
b7f04217f2 | ||
|
|
ddafc96bab | ||
|
|
f1cc3f3b37 | ||
|
|
c5edbd84c7 | ||
|
|
be98efa549 |
@@ -5,6 +5,102 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.14.3] - 2026-10-17
|
||||
|
||||
A tag beside a label. No migration; schema 13.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A tag beside a label no longer squeezes the field under it**: the
|
||||
e-mail's "not confirmed" on Account made its label taller and left less
|
||||
room above the field than Username has (the owner's phone).
|
||||
|
||||
## [2.14.2] - 2026-10-17
|
||||
|
||||
The last small items of the day. No migration; schema 13.
|
||||
|
||||
### Added
|
||||
|
||||
- **Health dials the inbound journal's socket** with the inbound relay on:
|
||||
the milter that writes Inbound › Log was the one the panel did not check.
|
||||
The send log's socket is named as the mockup names it, `journal-milter`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **A DMARC report says which day it is**: its day stays the reporter's, in
|
||||
UTC (owner, 2026-10-17: accepted as it is), and the report's page now says
|
||||
so under its window; Settings and Help name it as the one exception to
|
||||
the panel's zone. A daily report from midnight to midnight is one day, not
|
||||
two.
|
||||
|
||||
### Documentation
|
||||
|
||||
- The guide's gaps from the docs audit: `/license` and `/server/components`,
|
||||
DMARC pages' paths, `selfpost-backup -o`, `-i` and `-version`, the
|
||||
Inbound log's retention field, `PANEL_COOKIE_SECURE`.
|
||||
|
||||
## [2.14.1] - 2026-10-17
|
||||
|
||||
Overview's head in the mockup's words. No migration; schema 13.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Overview's head says it as the mockup does** (overview-head): "Mail is
|
||||
flowing. One thing needs attention." - or "Nothing needs attention", or
|
||||
"Mail may not be flowing" when a check fails - and under it each check
|
||||
that is not fine with its value ("TLS certificate: expires in 12 days.").
|
||||
|
||||
## [2.14.0] - 2026-10-17
|
||||
|
||||
Password reset by e-mail, to a confirmed address. Migration 0013; schema 13.
|
||||
|
||||
### Added
|
||||
|
||||
- **Password reset by e-mail** (password-reset): *Forgot your password?* on
|
||||
the sign-in page sends a one-time link, for 30 minutes, to the account's
|
||||
e-mail - once that address has been confirmed by a link sent to it. A new
|
||||
address is sent its confirmation link when it is saved (Account, or the
|
||||
user form); Account shows whether it is confirmed and can send the link
|
||||
again. Notifications go to the address confirmed or not. Setting a new
|
||||
password from a link signs every session of the account out. Migration
|
||||
0013; schema 13.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Changing your e-mail on Account asks for your password**, as changing
|
||||
the password does: the address is where a reset link goes.
|
||||
|
||||
## [2.13.1] - 2026-10-17
|
||||
|
||||
The documents and the panel's words checked against the code. No migration;
|
||||
schema 12.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A panel password's twelve characters are characters**: the minimum
|
||||
counted bytes, so six letters of a non-Latin alphabet passed for twelve.
|
||||
It counts characters now, as the backup password always did. Passwords
|
||||
already set are not affected.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The panel and its mockups say the same** (docs-audit): an inbound
|
||||
domain's MX box says when it was checked and its side menu links the
|
||||
domain's Inbound log; a deferred message's history ends with the wait in
|
||||
the queue; Overview says it refreshes on its own; DMARC's hint for an
|
||||
unaligned third party reads as the mockup. Help says what the code does:
|
||||
an application's password is kept by the server so an export can carry
|
||||
it, every password check of a backup counts, a password set for another
|
||||
user signs them out everywhere, the limit mail is daily per sending
|
||||
domain, and a domain administrator is no longer shown the Filter lists
|
||||
topic (their page is the global role's).
|
||||
|
||||
### Documentation
|
||||
|
||||
- The documents checked against the code (docs-audit, 2026-10-17): the
|
||||
guide, architecture, schema-migrations, security, development, README and
|
||||
product say what the code does.
|
||||
|
||||
## [2.13.0] - 2026-10-17
|
||||
|
||||
The panel in the container's time zone. No migration; schema 12.
|
||||
|
||||
@@ -27,11 +27,11 @@ send log and DNS checks in the panel, encrypted backups.
|
||||
- Optional inbound relay on port 25 (backup-MX / forwarder; off by default)
|
||||
- Optional DMARC aggregate report ingest on port 25 (off by default)
|
||||
- In-panel Help: a page of topics, opened from the `?` in the head of a box
|
||||
- Per-application client IP allow-list for SMTP AUTH (optional)
|
||||
- Per-application client IP allow-list for submitting mail (optional; refused at MAIL FROM, not an authentication boundary)
|
||||
- Web panel grouped by mail direction — Overview, Outbound (domains, log, queue, DMARC reports), Inbound, Server (health, system log, backup, users, settings)
|
||||
- Multi-domain relay — each SASL application is bound to one sending domain
|
||||
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
|
||||
- Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides
|
||||
- Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and per-application overrides
|
||||
- Full-server backup and single-domain export/import (optional password encryption)
|
||||
- Single Docker image; production data in a `./data` bind mount (the quick start below uses a named Docker volume instead)
|
||||
|
||||
@@ -112,8 +112,8 @@ outbound mail will not reach the real internet without DNS, PTR, and port 25.
|
||||
> local image and pulls nothing:
|
||||
>
|
||||
> ```sh
|
||||
> docker build -f build/Dockerfile --build-arg VERSION=2.13.0 \
|
||||
> -t ghcr.io/mixeme/selfpost:2.13.0 .
|
||||
> docker build -f build/Dockerfile --build-arg VERSION=2.14.3 \
|
||||
> -t ghcr.io/mixeme/selfpost:2.14.3 .
|
||||
> ```
|
||||
|
||||
```sh
|
||||
@@ -122,7 +122,7 @@ docker run --rm -d --name selfpost-try \
|
||||
-e SELFPOST_HOSTNAME=mail.local.test \
|
||||
-e PANEL_COOKIE_SECURE=false \
|
||||
-v selfpost-try-data:/data \
|
||||
ghcr.io/mixeme/selfpost:2.13.0
|
||||
ghcr.io/mixeme/selfpost:2.14.3
|
||||
```
|
||||
|
||||
**Get the setup URL** (pick one):
|
||||
|
||||
@@ -14,13 +14,15 @@ import (
|
||||
// are compared as they are, not as lists kept by hand beside them (audit
|
||||
// 2026-10-12, stage 5 - the three lists this file held were compared with each
|
||||
// other and with nothing else). Read: what the panel's code asks of the
|
||||
// environment (cmd/panel and internal, tests aside) and what the start-up
|
||||
// environment (cmd/panel, cmd/selfpost-backup and internal, tests aside) and what the start-up
|
||||
// scripts take with a default (build/*.sh, "${VAR:-…}"). Documented: the
|
||||
// operator's table in guide.md § Environment variables, with the paragraph
|
||||
// under it, and the internal list in architecture.md § Configuration.
|
||||
|
||||
var (
|
||||
goEnvRead = regexp.MustCompile(`(?:envDefault|envInt|envBool|envSeconds|envDuration|os\.Getenv|os\.LookupEnv)\("([A-Z][A-Z0-9_]+)"`)
|
||||
goEnvRead = regexp.MustCompile(`(?:envDefault|envInt|envBool|envSeconds|envDuration|os\.Getenv|os\.LookupEnv)\("([A-Z][A-Z0-9_]+)"`)
|
||||
// A name kept in a constant, as the backup CLI keeps its password's.
|
||||
goEnvConst = regexp.MustCompile(`\w+Env\s*=\s*"([A-Z][A-Z0-9_]+)"`)
|
||||
scriptEnvRead = regexp.MustCompile(`\$\{([A-Z][A-Z0-9_]+):[-?=]`)
|
||||
docEnvName = regexp.MustCompile("`([A-Z][A-Z0-9_]+)`")
|
||||
)
|
||||
@@ -38,12 +40,16 @@ func readFile(t *testing.T, path string) string {
|
||||
func envRead(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
keys := map[string]bool{}
|
||||
for _, root := range []string{".", filepath.Join("..", "..", "internal")} {
|
||||
for _, root := range []string{".", filepath.Join("..", "selfpost-backup"), filepath.Join("..", "..", "internal")} {
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return err
|
||||
}
|
||||
for _, m := range goEnvRead.FindAllStringSubmatch(readFile(t, path), -1) {
|
||||
text := readFile(t, path)
|
||||
for _, m := range goEnvRead.FindAllStringSubmatch(text, -1) {
|
||||
keys[m[1]] = true
|
||||
}
|
||||
for _, m := range goEnvConst.FindAllStringSubmatch(text, -1) {
|
||||
keys[m[1]] = true
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -118,6 +118,7 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
|
||||
SignSocket: cfg.signSocket,
|
||||
SignLocalSocket: cfg.signLocalSocket,
|
||||
JournalSocket: cfg.journalSocket,
|
||||
InboundJournalSocket: cfg.inboundJournalSocket,
|
||||
SenderSocket: cfg.senderSocket,
|
||||
SessionIdleDays: cfg.sessionIdleDays,
|
||||
DNSResolvers: cfg.dnsResolvers,
|
||||
@@ -169,7 +170,9 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
|
||||
srv := newHTTPServer(cfg.httpAddr, srvApp.Handler())
|
||||
|
||||
// Shut the server down cleanly when the process is asked to stop.
|
||||
shutDown := make(chan struct{})
|
||||
go func() {
|
||||
defer close(shutDown)
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -180,5 +183,13 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
// ListenAndServe returns at the stop, before its requests have
|
||||
// (net/http, Server.Shutdown); Shutdown returns once they have. The links
|
||||
// they mail after their answers go on after that, and end before the
|
||||
// database closes.
|
||||
<-shutDown
|
||||
drainCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
srvApp.Drain(drainCtx)
|
||||
return nil
|
||||
}
|
||||
|
||||
+9
-2
@@ -318,6 +318,13 @@ func openData(cfg config, resync func(*store.Store) error) (st *store.Store, res
|
||||
}
|
||||
if restored {
|
||||
log.Printf("restore manifest accepted; regenerating mail-path maps from SQLite")
|
||||
// A link taken after the backup was made would work again from it
|
||||
// (security review, 2026-10-17); a link sent before a restore is
|
||||
// asked for again.
|
||||
if err := st.DeleteAllUserLinks(); err != nil {
|
||||
_ = st.Close()
|
||||
return nil, false, err
|
||||
}
|
||||
if err := resync(st); err != nil {
|
||||
_ = st.Close()
|
||||
return nil, false, err
|
||||
@@ -330,8 +337,8 @@ func openData(cfg config, resync func(*store.Store) error) (st *store.Store, res
|
||||
return st, restored, nil
|
||||
}
|
||||
|
||||
// run starts the panel's three roles and blocks until a shutdown signal or the
|
||||
// first fatal error from any role. A signal triggers a clean stop of all
|
||||
// run starts the panel's roles (the list below) and blocks until a shutdown
|
||||
// signal or the first fatal error from any role. A signal triggers a clean stop of all
|
||||
// roles; a role error cancels the others and is returned so the process exits
|
||||
// non-zero (letting supervisord/Docker see the failure — architecture.md §
|
||||
// Image and processes).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Example host Apache vhost for the SelfPost panel (default scenario). Install
|
||||
# on the host running Apache — NOT inside the SelfPost container. Requires
|
||||
# mod_ssl and mod_proxy/mod_proxy_http enabled (`a2enmod ssl proxy proxy_http`).
|
||||
# mod_ssl, mod_proxy/mod_proxy_http and mod_rewrite enabled (`a2enmod ssl proxy proxy_http rewrite`).
|
||||
#
|
||||
# Certificates: obtain with the Apache certbot plugin against THIS vhost
|
||||
# (`certbot --apache -d mail.example.com`). Certbot edits this file in place to
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
services:
|
||||
selfpost:
|
||||
image: ghcr.io/mixeme/selfpost:2.13.0
|
||||
image: ghcr.io/mixeme/selfpost:2.14.3
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
||||
|
||||
+41
-28
@@ -135,9 +135,10 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
|
||||
Refusals come from `mail.log`: this listener logs as `postfix/inbound`
|
||||
(`syslog_name`), and the log tailer turns a `reject:` line of
|
||||
`postfix/inbound/smtpd` (Postfix's own policy) and a `milter-reject` /
|
||||
`milter-hold` / `milter-discard` line of `cleanup` (the filter's verdict,
|
||||
counted only for a queue id that listener opened) into a row, dated by the
|
||||
line, when the recipient is at an inbound domain
|
||||
`milter-discard` line of `cleanup` (the filter's verdict, counted only for
|
||||
a queue id that listener opened) into a row, dated by the line, when the
|
||||
recipient is at an inbound domain; a message the filter holds goes on to
|
||||
the inbound journal milter, which writes its row as `held`
|
||||
([internal/logtail/inbound.go](../internal/logtail/inbound.go)). A line
|
||||
names one recipient (the last), so a message refused at end-of-message is
|
||||
one row whatever its recipient count. The tailer saves its place every five seconds,
|
||||
@@ -183,8 +184,9 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
|
||||
an empty upstream host are omitted from the maps. Optional
|
||||
`INBOUND_ANTISPAM_MILTER` only when inbound relay is enabled (not on
|
||||
DMARC-only port 25); it is attached with its own `default_action`,
|
||||
`INBOUND_ANTISPAM_MILTER_ACTION`, which is `accept` (fail-open) unless set
|
||||
to `tempfail` (main.cf's `milter_default_action` is `tempfail`).
|
||||
`INBOUND_ANTISPAM_MILTER_ACTION`, `accept` (fail-open, the default) or
|
||||
`tempfail`, any other value stopping the start (main.cf's
|
||||
`milter_default_action` is `tempfail`).
|
||||
**DMARC ingest** (`DMARC_REPORTS_ENABLE=true`): allow-listed
|
||||
report addresses via `check_recipient_access` on `postfix/dmarc_recipients`;
|
||||
messages pipe to `dmarc-ingest` (symlink to `panel`). When both flags are
|
||||
@@ -217,8 +219,8 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
|
||||
and to smtpd's `client=` line in mail.log.
|
||||
- **Chroot disabled** for all services (DNS/TLS inside container).
|
||||
- **TLS certs** — read-only mount at `TLS_CERT_FILE` / `TLS_KEY_FILE`, copied
|
||||
at start into `/etc/postfix/tls-internal`, owned by root and `0600` as
|
||||
Postfix's TLS_README advises (Postfix itself reads the key as root and does
|
||||
at start into `/etc/postfix/tls-internal`, owned by root, the key `0600`
|
||||
(the certificate `0644`) as Postfix's TLS_README advises (Postfix itself reads the key as root and does
|
||||
not check its mode), which the mount cannot give; Postfix reads the copy.
|
||||
`cert-reload` compares the mount with the copy daily; when they differ and
|
||||
the certificate and key belong together it renews the copy (each file
|
||||
@@ -310,8 +312,8 @@ then OpenDKIM did, in a program of its own
|
||||
message has an hour from the end of its headers to its end - go-milter
|
||||
v0.4.1 tells a session nothing of a dropped connection - after which it is
|
||||
deferred, never signed over part of its body.
|
||||
- **Logs** go to the panel's stdout, the container's log, as `dkim-signer:`
|
||||
lines; nothing is written to `mail.log` (nor did OpenDKIM write there).
|
||||
- **Logs** go to the panel's stderr, the container's log, as
|
||||
`panel: … dkim-signer:` lines; nothing is written to `mail.log` (nor did OpenDKIM write there).
|
||||
- **The old tree,** `/data/opendkim` - the keys, `KeyTable` and
|
||||
`SigningTable` until `2.8.0`, shared with OpenDKIM through group
|
||||
`selfpost`. `2.8.x` copied its keys to `/data/dkim/keys` at its first
|
||||
@@ -574,9 +576,12 @@ and `MAIL_LOG` in [cmd/panel/main.go](../cmd/panel/main.go). Backups exclude
|
||||
Rotation uses rename + `postfix reload`
|
||||
([build/logrotate-mail.conf](../build/logrotate-mail.conf)), not `copytruncate` —
|
||||
the latter can drop `status=sent` lines and leave send-log rows stuck at
|
||||
`queued`. After rename, logrotate runs `create 0640 postfix selfpost` (postlogd
|
||||
recreates the file on its first line after the reload, as root and mode
|
||||
`0600`, which the unprivileged panel user cannot read). `follow()` drains the
|
||||
`queued`. After rename, logrotate runs `create 0640 postfix selfpost`, so the
|
||||
file is there, readable by the panel through group `selfpost`, before postlogd
|
||||
writes its first line after the reload; a file postlogd makes itself (one
|
||||
removed while the container runs) is `0640` too, through
|
||||
`maillog_file_permissions=0640` - Postfix's default is `0600`, which the
|
||||
unprivileged panel user could not read. `follow()` drains the
|
||||
old inode once more before switching descriptors, and keeps it open and reads
|
||||
it for a minute after: postlogd keeps writing to the renamed file until the
|
||||
reload makes it exit, seconds later (Postfix 3.10.13, src/postlogd/postlogd.c;
|
||||
@@ -625,8 +630,9 @@ is mail of its own queue id and matches no row.
|
||||
|
||||
**Read offset is persisted** (`logtail_state` table, in the baseline schema): the
|
||||
tailer stores its position plus a fingerprint of the log - the hash of its first
|
||||
512 bytes and the file's device and inode, so two logs that begin alike are two
|
||||
files and a renamed file keeps its fingerprint - and on start resumes from it,
|
||||
512 bytes and the file's inode (not the device, which can change across a
|
||||
host's reboot), so two logs that begin alike are two files and a renamed file
|
||||
keeps its fingerprint - and on start resumes from it,
|
||||
parsing the tail written while the panel was down. If the fingerprint no longer
|
||||
matches, the log was rotated in the meantime: the tailer first opens
|
||||
`mail.log.1` (left plain by `delaycompress`), and if that is the file the saved
|
||||
@@ -635,7 +641,7 @@ start - what Postfix wrote between the stop and the rotation is not lost. A
|
||||
`mail.log.1` that is another file (rotated twice) is logged with the time from
|
||||
which nothing was read. Re-parsing lines is harmless because `UpdateStatus`
|
||||
writes the same status onto the same row. Only a first-ever start, with nothing
|
||||
stored, begins at end-of-file, so installing the panel does not replay a
|
||||
stored (or a stored place that cannot be read), begins at end-of-file, so installing the panel does not replay a
|
||||
pre-existing log.
|
||||
|
||||
**A refusal's line, and a refusal the panel did not write.** A message refused
|
||||
@@ -815,7 +821,9 @@ building them only takes method values, so the route guard test can run it on
|
||||
a bare `Server` and ask which pattern answers a path:
|
||||
|
||||
- **public** — `/healthz` (liveness), `/license` (the embedded `LICENSE`),
|
||||
`/static/`, `/setup/`, `/login`, `/logout`;
|
||||
`/static/`, `/setup/`, `/login`, `/logout`, and the links of
|
||||
password-reset, opened from a mail signed in or not: `/login/reset`,
|
||||
`/login/reset/<token>`, `/confirm/<token>` (`internal/web/auth/links.go`);
|
||||
- **authenticated** — everything else, behind `RequireAuth`: a request without
|
||||
a live session is redirected to `/login`.
|
||||
|
||||
@@ -838,8 +846,8 @@ only reads (the delete confirmation at `GET …/delete` renders a form).
|
||||
| Outbound › DMARC reports | `GET /outbound/dmarc` · `…/dmarc/domains/{id}` · `…/dmarc/reports/{id}` — registered only with `DMARC_REPORTS_ENABLE=true` |
|
||||
| Inbound › Domains | `GET, POST /inbound/domains` · `/inbound/domains/{id}` · `POST …/dns-recheck`, `…/upstream`, `…/recipients` · `GET, POST …/delete` — registered only with `INBOUND_RELAY_ENABLE=true` |
|
||||
| Inbound › Log, Filter lists | `GET /inbound/log` · `GET, POST /inbound/filter-lists` · `POST /inbound/filter-lists/{id}/delete` — registered only with `INBOUND_RELAY_ENABLE=true` |
|
||||
| Server | `GET /server/health` (+ `/fragment`) · `POST /server/health/recheck`, `/server/health/reload` · `GET /server/log` (+ `/fragment`) · `GET, POST /server/backup` · `POST /server/backup/import` · `/server/users`, `…/new`, `…/{uid}`, `…/{uid}/delete` · `GET, POST /server/settings` · `POST /server/settings/dns-recheck` · `GET /server/components` |
|
||||
| Account | `GET /account` · `POST /account/profile`, `/account/password` |
|
||||
| Server | `GET /server/health` (+ `/fragment`) · `POST /server/health/recheck`, `/server/health/reload`, `/server/health/test-message` · `GET /server/log` (+ `/fragment`) · `GET, POST /server/backup` · `POST /server/backup/import` · `/server/users`, `…/new`, `…/{uid}`, `…/{uid}/delete` · `GET, POST /server/settings` · `POST /server/settings/dns-recheck` · `GET /server/components` |
|
||||
| Account | `GET /account` · `POST /account/profile`, `/account/password`, `/account/email/confirm` |
|
||||
| User menu | `GET /help` · `POST /logout` (public mux) |
|
||||
|
||||
An application is addressed under its domain, and an application id that does
|
||||
@@ -940,9 +948,8 @@ is one layout and one way to render.
|
||||
package (`NewOutDomain`, `NewHelp`, …) that the handler fills. The shell is
|
||||
derived from `Meta` and the feature flags (`SetInboundEnabled`,
|
||||
`SetDMARCEnabled`) in one place, `Engine.shell`, so which entries the menu
|
||||
has is decided once. No template reads a `map[string]any`; the one map left
|
||||
in the handlers is the health sampler's, which is turned into a typed
|
||||
`Health` before rendering.
|
||||
has is decided once. No page's data is a map: the health sampler too
|
||||
returns a typed `health.Machine`.
|
||||
- **The component kit.** [components.html](../internal/web/view/templates/components.html)
|
||||
holds one `{{define}}` per partial (page head, postmark, box and its head and
|
||||
foot, flash, health card, DNS record, facts, side menu, timeline, log pane,
|
||||
@@ -982,8 +989,8 @@ has the component skeleton of its mockup), `TestRedesignedPagesLoadOnlyTheKit`,
|
||||
ratchet list `internal/web/view/legacy_pages.txt` is empty and can only shrink;
|
||||
the CI step
|
||||
[design-first](../.github/scripts/design-first.sh) rejects a commit that
|
||||
changes a guard together with the code it judges, and a template that is a
|
||||
byte-for-byte return of one from history. The design contract and the process
|
||||
changes a guard together with the code it judges, and a template that is
|
||||
the same as one from history, whitespace aside. The design contract and the process
|
||||
are in [plans/panel-redesign.md](plans/panel-redesign.md) § Enforcement.
|
||||
|
||||
### Security headers, CSP and scripts
|
||||
@@ -1151,8 +1158,9 @@ single-connection trade-off that follows from it.
|
||||
| `postfix/dmarc_recipients` | Allow-listed DMARC aggregate report addresses |
|
||||
| `postfix/dmarc_transport` | Pipe transport for report ingest |
|
||||
| `postfix/dmarc_relay_domains` | Domains accepted for DMARC report delivery |
|
||||
| `postfix/queue/` | Postfix transit mail (deferred/active); survives container recreate. Handed to `postfix` / `postdrop` by name at every start, before `postfix check` (`own_queue` in `build/entrypoint.sh`): the package accounts' numbers moved in `2.8.0` (postfix 101 → 100, postdrop 103 → 102) |
|
||||
| `postfix/queue/` | Postfix transit mail (deferred/active); survives container recreate; excluded from backups. Handed to `postfix` / `postdrop` by name at every start, before `postfix check` (`own_queue` in `build/entrypoint.sh`): the package accounts' numbers moved in `2.8.0` (postfix 101 → 100, postdrop 103 → 102) |
|
||||
| `log/mail.log` | Postfix delivery log + rotated copies (excluded from backups) |
|
||||
| `antispam/` | The filter lists (six `.map` files) and the filter's key `filter.key`, which the spam filter's container mounts (`ANTISPAM_DIR`) |
|
||||
| `manifest.json` | Backup version stamp; removed by the first start after a restore once its Resync is done |
|
||||
| `contents.json` | A backup's last entry: the size and SHA-256 of each file under `data/`, compared by the first start after a restore before it opens the database (renamed `contents.checked.json` once compared, removed with the manifest) |
|
||||
|
||||
@@ -1221,8 +1229,9 @@ the stored log lines follow;
|
||||
`postfix reload` in `postrotate` — see § Log tailer above).
|
||||
|
||||
**Restore:** panel button or `selfpost-backup` CLI — self-contained archive:
|
||||
`data/` (SQLite snapshot + tree minus `log/`, the setup token and any
|
||||
`tls/` under `/data`), `docker-compose.yml`, `.env`,
|
||||
`data/` (SQLite snapshot + tree minus `log/`, the Postfix queue
|
||||
`postfix/queue`, the copy `sasl/sasldb2.new` a password change is made on, the
|
||||
setup token and any `tls/` under `/data`), `docker-compose.yml`, `.env`,
|
||||
and `certs/` when present;
|
||||
version check on restore. Requires the project directory mounted read-only at
|
||||
`SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` in the default compose file). On the
|
||||
@@ -1299,11 +1308,15 @@ unsupported rather than as a missing doc:
|
||||
`POSTFIX_DMARC_RECIPIENTS` (`/data/postfix/dmarc_recipients`),
|
||||
`POSTFIX_DMARC_TRANSPORT` (`/data/postfix/dmarc_transport`),
|
||||
`POSTFIX_DMARC_RELAY_DOMAINS` (`/data/postfix/dmarc_relay_domains`),
|
||||
`POSTFIX_QUEUE_DIR` (`/data/postfix/queue` — set in `build/postfix-config.sh`),
|
||||
`POSTFIX_QUEUE_DIR` (`/data/postfix/queue` — read in `build/postfix-config.sh`
|
||||
for `queue_directory`; `build/entrypoint.sh` hands `/data/postfix/queue` to
|
||||
Postfix by that path, so overriding it desyncs the queue's ownership),
|
||||
`SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` — operator project directory for
|
||||
full backups; mount `.:/selfpost-deploy:ro` in compose), `ANTISPAM_DIR`
|
||||
(`/data/antispam` — the filter lists and the filter's key, which the spam
|
||||
filter's container mounts).
|
||||
filter's container mounts). `SELFPOST_BACKUP_PASSWORD` is read by the
|
||||
`selfpost-backup` CLI, not the panel: the encryption password, the other
|
||||
way being `-password-file` (guide § Encrypting a backup).
|
||||
- **Milter and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`),
|
||||
`MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`),
|
||||
`MILTER_WAIT_TIMEOUT` (`30` seconds).
|
||||
|
||||
@@ -80,12 +80,18 @@
|
||||
<div class="sp-box-body">
|
||||
<form class="sp-form" method="post" action="#">
|
||||
<div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="admin" autocomplete="username" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div>
|
||||
<div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
|
||||
<p class="help">Your own address. A domain's report address can be filled with it from Domain settings.</p></div>
|
||||
<div class="field"><label class="label" for="email">E-mail <span class="tag is-warning is-light">not confirmed</span></label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
|
||||
<p class="help">Your own address. Saving a new one sends it a link to confirm it; once confirmed it is where a link to reset your password can go. A domain's report address can be filled with it from Domain settings.</p></div>
|
||||
<div class="field"><label class="label" for="pcur">Current password</label><div class="control"><input class="input" id="pcur" name="current_password" type="password" autocomplete="current-password"></div>
|
||||
<p class="help">Needed only to change the e-mail.</p></div>
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="notify" checked> Send me notifications</label>
|
||||
<p class="help">To this address, when something needs you: a DNS record of a domain stops matching, a rate limit refuses mail, the TLS certificate is about to expire, a message has waited in the queue for three days. <a href="help.html#notifications">What is sent and when</a></p></div>
|
||||
<button type="submit" class="button is-primary">Save profile</button>
|
||||
</form>
|
||||
<form class="sp-form mt-4" method="post" action="#">
|
||||
<p class="help mb-3">A link was sent to <span class="sp-mono">mix@example.org</span> at 14:02; it works once, for 24 hours.</p>
|
||||
<button type="submit" class="button">Send the link again</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,7 +114,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -52,7 +52,7 @@ HEAD = """<!DOCTYPE html>
|
||||
</head>
|
||||
"""
|
||||
|
||||
FOOT = ('<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a>'
|
||||
FOOT = ('<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a>'
|
||||
' · <a href="#">Source</a> · No warranty</footer>')
|
||||
|
||||
|
||||
|
||||
@@ -292,7 +292,7 @@
|
||||
<div class="sp-box-foot">Bulma's <code>is-small</code> and <code>is-size-7</code> are not used: a control is never small, and <code>sp-small</code> replaces the helper. The postmark's fine print is an imprint, not text.</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Confirm e-mail · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
|
||||
<link rel="stylesheet" href="../shared/brand.css">
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="columns is-gapless sp-signin">
|
||||
<div class="column is-half sp-signin-brand">
|
||||
<div>
|
||||
<img src="../../selfpost-stamp.svg" alt="SelfPost">
|
||||
<p>YOUR SMTP. YOUR RULES.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-half sp-signin-form">
|
||||
<div class="sp-form">
|
||||
<p class="sp-kicker">mail.example.org</p>
|
||||
<h1 class="title is-3">Confirm e-mail</h1>
|
||||
<div class="notification is-danger is-light"><i class="ti ti-alert-circle"></i>Sign in to the account in this browser, then open the link again.</div>
|
||||
<p><a href="login.html">Sign in</a></p>
|
||||
</div>
|
||||
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Confirm e-mail · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
|
||||
<link rel="stylesheet" href="../shared/brand.css">
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="columns is-gapless sp-signin">
|
||||
<div class="column is-half sp-signin-brand">
|
||||
<div>
|
||||
<img src="../../selfpost-stamp.svg" alt="SelfPost">
|
||||
<p>YOUR SMTP. YOUR RULES.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-half sp-signin-form">
|
||||
<form class="sp-form" method="post" action="account.html">
|
||||
<p class="sp-kicker">mail.example.org · admin</p>
|
||||
<h1 class="title is-3">Confirm e-mail</h1>
|
||||
<p class="sp-muted mb-4"><span class="sp-mono">mix@example.org</span> becomes the confirmed address of this account: where a link to reset its password can be sent.</p>
|
||||
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Confirm</button></div>
|
||||
</form>
|
||||
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -103,7 +103,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
<div class="sp-head">
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker"><a href="dmarc.html">DMARC reports</a> / <a href="dmarc-domain.html">shop.example.org</a> /</p>
|
||||
<h1 class="title is-4">Outlook.com · 20 Sep 2026</h1>
|
||||
<h1 class="title is-4">Outlook.com · 20 Sep</h1>
|
||||
<p class="sp-route"><span>shop.example.org</span><span class="tag is-success is-light">1 903 pass</span><span class="tag is-warning is-light">7 fail</span></p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,7 +81,7 @@
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Reporter</dt><dd>Outlook.com</dd></div>
|
||||
<div><dt>Report id</dt><dd class="sp-mono sp-small">5f1c9a0e7b2d4e61a3</dd></div>
|
||||
<div><dt>Window</dt><dd>20 Sep 00:00 – 21 Sep 00:00</dd></div>
|
||||
<div><dt>Window</dt><dd>20 Sep 03:00 – 21 Sep 03:00</dd><dd class="sp-muted">The reporter's day, 20 Sep: reports count their days in UTC, and the title and the lists name that day. The times are this panel's zone.</dd></div>
|
||||
<div><dt>Received</dt><dd>2026-09-21 04:40</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
@@ -112,7 +112,7 @@
|
||||
</table></div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
</table></div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker">Server</p>
|
||||
<h1 class="title is-3">Health</h1>
|
||||
<p class="sp-lead">Everything behind the six cards on Overview. Refreshes every 10 s.</p>
|
||||
<p class="sp-lead">Everything behind the six cards on Overview. Refreshes automatically.</p>
|
||||
</div>
|
||||
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form>
|
||||
</div>
|
||||
@@ -134,6 +134,7 @@
|
||||
<tr><td>dkim-signer <span class="sp-muted">· notifications</span><br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/sign-local.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>journal-milter<br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/journal.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>sender-check<br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/sender.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>inbound-journal-milter <span class="sp-muted">· Inbound log</span><br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/inbound-journal.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>rspamd <span class="sp-muted">· inbound spam filter</span><br><span class="sp-muted sp-mono sp-small">inet:antispam:11332</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
@@ -189,7 +190,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -126,12 +126,12 @@
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Outbound</h2></div>
|
||||
<div class="sp-help-topic" id="dns"><h3>DNS records</h3><p>DKIM proves the message was signed by this relay, SPF lists the addresses allowed to send for the domain, DMARC tells receivers what to do when both fail and where to report. Publish all three exactly as shown; merge SPF into an existing record rather than adding a second one. A domain's status is the worst of its three records, and results are cached for a few minutes, so use Re-check DNS after publishing. A record DNS already holds as shown is folded to its name and status; Show values opens it to copy them again. The SPF check is shallow: it looks for this server's address in the record itself and does not follow <code>include:</code> or <code>redirect=</code>, so a record that relies on them is reported as a warning, not a failure. <code>p=none</code> does not affect delivery; tighten to <code>quarantine</code>, then <code>reject</code>, once the reports look clean. A domain's report address is set in its Domain settings.</p></div>
|
||||
<div class="sp-help-topic" id="apps"><h3>Applications</h3><p>An application is one SASL login, unique across all domains. It may send as any address of its domain or only as listed addresses, and, with a list of client IPs set, only from those addresses. The password is shown once at creation or regeneration and is not stored in readable form.</p></div>
|
||||
<div class="sp-help-topic" id="apps"><h3>Applications</h3><p>An application is one SASL login, unique across all domains. It may send as any address of its domain or only as listed addresses, and, with a list of client IPs set, only from those addresses. The password is shown once, at creation or regeneration, and the panel does not show it again; the server keeps it, so a domain export can carry it.</p></div>
|
||||
<div class="sp-help-topic" id="sender-check"><h3>Sender check</h3><p>An application sends only as itself, and that holds for every sender a message names. The envelope sender is held against the login by Postfix. The <code>From:</code> line, the one a mail program shows and the one DMARC holds the DKIM signature to, is held against it by the panel, under the same rule: an application set to any address of its domain may write any address of that domain, and one with a list of addresses only those. A message with any other <code>From:</code> - another of your domains, the server's own name, an address somewhere else, or none at all - is refused for good with <code>550 5.7.1</code>. The line must hold one address, read as a mail program reads it: what a display name around it says is not judged, and a line holding more than one address is refused. A <code>Sender:</code> line, which a mail program shows beside <code>From:</code> as "on behalf of", is held to the same rule when the message has one. A system that writes for people outside your domains writes its own address in <code>From:</code> with the person's name, and the person's address in <code>Reply-To:</code>: their domain in <code>From:</code> would fail DMARC at the receiver anyway. It is a rejected row of the Outbound log, and its page says which address was refused. This is what keeps one application from sending, under this server's signature, as another domain or another application.<br><br>The check is strict. While the panel does not answer, Postfix does not accept mail unchecked: it replies with a temporary error and the application sends again later. Server › Health counts what that costs: for how long the panel did not answer while Postfix itself was running; how many connections Postfix turned away because of it - it refuses from the connection on, before a message is offered, so a client that tries three times is three and a monitor that only connects is one; and how many messages were refused with a temporary error in the middle, when the panel stopped answering after a message had begun. A time marked ≈ includes a start at which supervisord could not say since when Postfix was running: the time since the panel's last sign of life is counted, which may be more than Postfix was up. The card warns when a connection was turned away or a message refused in the last 24 hours. If the numbers stay at nothing, the check is where it should be; if they do not, it belongs in a process of its own.</p></div>
|
||||
<div class="sp-help-topic" id="limits"><h3>Rate limits</h3><p>Level 1 is a per-IP ceiling set in <code>.env</code>. Level 2 is optional, per domain and per application, entered by hand or computed from the last 30 days of sending, and can never exceed level 1. An application's own limit replaces its domain's.</p></div>
|
||||
<div class="sp-help-topic" id="log"><h3>Outbound log</h3><p>One row per message and recipient, with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out; or closed by the panel when Postfix no longer held the message and <code>mail.log</code> never said what became of it) and rejected (refused before queueing, so it never reached the queue: under a level-2 rate limit, from a client address the application does not allow, or for a <code>From:</code> or <code>Sender:</code> line that is not the application's - the message's page says which). A message's page lists these steps and the lines Postfix wrote about it in <code>mail.log</code>, found by queue id - and for a message refused before it had one, Postfix's one line about the refusal. The lines are kept with the row: they stay on the page after that file has rotated, for as long as the row does. A message accepted before version 2.2.0 shows them only while they are still in the current file. Rows are deleted after the retention set in Server › Settings, 90 days by default, and their lines with them. A domain administrator sees only the rows of their own domains.</p></div>
|
||||
<div class="sp-help-topic" id="log"><h3>Outbound log</h3><p>One row per message and recipient (a message refused for its From: or Sender: line is one row, however many recipients it had), with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out; or closed by the panel when Postfix no longer held the message and <code>mail.log</code> never said what became of it) and rejected (refused before queueing, so it never reached the queue: under a level-2 rate limit, from a client address the application does not allow, or for a <code>From:</code> or <code>Sender:</code> line that is not the application's - the message's page says which). A message's page lists these steps and the lines Postfix wrote about it in <code>mail.log</code>, found by queue id - and for a message refused before it had one, Postfix's one line about the refusal. The lines are kept with the row: they stay on the page after that file has rotated, for as long as the row does. A message accepted before version 2.2.0 shows them only while they are still in the current file. Rows are deleted after the retention set in Server › Settings, 90 days by default, and their lines with them. A domain administrator sees only the rows of their own domains.</p></div>
|
||||
<div class="sp-help-topic" id="queue"><h3>Queue and retries</h3><p>The page lists, as <code>postqueue -p</code> prints it, the messages Postfix has accepted and not yet delivered: mail being sent right now and mail deferred for a later attempt. Only the global role can open it, and it only shows; nothing here deletes or forces a message. A deferred message is tried again after the first retry delay, then at gaps that double up to a cap, until the receiving server takes it or the queue lifetime runs out and it bounces; there is no attempt counter, and the box on the page shows this server's values. A queue that keeps growing means a receiver is refusing or not answering: open the message from the Outbound log to read the reason Postfix logged.</p></div>
|
||||
<div class="sp-help-topic" id="dmarc"><h3>DMARC reports</h3><p>An aggregate report is the summary a receiving provider sends, usually daily, to the <code>rua=</code> address of your DMARC record: which servers sent mail as your domain and whether SPF or DKIM passed for it. A domain's report address is one of three choices, set in its Domain settings: no reports (the default for a new domain), the SelfPost hosted address (an address on this server whose reports are parsed and shown under DMARC reports; offered only while hosted reports are enabled on the server), or an address typed for the domain (an ordinary mailbox that receives the raw XML; a button fills in your own profile e-mail). When the typed address is on another domain than the one sending, that domain must publish a report-authorization record, or providers will not send; the domain's page shows it with the other DNS records and checks it. In the tables pass means SPF or DKIM passed and aligned, fail means neither did, and your own server shows as <em>this relay</em>. A fail from any other source is someone else sending as your domain — a forwarder, an application you forgot, or abuse — so do not tighten <code>p=</code> until that sender is fixed or removed. A domain administrator reads the reports of their own domains.</p></div>
|
||||
<div class="sp-help-topic" id="dmarc"><h3>DMARC reports</h3><p>An aggregate report is the summary a receiving provider sends, usually daily, to the <code>rua=</code> address of your DMARC record - its day is the reporter's, counted in UTC, and that is the day the lists and a report's title name, while the report's page gives its window in this panel's zone: which servers sent mail as your domain and whether SPF or DKIM passed for it. A domain's report address is one of three choices, set in its Domain settings: no reports (the default for a new domain), the SelfPost hosted address (an address on this server whose reports are parsed and shown under DMARC reports; offered only while hosted reports are enabled on the server), or an address typed for the domain (an ordinary mailbox that receives the raw XML; a button fills in your own profile e-mail). When the typed address is on another domain than the one sending, that domain must publish a report-authorization record, or providers will not send; the domain's page shows it with the other DNS records and checks it. In the tables pass means SPF or DKIM passed and aligned, fail means neither did, and your own server shows as <em>this relay</em>. A fail from any other source is someone else sending as your domain — a forwarder, an application you forgot, or abuse — so do not tighten <code>p=</code> until that sender is fixed or removed. A domain administrator reads the reports of their own domains.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Inbound</h2></div>
|
||||
@@ -144,19 +144,19 @@
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div>
|
||||
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys and application credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. Both it and a domain's export ask for your account password - the one you sign in with - at every download, so a session left open is not enough to take them; wrong guesses count against the same limit as a password change. The Postfix queue is not in it. The backup is taken while the server is running, and Postfix keeps moving messages through the queue, so a copy could hold a message twice or miss it; mail still waiting for delivery is therefore not carried over, and a restored server starts with an empty queue. To keep the queue, stop the container and archive the project directory instead. The private key in <code>certs/</code> has to be readable by the panel: give it to group 10998 on the host (<code>chgrp 10998</code>, <code>chmod 640</code>). Until then the panel refuses to make a backup and names the file, rather than hand over an archive without the key. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
|
||||
<div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out of their other sessions.</p></div>
|
||||
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. The time zone is the container's, <code>TZ</code> in <code>.env</code> (UTC when it is not set): every time the panel shows and every day it counts is that zone's, as are the stamps Postfix writes in <code>mail.log</code>, so the two always agree; changing it is a restart of the container. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. With the inbound relay on there is a second number, for the Inbound log, with the same range. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it. Notifications are turned on or off here for everyone.</p></div>
|
||||
<div class="sp-help-topic" id="notifications"><h3>Notifications</h3><p>The panel writes to its users when something needs them, from <code>postmaster@</code> the server's hostname. Four things are reported. A sending domain's SPF, DKIM or DMARC record stops matching: sent once two hourly checks in a row have found it so, and once more when the record matches again. A level-2 rate limit refuses mail: sent at the first refusal, then at most once a day for the same limit. The TLS certificate is 14 days from expiry, and again at 3 days if it is still the same certificate. A message has waited in the queue for more than three days, with one more message when none is left. A global user is told all of it; a domain administrator is told about the DNS records and limits of their own domains.<br><br>Each user turns notifications on or off under Account and needs an e-mail there; Server › Settings turns them off for everyone. They are the panel's own mail: not rows of the Outbound log, and not counted against any limit.<br><br>For that mail to arrive, the server's own name needs two DNS records of its own, as each of your domains does: a DKIM key, which the panel generates and signs notifications with, and an SPF record, <code>v=spf1 a -all</code>. Server › Settings gives both with the values to publish and whether they are in place, and its Re-check DNS checks them again once they are published; Server › Health shows them too. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains does not depend on either.</p></div>
|
||||
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys and application credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. Both it and a domain's export ask for your account password - the one you sign in with - at every download, so a session left open is not enough to take them; every attempt, right or wrong, counts against the limit of a password change, ten in 15 minutes. The Postfix queue is not in it. The backup is taken while the server is running, and Postfix keeps moving messages through the queue, so a copy could hold a message twice or miss it; mail still waiting for delivery is therefore not carried over, and a restored server starts with an empty queue. To keep the queue, stop the container and archive the project directory instead. The private key in <code>certs/</code> has to be readable by the panel: give it to group 10998 on the host (<code>chgrp 10998</code>, <code>chmod 640</code>). Until then the panel refuses to make a backup and names the file, rather than hand over an archive without the key. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
|
||||
<div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out everywhere; set for yourself, it keeps the session you set it from.</p></div>
|
||||
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. The time zone is the container's, <code>TZ</code> in <code>.env</code> (UTC when it is not set): every time the panel shows and every day it counts is that zone's, as are the stamps Postfix writes in <code>mail.log</code>, so the two always agree; changing it is a restart of the container. The one exception is the day a DMARC report covers, which is the reporter's, counted in UTC. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. With the inbound relay on there is a second number, for the Inbound log, with the same range. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it. Notifications are turned on or off here for everyone.</p></div>
|
||||
<div class="sp-help-topic" id="notifications"><h3>Notifications</h3><p>The panel writes to its users when something needs them, from <code>postmaster@</code> the server's hostname. Four things are reported. A sending domain's SPF, DKIM or DMARC record stops matching: sent once two hourly checks in a row have found it so, and once more when the record matches again. A level-2 rate limit refuses mail: sent at the first refusal, then at most once a day for the same sending domain. The TLS certificate is 14 days from expiry, and again at 3 days if it is still the same certificate. A message has waited in the queue for more than three days, with one more message when none is left. A global user is told all of it; a domain administrator is told about the DNS records and limits of their own domains.<br><br>Each user turns notifications on or off under Account and needs an e-mail there; Server › Settings turns them off for everyone. They are the panel's own mail: not rows of the Outbound log, and not counted against any limit.<br><br>For that mail to arrive, the server's own name needs two DNS records of its own, as each of your domains does: a DKIM key, which the panel generates and signs notifications with, and an SPF record, <code>v=spf1 a -all</code>. Server › Settings gives both with the values to publish and whether they are in place, and its Re-check DNS checks them again once they are published; Server › Health shows them too. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains does not depend on either.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">05</span><h2>Account</h2></div>
|
||||
<div class="sp-help-topic" id="account"><h3>Your account</h3><p>Account is your own, whatever your role: your username and e-mail, whether the panel sends you notifications, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected.</p></div>
|
||||
<div class="sp-help-topic" id="account"><h3>Your account</h3><p>Account is your own, whatever your role: your username and e-mail, whether the panel sends you notifications, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected. A new e-mail is sent a link that confirms it, which works once, for 24 hours; changing the e-mail asks for your password. Notifications go to the address whether it is confirmed or not, but only a confirmed one can get a link to reset a forgotten password: <em>Forgot your password?</em> on the sign-in page sends it, for 30 minutes and once, and setting the new password signs every session of the account out. Without a confirmed e-mail the global administrator sets a new password under Users, and the only global administrator recovers from the container.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
dev@lists.example.org
|
||||
owner@lists.example.org
|
||||
security@lists.example.org</textarea></div>
|
||||
<p class="help">Unknown recipients are rejected at RCPT, so this relay generates no backscatter. Prefer a list unless the upstream itself rejects unknowns.</p></div>
|
||||
<p class="help">One per line or comma-separated. Unknown recipients are rejected at RCPT, so this relay generates no backscatter. Prefer a list unless the upstream itself rejects unknowns.</p></div>
|
||||
<button type="submit" class="button is-primary">Save recipients</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -154,17 +154,16 @@ security@lists.example.org</textarea></div>
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Spam filter</h2><div class="sp-end"><span class="tag is-success is-light">on</span></div><a class="sp-help" href="help.html#filter" title="A server setting"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Engine</dt><dd>rspamd, for every inbound domain of this server</dd></div>
|
||||
<div><dt>Suspicious mail</dt><dd>Held — <a href="in-quarantine.html">1 message in quarantine</a> for this domain</dd></div>
|
||||
<div><dt>Spam</dt><dd>Refused at the door; see <em>reject</em> in the <a href="in-log.html">log</a></dd></div>
|
||||
<div><dt>Allow and deny lists</dt><dd class="sp-muted">Kept by the global administrator, one pair for the whole server</dd></div>
|
||||
<div><dt>Applies to</dt><dd>Every inbound domain of this server</dd></div>
|
||||
<div><dt>Filter</dt><dd class="sp-mono">inet:antispam:11332</dd></div>
|
||||
<div><dt>If the filter is down</dt><dd>Mail is accepted unfiltered</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
<div class="sp-box-foot">The MX tag is green when at least one MX of the domain points at this server.</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
<div class="sp-box-foot"><span>Page 1 of 38</span><a href="#">Older →</a><span class="sp-end">A decision opens its reason, an upstream result its last reply · Allow / Deny sender opens <a href="in-filter-lists.html">Filter lists</a> with the entry filled in</span></div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
<div class="sp-box-foot"><span>3 messages · 3.0 MB of 500 MB</span><span class="sp-end">A domain administrator sees only their assigned inbound domains</span></div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Waiting</h2><div class="sp-end">Refreshes every 10 s</div></div>
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Waiting</h2><div class="sp-end">Refreshes on its own</div></div>
|
||||
<div class="sp-empty"><i class="ti ti-stack-2"></i>Nothing is waiting. Every accepted message has reached its upstream.</div>
|
||||
</div>
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<h1 class="title is-3">No administrator yet</h1>
|
||||
<p class="sp-muted">Open the one-time setup link printed in the container log to create one.</p>
|
||||
</div>
|
||||
<p class="sp-foot">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
|
||||
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign in · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
|
||||
<link rel="stylesheet" href="../shared/brand.css">
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="columns is-gapless sp-signin">
|
||||
<div class="column is-half sp-signin-brand">
|
||||
<div>
|
||||
<img src="../../selfpost-stamp.svg" alt="SelfPost">
|
||||
<p>YOUR SMTP. YOUR RULES.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-half sp-signin-form">
|
||||
<form class="sp-form" method="post" action="overview.html">
|
||||
<p class="sp-kicker">mail.example.org</p>
|
||||
<h1 class="title is-3">Sign in</h1>
|
||||
<div class="notification is-success is-light"><i class="ti ti-circle-check"></i>If that account has a confirmed e-mail, the link is on its way. It works once, for 30 minutes.</div>
|
||||
<div class="field">
|
||||
<label class="label" for="username">Username</label>
|
||||
<div class="control has-icons-left"><input class="input" id="username" name="username" autocomplete="username" required><span class="icon is-left"><i class="ti ti-user"></i></span></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="password">Password</label>
|
||||
<div class="control has-icons-left"><input class="input" id="password" name="password" type="password" autocomplete="current-password" required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
|
||||
</div>
|
||||
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Sign in</button></div>
|
||||
<p class="mt-4"><a href="login-reset.html">Forgot your password?</a></p>
|
||||
</form>
|
||||
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>New password · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
|
||||
<link rel="stylesheet" href="../shared/brand.css">
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="columns is-gapless sp-signin">
|
||||
<div class="column is-half sp-signin-brand">
|
||||
<div>
|
||||
<img src="../../selfpost-stamp.svg" alt="SelfPost">
|
||||
<p>YOUR SMTP. YOUR RULES.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-half sp-signin-form">
|
||||
<form class="sp-form" method="post" action="login.html">
|
||||
<p class="sp-kicker">mail.example.org · admin</p>
|
||||
<h1 class="title is-3">New password</h1>
|
||||
<p class="sp-muted mb-4">Setting it signs every session of this account out; sign in with it afterwards.</p>
|
||||
<div class="field">
|
||||
<label class="label" for="new_password">New password</label>
|
||||
<div class="control has-icons-left"><input class="input" id="new_password" name="new_password" type="password" autocomplete="new-password" required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="new_password_confirm">Repeat</label>
|
||||
<div class="control has-icons-left"><input class="input" id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password" required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
|
||||
</div>
|
||||
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Set password</button></div>
|
||||
</form>
|
||||
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Reset password · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
|
||||
<link rel="stylesheet" href="../shared/brand.css">
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="columns is-gapless sp-signin">
|
||||
<div class="column is-half sp-signin-brand">
|
||||
<div>
|
||||
<img src="../../selfpost-stamp.svg" alt="SelfPost">
|
||||
<p>YOUR SMTP. YOUR RULES.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-half sp-signin-form">
|
||||
<form class="sp-form" method="post" action="login-reset-sent.html">
|
||||
<p class="sp-kicker">mail.example.org</p>
|
||||
<h1 class="title is-3">Reset password</h1>
|
||||
<p class="sp-muted mb-4">A link to set a new password goes to the e-mail of the account, once that address is confirmed under Account. It works once, for 30 minutes.</p>
|
||||
<div class="field">
|
||||
<label class="label" for="username">Username</label>
|
||||
<div class="control has-icons-left"><input class="input" id="username" name="username" autocomplete="username" required><span class="icon is-left"><i class="ti ti-user"></i></span></div>
|
||||
</div>
|
||||
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Send the link</button></div>
|
||||
<p class="mt-4"><a href="login.html">Back to sign in</a></p>
|
||||
</form>
|
||||
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -30,8 +30,9 @@
|
||||
<div class="control has-icons-left"><input class="input" id="password" name="password" type="password" autocomplete="current-password" required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
|
||||
</div>
|
||||
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Sign in</button></div>
|
||||
<p class="mt-4"><a href="login-reset.html">Forgot your password?</a></p>
|
||||
</form>
|
||||
<p class="sp-foot">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
|
||||
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker"><a href="out-domains.html">Outbound domains</a> / <a href="out-domain.html">example.org</a> / Applications /</p>
|
||||
<h1 class="title is-3 sp-mono">prod-server</h1>
|
||||
<p class="sp-lead">1 102 messages in 30 days · peak 96 msg/h · average 1.5 msg/h. The same form, empty, adds a new application.</p>
|
||||
<p class="sp-lead">1 102 messages in 30 days · peak 96 msg/h · average 1.5 msg/h.</p>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<a class="button" href="out-app-created.html"><i class="ti ti-key"></i>New password</a>
|
||||
@@ -85,7 +85,7 @@
|
||||
<div class="sp-box-body">
|
||||
<div class="field"><label class="label" for="login">Login</label>
|
||||
<div class="control"><input class="input sp-mono" id="login" name="login" value="prod-server" autocomplete="off" spellcheck="false" required></div>
|
||||
<p class="help">Unique across domains; letters, digits, <code>.</code> <code>-</code> <code>_</code>.</p></div>
|
||||
<p class="help">Fixed: add a new application to use another login.</p></div>
|
||||
<div class="field"><label class="label" for="amode">May send as</label>
|
||||
<div class="control"><div class="select is-fullwidth"><select id="amode" name="mode"><option selected>Any address of the domain</option><option>Specific addresses (list)</option></select></div></div></div>
|
||||
<div class="field"><label class="label" for="addresses">Addresses</label>
|
||||
@@ -129,7 +129,7 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
<form class="sp-form" method="post" action="#">
|
||||
<div class="field"><label class="label" for="mode">Limit mode</label>
|
||||
<div class="control"><div class="select is-fullwidth"><select id="mode" name="mode"><option>Manual</option><option selected>Auto (from statistics)</option></select></div></div></div>
|
||||
<div class="field"><label class="label" for="mult">Multiplier (1.5–10, default 3)</label>
|
||||
<div class="field"><label class="label" for="mult">Multiplier (1.5–5, default 2.5)</label>
|
||||
<div class="control"><input class="input" id="mult" name="auto_multiplier" type="number" min="1.5" max="10" step="0.1" value="3"></div>
|
||||
<p class="help">Computed limit: <strong>288</strong> messages / 3600 s. Peak was 96 msg/h. Last recalculated yesterday 03:00.</p></div>
|
||||
<div class="buttons">
|
||||
@@ -142,7 +142,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -172,7 +172,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
<div class="sp-box-foot">Each tag is one check. Green — published and correct; amber — published but weak; red — missing or wrong.</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
<div class="sp-box-foot"><span>Page 1 of 412</span><a href="#">Older →</a><span class="sp-end">New rows appear on their own</span></div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -94,9 +94,9 @@
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>History</h2><a class="sp-help" href="help.html#log" title="What the statuses mean"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<ol class="sp-timeline">
|
||||
<li><time>13:58:07</time><strong>Accepted</strong> from <span class="sp-mono">alerts</span> at 198.51.100.7</li>
|
||||
<li class="sp-warn"><time>13:58:09</time><strong>Deferred</strong> — the receiving server asked to retry later</li>
|
||||
<li class="sp-pending"><time>next attempt ≈ 14:08</time>Still in the <a href="out-queue.html">queue</a>; kept for 5 days, then bounced</li>
|
||||
<li><time>2026-09-21 13:58:07</time><strong>Accepted and queued</strong> — Postfix accepted the message over an authenticated submission and the journal-milter recorded it.</li>
|
||||
<li class="sp-warn"><time>2026-09-21 13:58:09</time><strong>Deferred, will be retried</strong> — the receiving server could not take the message yet. Postfix retries: first after 5 minutes, then with increasing gaps up to 1 hour 7 minutes, for up to 5 days.</li>
|
||||
<li class="sp-pending"><time>not yet</time>Still in the queue; the next attempt is Postfix's own, and after 5 days it is bounced.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,7 +112,7 @@
|
||||
</span></pre>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker">Outbound</p>
|
||||
<h1 class="title is-3">Outbound queue</h1>
|
||||
<p class="sp-lead">Messages accepted from applications and still waiting for the receiving server. Refreshes every 10 s.</p>
|
||||
<p class="sp-lead">Messages accepted from applications and still waiting for the receiving server. Refreshes on its own.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -70,9 +70,9 @@
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker">mail.example.org</p>
|
||||
<h1 class="title is-3">Mail is flowing. One thing needs attention.</h1>
|
||||
<p class="sp-lead">The TLS certificate expires in 12 days and has not renewed yet.</p>
|
||||
<p class="sp-lead"><a href="health.html">TLS certificate</a>: expires in 12 days.</p>
|
||||
</div>
|
||||
<div class="sp-muted sp-small">Updated 14:02:31<br>refreshes every 10 s</div>
|
||||
<div class="sp-muted sp-small">Updated 14:02:31<br>refreshes on its own</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
@@ -82,7 +82,7 @@
|
||||
<a href="health.html"><span class="sp-h-icon"><i class="ti ti-server-cog"></i></span><span><span class="sp-h-name">Processes</span><span class="sp-h-value">5 of 5 running</span><span class="sp-h-sub">up 18 days</span></span></a>
|
||||
<a href="health.html" class="sp-warn"><span class="sp-h-icon"><i class="ti ti-certificate"></i></span><span><span class="sp-h-name">TLS certificate</span><span class="sp-h-value">Expires in 12 days</span><span class="sp-h-sub">renewal has not run</span></span></a>
|
||||
<a href="out-queue.html"><span class="sp-h-icon"><i class="ti ti-stack-2"></i></span><span><span class="sp-h-name">Queues</span><span class="sp-h-value">Both empty</span><span class="sp-h-sub">outbound and inbound</span></span></a>
|
||||
<a href="health.html"><span class="sp-h-icon"><i class="ti ti-plug-connected"></i></span><span><span class="sp-h-name">Milter sockets</span><span class="sp-h-value">5 of 5 answering</span><span class="sp-h-sub">incl. the spam filter</span></span></a>
|
||||
<a href="health.html"><span class="sp-h-icon"><i class="ti ti-plug-connected"></i></span><span><span class="sp-h-name">Milter sockets</span><span class="sp-h-value">6 of 6 answering</span><span class="sp-h-sub">incl. the spam filter</span></span></a>
|
||||
<a href="health.html"><span class="sp-h-icon"><i class="ti ti-arrows-exchange"></i></span><span><span class="sp-h-name">Reverse DNS</span><span class="sp-h-value">Forward = reverse</span><span class="sp-h-sub">203.0.113.25</span></span></a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,7 +115,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -146,13 +146,13 @@
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Zone</dt><dd class="sp-mono">Europe/Moscow</dd><dd class="sp-muted">UTC+03:00. Set in <code>.env</code> (<code>TZ</code>); restart to change.</dd></div>
|
||||
</dl>
|
||||
<p class="mt-4 sp-muted">Every time the panel shows, and every day it counts - today on Health, the days of the statistics - is this zone's, and so are the stamps of <code>mail.log</code>.</p>
|
||||
<p class="mt-4 sp-muted">Every time the panel shows, and every day it counts - today on Health, the days of the statistics - is this zone's, and so are the stamps of <code>mail.log</code>. One exception: the day a DMARC report covers is the reporter's, in UTC.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Create administrator</button></div>
|
||||
</form>
|
||||
<p class="sp-foot">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
|
||||
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,12 +14,18 @@
|
||||
<div class="sp-box-body">
|
||||
<form class="sp-form" method="post" action="#">
|
||||
<div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="admin" autocomplete="username" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div>
|
||||
<div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
|
||||
<p class="help">Your own address. A domain's report address can be filled with it from Domain settings.</p></div>
|
||||
<div class="field"><label class="label" for="email">E-mail <span class="tag is-warning is-light">not confirmed</span></label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
|
||||
<p class="help">Your own address. Saving a new one sends it a link to confirm it; once confirmed it is where a link to reset your password can go. A domain's report address can be filled with it from Domain settings.</p></div>
|
||||
<div class="field"><label class="label" for="pcur">Current password</label><div class="control"><input class="input" id="pcur" name="current_password" type="password" autocomplete="current-password"></div>
|
||||
<p class="help">Needed only to change the e-mail.</p></div>
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="notify" checked> Send me notifications</label>
|
||||
<p class="help">To this address, when something needs you: a DNS record of a domain stops matching, a rate limit refuses mail, the TLS certificate is about to expire, a message has waited in the queue for three days. <a href="help.html#notifications">What is sent and when</a></p></div>
|
||||
<button type="submit" class="button is-primary">Save profile</button>
|
||||
</form>
|
||||
<form class="sp-form mt-4" method="post" action="#">
|
||||
<p class="help mb-3">A link was sent to <span class="sp-mono">mix@example.org</span> at 14:02; it works once, for 24 hours.</p>
|
||||
<button type="submit" class="button">Send the link again</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<!-- title: Confirm e-mail | shell: bare -->
|
||||
<div class="columns is-gapless sp-signin">
|
||||
<div class="column is-half sp-signin-brand">
|
||||
<div>
|
||||
<img src="../../selfpost-stamp.svg" alt="SelfPost">
|
||||
<p>YOUR SMTP. YOUR RULES.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-half sp-signin-form">
|
||||
<div class="sp-form">
|
||||
<p class="sp-kicker">mail.example.org</p>
|
||||
<h1 class="title is-3">Confirm e-mail</h1>
|
||||
<div class="notification is-danger is-light"><i class="ti ti-alert-circle"></i>Sign in to the account in this browser, then open the link again.</div>
|
||||
<p><a href="login.html">Sign in</a></p>
|
||||
</div>
|
||||
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
<!-- title: Confirm e-mail | shell: bare -->
|
||||
<div class="columns is-gapless sp-signin">
|
||||
<div class="column is-half sp-signin-brand">
|
||||
<div>
|
||||
<img src="../../selfpost-stamp.svg" alt="SelfPost">
|
||||
<p>YOUR SMTP. YOUR RULES.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-half sp-signin-form">
|
||||
<form class="sp-form" method="post" action="account.html">
|
||||
<p class="sp-kicker">mail.example.org · admin</p>
|
||||
<h1 class="title is-3">Confirm e-mail</h1>
|
||||
<p class="sp-muted mb-4"><span class="sp-mono">mix@example.org</span> becomes the confirmed address of this account: where a link to reset its password can be sent.</p>
|
||||
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Confirm</button></div>
|
||||
</form>
|
||||
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="sp-head">
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker"><a href="dmarc.html">DMARC reports</a> / <a href="dmarc-domain.html">shop.example.org</a> /</p>
|
||||
<h1 class="title is-4">Outlook.com · 20 Sep 2026</h1>
|
||||
<h1 class="title is-4">Outlook.com · 20 Sep</h1>
|
||||
<p class="sp-route"><span>shop.example.org</span><span class="tag is-success is-light">1 903 pass</span><span class="tag is-warning is-light">7 fail</span></p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -15,7 +15,7 @@
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Reporter</dt><dd>Outlook.com</dd></div>
|
||||
<div><dt>Report id</dt><dd class="sp-mono sp-small">5f1c9a0e7b2d4e61a3</dd></div>
|
||||
<div><dt>Window</dt><dd>20 Sep 00:00 – 21 Sep 00:00</dd></div>
|
||||
<div><dt>Window</dt><dd>20 Sep 03:00 – 21 Sep 03:00</dd><dd class="sp-muted">The reporter's day, 20 Sep: reports count their days in UTC, and the title and the lists name that day. The times are this panel's zone.</dd></div>
|
||||
<div><dt>Received</dt><dd>2026-09-21 04:40</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker">Server</p>
|
||||
<h1 class="title is-3">Health</h1>
|
||||
<p class="sp-lead">Everything behind the six cards on Overview. Refreshes every 10 s.</p>
|
||||
<p class="sp-lead">Everything behind the six cards on Overview. Refreshes automatically.</p>
|
||||
</div>
|
||||
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form>
|
||||
</div>
|
||||
@@ -68,6 +68,7 @@
|
||||
<tr><td>dkim-signer <span class="sp-muted">· notifications</span><br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/sign-local.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>journal-milter<br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/journal.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>sender-check<br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/sender.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>inbound-journal-milter <span class="sp-muted">· Inbound log</span><br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/inbound-journal.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>rspamd <span class="sp-muted">· inbound spam filter</span><br><span class="sp-muted sp-mono sp-small">inet:antispam:11332</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
|
||||
@@ -60,12 +60,12 @@
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Outbound</h2></div>
|
||||
<div class="sp-help-topic" id="dns"><h3>DNS records</h3><p>DKIM proves the message was signed by this relay, SPF lists the addresses allowed to send for the domain, DMARC tells receivers what to do when both fail and where to report. Publish all three exactly as shown; merge SPF into an existing record rather than adding a second one. A domain's status is the worst of its three records, and results are cached for a few minutes, so use Re-check DNS after publishing. A record DNS already holds as shown is folded to its name and status; Show values opens it to copy them again. The SPF check is shallow: it looks for this server's address in the record itself and does not follow <code>include:</code> or <code>redirect=</code>, so a record that relies on them is reported as a warning, not a failure. <code>p=none</code> does not affect delivery; tighten to <code>quarantine</code>, then <code>reject</code>, once the reports look clean. A domain's report address is set in its Domain settings.</p></div>
|
||||
<div class="sp-help-topic" id="apps"><h3>Applications</h3><p>An application is one SASL login, unique across all domains. It may send as any address of its domain or only as listed addresses, and, with a list of client IPs set, only from those addresses. The password is shown once at creation or regeneration and is not stored in readable form.</p></div>
|
||||
<div class="sp-help-topic" id="apps"><h3>Applications</h3><p>An application is one SASL login, unique across all domains. It may send as any address of its domain or only as listed addresses, and, with a list of client IPs set, only from those addresses. The password is shown once, at creation or regeneration, and the panel does not show it again; the server keeps it, so a domain export can carry it.</p></div>
|
||||
<div class="sp-help-topic" id="sender-check"><h3>Sender check</h3><p>An application sends only as itself, and that holds for every sender a message names. The envelope sender is held against the login by Postfix. The <code>From:</code> line, the one a mail program shows and the one DMARC holds the DKIM signature to, is held against it by the panel, under the same rule: an application set to any address of its domain may write any address of that domain, and one with a list of addresses only those. A message with any other <code>From:</code> - another of your domains, the server's own name, an address somewhere else, or none at all - is refused for good with <code>550 5.7.1</code>. The line must hold one address, read as a mail program reads it: what a display name around it says is not judged, and a line holding more than one address is refused. A <code>Sender:</code> line, which a mail program shows beside <code>From:</code> as "on behalf of", is held to the same rule when the message has one. A system that writes for people outside your domains writes its own address in <code>From:</code> with the person's name, and the person's address in <code>Reply-To:</code>: their domain in <code>From:</code> would fail DMARC at the receiver anyway. It is a rejected row of the Outbound log, and its page says which address was refused. This is what keeps one application from sending, under this server's signature, as another domain or another application.<br><br>The check is strict. While the panel does not answer, Postfix does not accept mail unchecked: it replies with a temporary error and the application sends again later. Server › Health counts what that costs: for how long the panel did not answer while Postfix itself was running; how many connections Postfix turned away because of it - it refuses from the connection on, before a message is offered, so a client that tries three times is three and a monitor that only connects is one; and how many messages were refused with a temporary error in the middle, when the panel stopped answering after a message had begun. A time marked ≈ includes a start at which supervisord could not say since when Postfix was running: the time since the panel's last sign of life is counted, which may be more than Postfix was up. The card warns when a connection was turned away or a message refused in the last 24 hours. If the numbers stay at nothing, the check is where it should be; if they do not, it belongs in a process of its own.</p></div>
|
||||
<div class="sp-help-topic" id="limits"><h3>Rate limits</h3><p>Level 1 is a per-IP ceiling set in <code>.env</code>. Level 2 is optional, per domain and per application, entered by hand or computed from the last 30 days of sending, and can never exceed level 1. An application's own limit replaces its domain's.</p></div>
|
||||
<div class="sp-help-topic" id="log"><h3>Outbound log</h3><p>One row per message and recipient, with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out; or closed by the panel when Postfix no longer held the message and <code>mail.log</code> never said what became of it) and rejected (refused before queueing, so it never reached the queue: under a level-2 rate limit, from a client address the application does not allow, or for a <code>From:</code> or <code>Sender:</code> line that is not the application's - the message's page says which). A message's page lists these steps and the lines Postfix wrote about it in <code>mail.log</code>, found by queue id - and for a message refused before it had one, Postfix's one line about the refusal. The lines are kept with the row: they stay on the page after that file has rotated, for as long as the row does. A message accepted before version 2.2.0 shows them only while they are still in the current file. Rows are deleted after the retention set in Server › Settings, 90 days by default, and their lines with them. A domain administrator sees only the rows of their own domains.</p></div>
|
||||
<div class="sp-help-topic" id="log"><h3>Outbound log</h3><p>One row per message and recipient (a message refused for its From: or Sender: line is one row, however many recipients it had), with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out; or closed by the panel when Postfix no longer held the message and <code>mail.log</code> never said what became of it) and rejected (refused before queueing, so it never reached the queue: under a level-2 rate limit, from a client address the application does not allow, or for a <code>From:</code> or <code>Sender:</code> line that is not the application's - the message's page says which). A message's page lists these steps and the lines Postfix wrote about it in <code>mail.log</code>, found by queue id - and for a message refused before it had one, Postfix's one line about the refusal. The lines are kept with the row: they stay on the page after that file has rotated, for as long as the row does. A message accepted before version 2.2.0 shows them only while they are still in the current file. Rows are deleted after the retention set in Server › Settings, 90 days by default, and their lines with them. A domain administrator sees only the rows of their own domains.</p></div>
|
||||
<div class="sp-help-topic" id="queue"><h3>Queue and retries</h3><p>The page lists, as <code>postqueue -p</code> prints it, the messages Postfix has accepted and not yet delivered: mail being sent right now and mail deferred for a later attempt. Only the global role can open it, and it only shows; nothing here deletes or forces a message. A deferred message is tried again after the first retry delay, then at gaps that double up to a cap, until the receiving server takes it or the queue lifetime runs out and it bounces; there is no attempt counter, and the box on the page shows this server's values. A queue that keeps growing means a receiver is refusing or not answering: open the message from the Outbound log to read the reason Postfix logged.</p></div>
|
||||
<div class="sp-help-topic" id="dmarc"><h3>DMARC reports</h3><p>An aggregate report is the summary a receiving provider sends, usually daily, to the <code>rua=</code> address of your DMARC record: which servers sent mail as your domain and whether SPF or DKIM passed for it. A domain's report address is one of three choices, set in its Domain settings: no reports (the default for a new domain), the SelfPost hosted address (an address on this server whose reports are parsed and shown under DMARC reports; offered only while hosted reports are enabled on the server), or an address typed for the domain (an ordinary mailbox that receives the raw XML; a button fills in your own profile e-mail). When the typed address is on another domain than the one sending, that domain must publish a report-authorization record, or providers will not send; the domain's page shows it with the other DNS records and checks it. In the tables pass means SPF or DKIM passed and aligned, fail means neither did, and your own server shows as <em>this relay</em>. A fail from any other source is someone else sending as your domain — a forwarder, an application you forgot, or abuse — so do not tighten <code>p=</code> until that sender is fixed or removed. A domain administrator reads the reports of their own domains.</p></div>
|
||||
<div class="sp-help-topic" id="dmarc"><h3>DMARC reports</h3><p>An aggregate report is the summary a receiving provider sends, usually daily, to the <code>rua=</code> address of your DMARC record - its day is the reporter's, counted in UTC, and that is the day the lists and a report's title name, while the report's page gives its window in this panel's zone: which servers sent mail as your domain and whether SPF or DKIM passed for it. A domain's report address is one of three choices, set in its Domain settings: no reports (the default for a new domain), the SelfPost hosted address (an address on this server whose reports are parsed and shown under DMARC reports; offered only while hosted reports are enabled on the server), or an address typed for the domain (an ordinary mailbox that receives the raw XML; a button fills in your own profile e-mail). When the typed address is on another domain than the one sending, that domain must publish a report-authorization record, or providers will not send; the domain's page shows it with the other DNS records and checks it. In the tables pass means SPF or DKIM passed and aligned, fail means neither did, and your own server shows as <em>this relay</em>. A fail from any other source is someone else sending as your domain — a forwarder, an application you forgot, or abuse — so do not tighten <code>p=</code> until that sender is fixed or removed. A domain administrator reads the reports of their own domains.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Inbound</h2></div>
|
||||
@@ -78,14 +78,14 @@
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div>
|
||||
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys and application credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. Both it and a domain's export ask for your account password - the one you sign in with - at every download, so a session left open is not enough to take them; wrong guesses count against the same limit as a password change. The Postfix queue is not in it. The backup is taken while the server is running, and Postfix keeps moving messages through the queue, so a copy could hold a message twice or miss it; mail still waiting for delivery is therefore not carried over, and a restored server starts with an empty queue. To keep the queue, stop the container and archive the project directory instead. The private key in <code>certs/</code> has to be readable by the panel: give it to group 10998 on the host (<code>chgrp 10998</code>, <code>chmod 640</code>). Until then the panel refuses to make a backup and names the file, rather than hand over an archive without the key. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
|
||||
<div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out of their other sessions.</p></div>
|
||||
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. The time zone is the container's, <code>TZ</code> in <code>.env</code> (UTC when it is not set): every time the panel shows and every day it counts is that zone's, as are the stamps Postfix writes in <code>mail.log</code>, so the two always agree; changing it is a restart of the container. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. With the inbound relay on there is a second number, for the Inbound log, with the same range. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it. Notifications are turned on or off here for everyone.</p></div>
|
||||
<div class="sp-help-topic" id="notifications"><h3>Notifications</h3><p>The panel writes to its users when something needs them, from <code>postmaster@</code> the server's hostname. Four things are reported. A sending domain's SPF, DKIM or DMARC record stops matching: sent once two hourly checks in a row have found it so, and once more when the record matches again. A level-2 rate limit refuses mail: sent at the first refusal, then at most once a day for the same limit. The TLS certificate is 14 days from expiry, and again at 3 days if it is still the same certificate. A message has waited in the queue for more than three days, with one more message when none is left. A global user is told all of it; a domain administrator is told about the DNS records and limits of their own domains.<br><br>Each user turns notifications on or off under Account and needs an e-mail there; Server › Settings turns them off for everyone. They are the panel's own mail: not rows of the Outbound log, and not counted against any limit.<br><br>For that mail to arrive, the server's own name needs two DNS records of its own, as each of your domains does: a DKIM key, which the panel generates and signs notifications with, and an SPF record, <code>v=spf1 a -all</code>. Server › Settings gives both with the values to publish and whether they are in place, and its Re-check DNS checks them again once they are published; Server › Health shows them too. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains does not depend on either.</p></div>
|
||||
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys and application credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. Both it and a domain's export ask for your account password - the one you sign in with - at every download, so a session left open is not enough to take them; every attempt, right or wrong, counts against the limit of a password change, ten in 15 minutes. The Postfix queue is not in it. The backup is taken while the server is running, and Postfix keeps moving messages through the queue, so a copy could hold a message twice or miss it; mail still waiting for delivery is therefore not carried over, and a restored server starts with an empty queue. To keep the queue, stop the container and archive the project directory instead. The private key in <code>certs/</code> has to be readable by the panel: give it to group 10998 on the host (<code>chgrp 10998</code>, <code>chmod 640</code>). Until then the panel refuses to make a backup and names the file, rather than hand over an archive without the key. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
|
||||
<div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out everywhere; set for yourself, it keeps the session you set it from.</p></div>
|
||||
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. The time zone is the container's, <code>TZ</code> in <code>.env</code> (UTC when it is not set): every time the panel shows and every day it counts is that zone's, as are the stamps Postfix writes in <code>mail.log</code>, so the two always agree; changing it is a restart of the container. The one exception is the day a DMARC report covers, which is the reporter's, counted in UTC. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. With the inbound relay on there is a second number, for the Inbound log, with the same range. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it. Notifications are turned on or off here for everyone.</p></div>
|
||||
<div class="sp-help-topic" id="notifications"><h3>Notifications</h3><p>The panel writes to its users when something needs them, from <code>postmaster@</code> the server's hostname. Four things are reported. A sending domain's SPF, DKIM or DMARC record stops matching: sent once two hourly checks in a row have found it so, and once more when the record matches again. A level-2 rate limit refuses mail: sent at the first refusal, then at most once a day for the same sending domain. The TLS certificate is 14 days from expiry, and again at 3 days if it is still the same certificate. A message has waited in the queue for more than three days, with one more message when none is left. A global user is told all of it; a domain administrator is told about the DNS records and limits of their own domains.<br><br>Each user turns notifications on or off under Account and needs an e-mail there; Server › Settings turns them off for everyone. They are the panel's own mail: not rows of the Outbound log, and not counted against any limit.<br><br>For that mail to arrive, the server's own name needs two DNS records of its own, as each of your domains does: a DKIM key, which the panel generates and signs notifications with, and an SPF record, <code>v=spf1 a -all</code>. Server › Settings gives both with the values to publish and whether they are in place, and its Re-check DNS checks them again once they are published; Server › Health shows them too. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains does not depend on either.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">05</span><h2>Account</h2></div>
|
||||
<div class="sp-help-topic" id="account"><h3>Your account</h3><p>Account is your own, whatever your role: your username and e-mail, whether the panel sends you notifications, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected.</p></div>
|
||||
<div class="sp-help-topic" id="account"><h3>Your account</h3><p>Account is your own, whatever your role: your username and e-mail, whether the panel sends you notifications, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected. A new e-mail is sent a link that confirms it, which works once, for 24 hours; changing the e-mail asks for your password. Notifications go to the address whether it is confirmed or not, but only a confirmed one can get a link to reset a forgotten password: <em>Forgot your password?</em> on the sign-in page sends it, for 30 minutes and once, and setting the new password signs every session of the account out. Without a confirmed e-mail the global administrator sets a new password under Users, and the only global administrator recovers from the container.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
dev@lists.example.org
|
||||
owner@lists.example.org
|
||||
security@lists.example.org</textarea></div>
|
||||
<p class="help">Unknown recipients are rejected at RCPT, so this relay generates no backscatter. Prefer a list unless the upstream itself rejects unknowns.</p></div>
|
||||
<p class="help">One per line or comma-separated. Unknown recipients are rejected at RCPT, so this relay generates no backscatter. Prefer a list unless the upstream itself rejects unknowns.</p></div>
|
||||
<button type="submit" class="button is-primary">Save recipients</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -88,10 +88,9 @@ security@lists.example.org</textarea></div>
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Spam filter</h2><div class="sp-end"><span class="tag is-success is-light">on</span></div><a class="sp-help" href="help.html#filter" title="A server setting"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Engine</dt><dd>rspamd, for every inbound domain of this server</dd></div>
|
||||
<div><dt>Suspicious mail</dt><dd>Held — <a href="in-quarantine.html">1 message in quarantine</a> for this domain</dd></div>
|
||||
<div><dt>Spam</dt><dd>Refused at the door; see <em>reject</em> in the <a href="in-log.html">log</a></dd></div>
|
||||
<div><dt>Allow and deny lists</dt><dd class="sp-muted">Kept by the global administrator, one pair for the whole server</dd></div>
|
||||
<div><dt>Applies to</dt><dd>Every inbound domain of this server</dd></div>
|
||||
<div><dt>Filter</dt><dd class="sp-mono">inet:antispam:11332</dd></div>
|
||||
<div><dt>If the filter is down</dt><dd>Mail is accepted unfiltered</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Waiting</h2><div class="sp-end">Refreshes every 10 s</div></div>
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Waiting</h2><div class="sp-end">Refreshes on its own</div></div>
|
||||
<div class="sp-empty"><i class="ti ti-stack-2"></i>Nothing is waiting. Every accepted message has reached its upstream.</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
<h1 class="title is-3">No administrator yet</h1>
|
||||
<p class="sp-muted">Open the one-time setup link printed in the container log to create one.</p>
|
||||
</div>
|
||||
<p class="sp-foot">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
|
||||
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<!-- title: Sign in | shell: bare -->
|
||||
<div class="columns is-gapless sp-signin">
|
||||
<div class="column is-half sp-signin-brand">
|
||||
<div>
|
||||
<img src="../../selfpost-stamp.svg" alt="SelfPost">
|
||||
<p>YOUR SMTP. YOUR RULES.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-half sp-signin-form">
|
||||
<form class="sp-form" method="post" action="overview.html">
|
||||
<p class="sp-kicker">mail.example.org</p>
|
||||
<h1 class="title is-3">Sign in</h1>
|
||||
<div class="notification is-success is-light"><i class="ti ti-circle-check"></i>If that account has a confirmed e-mail, the link is on its way. It works once, for 30 minutes.</div>
|
||||
<div class="field">
|
||||
<label class="label" for="username">Username</label>
|
||||
<div class="control has-icons-left"><input class="input" id="username" name="username" autocomplete="username" required><span class="icon is-left"><i class="ti ti-user"></i></span></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="password">Password</label>
|
||||
<div class="control has-icons-left"><input class="input" id="password" name="password" type="password" autocomplete="current-password" required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
|
||||
</div>
|
||||
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Sign in</button></div>
|
||||
<p class="mt-4"><a href="login-reset.html">Forgot your password?</a></p>
|
||||
</form>
|
||||
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
<!-- title: New password | shell: bare -->
|
||||
<div class="columns is-gapless sp-signin">
|
||||
<div class="column is-half sp-signin-brand">
|
||||
<div>
|
||||
<img src="../../selfpost-stamp.svg" alt="SelfPost">
|
||||
<p>YOUR SMTP. YOUR RULES.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-half sp-signin-form">
|
||||
<form class="sp-form" method="post" action="login.html">
|
||||
<p class="sp-kicker">mail.example.org · admin</p>
|
||||
<h1 class="title is-3">New password</h1>
|
||||
<p class="sp-muted mb-4">Setting it signs every session of this account out; sign in with it afterwards.</p>
|
||||
<div class="field">
|
||||
<label class="label" for="new_password">New password</label>
|
||||
<div class="control has-icons-left"><input class="input" id="new_password" name="new_password" type="password" autocomplete="new-password" required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="new_password_confirm">Repeat</label>
|
||||
<div class="control has-icons-left"><input class="input" id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password" required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
|
||||
</div>
|
||||
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Set password</button></div>
|
||||
</form>
|
||||
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<!-- title: Reset password | shell: bare -->
|
||||
<div class="columns is-gapless sp-signin">
|
||||
<div class="column is-half sp-signin-brand">
|
||||
<div>
|
||||
<img src="../../selfpost-stamp.svg" alt="SelfPost">
|
||||
<p>YOUR SMTP. YOUR RULES.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-half sp-signin-form">
|
||||
<form class="sp-form" method="post" action="login-reset-sent.html">
|
||||
<p class="sp-kicker">mail.example.org</p>
|
||||
<h1 class="title is-3">Reset password</h1>
|
||||
<p class="sp-muted mb-4">A link to set a new password goes to the e-mail of the account, once that address is confirmed under Account. It works once, for 30 minutes.</p>
|
||||
<div class="field">
|
||||
<label class="label" for="username">Username</label>
|
||||
<div class="control has-icons-left"><input class="input" id="username" name="username" autocomplete="username" required><span class="icon is-left"><i class="ti ti-user"></i></span></div>
|
||||
</div>
|
||||
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Send the link</button></div>
|
||||
<p class="mt-4"><a href="login.html">Back to sign in</a></p>
|
||||
</form>
|
||||
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -19,7 +19,8 @@
|
||||
<div class="control has-icons-left"><input class="input" id="password" name="password" type="password" autocomplete="current-password" required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
|
||||
</div>
|
||||
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Sign in</button></div>
|
||||
<p class="mt-4"><a href="login-reset.html">Forgot your password?</a></p>
|
||||
</form>
|
||||
<p class="sp-foot">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
|
||||
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker"><a href="out-domains.html">Outbound domains</a> / <a href="out-domain.html">example.org</a> / Applications /</p>
|
||||
<h1 class="title is-3 sp-mono">prod-server</h1>
|
||||
<p class="sp-lead">1 102 messages in 30 days · peak 96 msg/h · average 1.5 msg/h. The same form, empty, adds a new application.</p>
|
||||
<p class="sp-lead">1 102 messages in 30 days · peak 96 msg/h · average 1.5 msg/h.</p>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<a class="button" href="out-app-created.html"><i class="ti ti-key"></i>New password</a>
|
||||
@@ -19,7 +19,7 @@
|
||||
<div class="sp-box-body">
|
||||
<div class="field"><label class="label" for="login">Login</label>
|
||||
<div class="control"><input class="input sp-mono" id="login" name="login" value="prod-server" autocomplete="off" spellcheck="false" required></div>
|
||||
<p class="help">Unique across domains; letters, digits, <code>.</code> <code>-</code> <code>_</code>.</p></div>
|
||||
<p class="help">Fixed: add a new application to use another login.</p></div>
|
||||
<div class="field"><label class="label" for="amode">May send as</label>
|
||||
<div class="control"><div class="select is-fullwidth"><select id="amode" name="mode"><option selected>Any address of the domain</option><option>Specific addresses (list)</option></select></div></div></div>
|
||||
<div class="field"><label class="label" for="addresses">Addresses</label>
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
<form class="sp-form" method="post" action="#">
|
||||
<div class="field"><label class="label" for="mode">Limit mode</label>
|
||||
<div class="control"><div class="select is-fullwidth"><select id="mode" name="mode"><option>Manual</option><option selected>Auto (from statistics)</option></select></div></div></div>
|
||||
<div class="field"><label class="label" for="mult">Multiplier (1.5–10, default 3)</label>
|
||||
<div class="field"><label class="label" for="mult">Multiplier (1.5–5, default 2.5)</label>
|
||||
<div class="control"><input class="input" id="mult" name="auto_multiplier" type="number" min="1.5" max="10" step="0.1" value="3"></div>
|
||||
<p class="help">Computed limit: <strong>288</strong> messages / 3600 s. Peak was 96 msg/h. Last recalculated yesterday 03:00.</p></div>
|
||||
<div class="buttons">
|
||||
|
||||
@@ -28,9 +28,9 @@
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>History</h2><a class="sp-help" href="help.html#log" title="What the statuses mean"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<ol class="sp-timeline">
|
||||
<li><time>13:58:07</time><strong>Accepted</strong> from <span class="sp-mono">alerts</span> at 198.51.100.7</li>
|
||||
<li class="sp-warn"><time>13:58:09</time><strong>Deferred</strong> — the receiving server asked to retry later</li>
|
||||
<li class="sp-pending"><time>next attempt ≈ 14:08</time>Still in the <a href="out-queue.html">queue</a>; kept for 5 days, then bounced</li>
|
||||
<li><time>2026-09-21 13:58:07</time><strong>Accepted and queued</strong> — Postfix accepted the message over an authenticated submission and the journal-milter recorded it.</li>
|
||||
<li class="sp-warn"><time>2026-09-21 13:58:09</time><strong>Deferred, will be retried</strong> — the receiving server could not take the message yet. Postfix retries: first after 5 minutes, then with increasing gaps up to 1 hour 7 minutes, for up to 5 days.</li>
|
||||
<li class="sp-pending"><time>not yet</time>Still in the queue; the next attempt is Postfix's own, and after 5 days it is bounced.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker">Outbound</p>
|
||||
<h1 class="title is-3">Outbound queue</h1>
|
||||
<p class="sp-lead">Messages accepted from applications and still waiting for the receiving server. Refreshes every 10 s.</p>
|
||||
<p class="sp-lead">Messages accepted from applications and still waiting for the receiving server. Refreshes on its own.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker">mail.example.org</p>
|
||||
<h1 class="title is-3">Mail is flowing. One thing needs attention.</h1>
|
||||
<p class="sp-lead">The TLS certificate expires in 12 days and has not renewed yet.</p>
|
||||
<p class="sp-lead"><a href="health.html">TLS certificate</a>: expires in 12 days.</p>
|
||||
</div>
|
||||
<div class="sp-muted sp-small">Updated 14:02:31<br>refreshes every 10 s</div>
|
||||
<div class="sp-muted sp-small">Updated 14:02:31<br>refreshes on its own</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
@@ -16,7 +16,7 @@
|
||||
<a href="health.html"><span class="sp-h-icon"><i class="ti ti-server-cog"></i></span><span><span class="sp-h-name">Processes</span><span class="sp-h-value">5 of 5 running</span><span class="sp-h-sub">up 18 days</span></span></a>
|
||||
<a href="health.html" class="sp-warn"><span class="sp-h-icon"><i class="ti ti-certificate"></i></span><span><span class="sp-h-name">TLS certificate</span><span class="sp-h-value">Expires in 12 days</span><span class="sp-h-sub">renewal has not run</span></span></a>
|
||||
<a href="out-queue.html"><span class="sp-h-icon"><i class="ti ti-stack-2"></i></span><span><span class="sp-h-name">Queues</span><span class="sp-h-value">Both empty</span><span class="sp-h-sub">outbound and inbound</span></span></a>
|
||||
<a href="health.html"><span class="sp-h-icon"><i class="ti ti-plug-connected"></i></span><span><span class="sp-h-name">Milter sockets</span><span class="sp-h-value">5 of 5 answering</span><span class="sp-h-sub">incl. the spam filter</span></span></a>
|
||||
<a href="health.html"><span class="sp-h-icon"><i class="ti ti-plug-connected"></i></span><span><span class="sp-h-name">Milter sockets</span><span class="sp-h-value">6 of 6 answering</span><span class="sp-h-sub">incl. the spam filter</span></span></a>
|
||||
<a href="health.html"><span class="sp-h-icon"><i class="ti ti-arrows-exchange"></i></span><span><span class="sp-h-name">Reverse DNS</span><span class="sp-h-value">Forward = reverse</span><span class="sp-h-sub">203.0.113.25</span></span></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Zone</dt><dd class="sp-mono">Europe/Moscow</dd><dd class="sp-muted">UTC+03:00. Set in <code>.env</code> (<code>TZ</code>); restart to change.</dd></div>
|
||||
</dl>
|
||||
<p class="mt-4 sp-muted">Every time the panel shows, and every day it counts - today on Health, the days of the statistics - is this zone's, and so are the stamps of <code>mail.log</code>.</p>
|
||||
<p class="mt-4 sp-muted">Every time the panel shows, and every day it counts - today on Health, the days of the statistics - is this zone's, and so are the stamps of <code>mail.log</code>. One exception: the day a DMARC report covers is the reporter's, in UTC.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,6 @@
|
||||
</div>
|
||||
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Create administrator</button></div>
|
||||
</form>
|
||||
<p class="sp-foot">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
|
||||
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker">Server</p>
|
||||
<h1 class="title is-3">System log</h1>
|
||||
<p class="sp-lead">The tail of <code>mail.log</code>, the log Postfix writes. For one message, open it from a log page instead.</p>
|
||||
<p class="sp-lead">The tail of <code>mail.log</code>, the log Postfix writes. For one message, open it from the <a href="out-log.html">Outbound log</a> instead.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Recent lines</h2><div class="sp-end">Last 200 · refreshes every 10 s</div></div>
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Recent lines</h2><div class="sp-end">Last 200 lines · refreshes on its own</div></div>
|
||||
<pre class="sp-log"><span class="sp-ll"><span class="sp-d">Sep 21 14:01:52</span> postfix/smtp[2131]: 4XcB9q0Lw2z1: to=<m.keller@gmx.de>, relay=mx00.gmx.net[212.227.15.9]:25, delay=0.8, status=sent (250 Requested mail action okay)
|
||||
</span><span class="sp-ll"><span class="sp-d">Sep 21 14:01:52</span> postfix/qmgr[58]: 4XcB9q0Lw2z1: removed
|
||||
</span><span class="sp-ll"><span class="sp-d">Sep 21 14:00:18</span> postfix/smtpd[2129]: connect from mail.uni.example[192.0.2.77]
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
<p class="mb-3">Deleting the panel user <strong>acme-ops</strong> will:</p>
|
||||
<ul class="sp-list-check">
|
||||
<li><i class="ti ti-x"></i>end any signed-in session of this user at once;</li>
|
||||
<li><i class="ti ti-x"></i>remove the assignments to <strong>all outbound domains</strong> and to <strong>acme.io</strong> (inbound).</li>
|
||||
<li><i class="ti ti-x"></i>remove the assignments to <strong>all outbound domains</strong> (outbound) and to <strong>acme.io</strong> (inbound).</li>
|
||||
</ul>
|
||||
<p class="sp-muted mb-4">The domains, their applications and passwords, upstreams and recipients are not affected.</p>
|
||||
<p class="sp-muted mb-4">Applications and their passwords, upstreams and recipients are not affected.</p>
|
||||
<form method="post" action="users.html">
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button is-danger"><i class="ti ti-trash"></i>Delete acme-ops</button>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker"><a href="users.html">Users</a> /</p>
|
||||
<h1 class="title is-3">acme-ops</h1>
|
||||
<p class="sp-lead">The same form, empty, creates a new user.</p>
|
||||
</div>
|
||||
<a class="button is-danger is-light" href="user-delete.html"><i class="ti ti-trash"></i>Delete user</a>
|
||||
</div>
|
||||
@@ -15,8 +14,8 @@
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Account</h2><a class="sp-help" href="help.html#users" title="Roles and domain assignments"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<div class="field"><label class="label" for="username">Username</label><div class="control"><input class="input" id="username" name="username" value="acme-ops" autocomplete="username" spellcheck="false" required></div></div>
|
||||
<div class="field"><label class="label" for="email">E-mail</label><div class="control"><input class="input" id="email" name="email" type="email" value="ops@acme.io" autocomplete="off"></div>
|
||||
<p class="help">For notifications and a password-reset link. The user can change it under Account.</p></div>
|
||||
<div class="field"><label class="label" for="email">E-mail <span class="tag is-success is-light">confirmed</span></label><div class="control"><input class="input" id="email" name="email" type="email" value="ops@acme.io" autocomplete="off"></div>
|
||||
<p class="help">The user's own address. They can change it under Account. A new one is sent a link to confirm it; until it is confirmed no reset link goes to it.</p></div>
|
||||
<div class="field"><label class="label" for="password">Password</label><div class="control"><input class="input" id="password" name="password" type="password" autocomplete="new-password"></div>
|
||||
<p class="help">Leave empty to keep the current one.</p></div>
|
||||
<div class="field"><label class="label" for="role">Role</label>
|
||||
@@ -33,7 +32,7 @@
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids" checked disabled> example.org</label></div>
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids" checked disabled> shop.example.org</label></div>
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids" checked disabled> notify.acme.io</label></div>
|
||||
<p class="help">With <em>All</em> ticked the list below it is ignored — and greyed out where scripts run.</p>
|
||||
<p class="help">With <em>All</em> ticked the single domains are ignored.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box">
|
||||
|
||||
@@ -69,12 +69,12 @@
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker">Server</p>
|
||||
<h1 class="title is-3">System log</h1>
|
||||
<p class="sp-lead">The tail of <code>mail.log</code>, the log Postfix writes. For one message, open it from a log page instead.</p>
|
||||
<p class="sp-lead">The tail of <code>mail.log</code>, the log Postfix writes. For one message, open it from the <a href="out-log.html">Outbound log</a> instead.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Recent lines</h2><div class="sp-end">Last 200 · refreshes every 10 s</div></div>
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Recent lines</h2><div class="sp-end">Last 200 lines · refreshes on its own</div></div>
|
||||
<pre class="sp-log"><span class="sp-ll"><span class="sp-d">Sep 21 14:01:52</span> postfix/smtp[2131]: 4XcB9q0Lw2z1: to=<m.keller@gmx.de>, relay=mx00.gmx.net[212.227.15.9]:25, delay=0.8, status=sent (250 Requested mail action okay)
|
||||
</span><span class="sp-ll"><span class="sp-d">Sep 21 14:01:52</span> postfix/qmgr[58]: 4XcB9q0Lw2z1: removed
|
||||
</span><span class="sp-ll"><span class="sp-d">Sep 21 14:00:18</span> postfix/smtpd[2129]: connect from mail.uni.example[192.0.2.77]
|
||||
@@ -87,7 +87,7 @@
|
||||
</span></pre>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -224,6 +224,12 @@ code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; pad
|
||||
/* A label may carry a tag, the verdict on its value (the certificate's
|
||||
names against the hostname); it keeps the tag's own type. */
|
||||
.sp-facts dt .tag { margin-left: .4rem; text-transform: none; letter-spacing: 0; vertical-align: 1px; }
|
||||
/* A tag beside a label's text (a field's or a fact's) is no taller than the
|
||||
text's line: Bulma's tag is 2em high, which made the line taller and left
|
||||
less room under the label than under one without a tag (an e-mail's
|
||||
"not confirmed" on Account, the owner's phone, 2026-10-17). */
|
||||
.label .tag, .sp-facts dt .tag { height: auto; padding-top: 0; padding-bottom: 0; line-height: 1.5; }
|
||||
.label .tag { margin-left: .25rem; vertical-align: 1px; }
|
||||
/* A fact that takes the whole row: a line a server wrote, read as one. */
|
||||
.sp-facts .sp-wide { grid-column: 1 / -1; }
|
||||
.sp-facts dd.sp-big { font-size: 1.5rem; font-weight: 600; line-height: 1.2; }
|
||||
|
||||
@@ -80,9 +80,9 @@
|
||||
<p class="mb-3">Deleting the panel user <strong>acme-ops</strong> will:</p>
|
||||
<ul class="sp-list-check">
|
||||
<li><i class="ti ti-x"></i>end any signed-in session of this user at once;</li>
|
||||
<li><i class="ti ti-x"></i>remove the assignments to <strong>all outbound domains</strong> and to <strong>acme.io</strong> (inbound).</li>
|
||||
<li><i class="ti ti-x"></i>remove the assignments to <strong>all outbound domains</strong> (outbound) and to <strong>acme.io</strong> (inbound).</li>
|
||||
</ul>
|
||||
<p class="sp-muted mb-4">The domains, their applications and passwords, upstreams and recipients are not affected.</p>
|
||||
<p class="sp-muted mb-4">Applications and their passwords, upstreams and recipients are not affected.</p>
|
||||
<form method="post" action="users.html">
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button is-danger"><i class="ti ti-trash"></i>Delete acme-ops</button>
|
||||
@@ -94,7 +94,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -69,7 +69,6 @@
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker"><a href="users.html">Users</a> /</p>
|
||||
<h1 class="title is-3">acme-ops</h1>
|
||||
<p class="sp-lead">The same form, empty, creates a new user.</p>
|
||||
</div>
|
||||
<a class="button is-danger is-light" href="user-delete.html"><i class="ti ti-trash"></i>Delete user</a>
|
||||
</div>
|
||||
@@ -81,8 +80,8 @@
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Account</h2><a class="sp-help" href="help.html#users" title="Roles and domain assignments"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<div class="field"><label class="label" for="username">Username</label><div class="control"><input class="input" id="username" name="username" value="acme-ops" autocomplete="username" spellcheck="false" required></div></div>
|
||||
<div class="field"><label class="label" for="email">E-mail</label><div class="control"><input class="input" id="email" name="email" type="email" value="ops@acme.io" autocomplete="off"></div>
|
||||
<p class="help">For notifications and a password-reset link. The user can change it under Account.</p></div>
|
||||
<div class="field"><label class="label" for="email">E-mail <span class="tag is-success is-light">confirmed</span></label><div class="control"><input class="input" id="email" name="email" type="email" value="ops@acme.io" autocomplete="off"></div>
|
||||
<p class="help">The user's own address. They can change it under Account. A new one is sent a link to confirm it; until it is confirmed no reset link goes to it.</p></div>
|
||||
<div class="field"><label class="label" for="password">Password</label><div class="control"><input class="input" id="password" name="password" type="password" autocomplete="new-password"></div>
|
||||
<p class="help">Leave empty to keep the current one.</p></div>
|
||||
<div class="field"><label class="label" for="role">Role</label>
|
||||
@@ -99,7 +98,7 @@
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids" checked disabled> example.org</label></div>
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids" checked disabled> shop.example.org</label></div>
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids" checked disabled> notify.acme.io</label></div>
|
||||
<p class="help">With <em>All</em> ticked the list below it is ignored — and greyed out where scripts run.</p>
|
||||
<p class="help">With <em>All</em> ticked the single domains are ignored.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box">
|
||||
@@ -119,7 +118,7 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
</table></div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
+11
-5
@@ -107,7 +107,7 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
|
||||
2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see
|
||||
[§ Release image](#release-image)).
|
||||
|
||||
**Planned version cuts** (from pin `2.13.0`; adjust if semver changes mid-track):
|
||||
**Planned version cuts** (from pin `2.14.3`; adjust if semver changes mid-track):
|
||||
|
||||
| Stage | ID | Cut |
|
||||
|---|---|---|
|
||||
@@ -150,6 +150,11 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
|
||||
| 37 | health-setup-checks | `2.11.0` |
|
||||
| 38 | test-email | `2.12.0` |
|
||||
| 39 | local-time | `2.13.0` |
|
||||
| 40 | docs-audit | `2.13.1` |
|
||||
| 41 | password-reset | `2.14.0` |
|
||||
| 42 | overview-head | `2.14.1` |
|
||||
| 43 | dmarc-day, health-sockets | `2.14.2` |
|
||||
| 44 | label-tag | `2.14.3` |
|
||||
|
||||
Docs-only prep (checklists in plans, no product code) uses the same per-step
|
||||
commit rule but **no** version cut until the next product stage ships.
|
||||
@@ -483,7 +488,8 @@ make e2e
|
||||
**Stack:** [deploy/docker-compose.yml](../deploy/docker-compose.yml) +
|
||||
[test/e2e/compose.override.yml](../test/e2e/compose.override.yml) — same
|
||||
`cap_drop`/`cap_add`/`no-new-privileges` as production. Override: high ports
|
||||
(`20465`/`20587`/`20080`), test hostname, self-signed TLS,
|
||||
(`20465`/`20587`/`20080`, port 25 at `20025`, the stand's CoreDNS at
|
||||
`20053/udp`), test hostname, self-signed TLS,
|
||||
`PANEL_COOKIE_SECURE=false`, isolated compose project, and the image's
|
||||
`VERSION` from `SELFPOST_E2E_VERSION` (unset locally: `e2e`). Mail is hermetic:
|
||||
CoreDNS (fake zone) + Postfix `smtp-sink` as sink-MX; DKIM TXT is scraped from
|
||||
@@ -536,10 +542,10 @@ Workflows in [.github/workflows/](../.github/workflows/). What each job runs —
|
||||
### `test.yml` — every push and PR to `main`
|
||||
|
||||
`design-first` (needs the full history) → `gofmt -l` → `go vet ./...` →
|
||||
`go test ./...` (main module, no e2e; includes the panel guard tests, see
|
||||
Staticcheck 2026.1 → `go test ./...` (main module, no e2e; includes the panel guard tests, see
|
||||
[§ Panel pages](#panel-pages-the-component-kit-and-its-guards)) → the e2e
|
||||
module compiled, not run (`go vet ./...` and `go test -c -o /dev/null .` in
|
||||
`test/e2e`).
|
||||
module compiled, not run (`go vet ./...`, Staticcheck and
|
||||
`go test -c -o /dev/null .` in `test/e2e`).
|
||||
|
||||
### `release.yml` — published GitHub Release, or `workflow_dispatch` with SemVer
|
||||
|
||||
|
||||
+101
-46
@@ -84,8 +84,9 @@ Since `2.7.0` a message sent on 465 or 587 does not tell its recipients
|
||||
where the application runs: the `Received:` header SelfPost adds reads
|
||||
`Received: by mail.example.com (Postfix) id 4AbC12xyz for <...>; <date>`,
|
||||
without the application's address, host name or login. To trace a message
|
||||
someone complains about, take that queue id to *Outbound › Log*; mail.log
|
||||
keeps the client address on smtpd's `client=` line for the same id.
|
||||
someone complains about, find its row in *Outbound › Log* (by domain,
|
||||
application and day; the message's page shows its queue id); mail.log keeps
|
||||
the client address on smtpd's `client=` line for the same id.
|
||||
|
||||
### Local trial
|
||||
|
||||
@@ -204,14 +205,14 @@ cat ./data/setup-token
|
||||
#### Fixed image tag
|
||||
|
||||
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
|
||||
deliberately never `:latest`. The current pin is `2.13.0`. **No 2.x image is
|
||||
deliberately never `:latest`. The current pin is `2.14.3`. **No 2.x image is
|
||||
published on ghcr.io yet** (the newest there is `1.3.0`); until one is, build
|
||||
it from the checkout under the pinned name, and Compose uses the local image
|
||||
instead of pulling:
|
||||
|
||||
```sh
|
||||
docker build -f build/Dockerfile --build-arg VERSION=2.13.0 \
|
||||
-t ghcr.io/mixeme/selfpost:2.13.0 .
|
||||
docker build -f build/Dockerfile --build-arg VERSION=2.14.3 \
|
||||
-t ghcr.io/mixeme/selfpost:2.14.3 .
|
||||
```
|
||||
|
||||
`VERSION` must equal the tag: it is the version the backup check compares
|
||||
@@ -250,7 +251,12 @@ expected to set; defaults match the code exactly.
|
||||
| `PANEL_SESSION_IDLE_DAYS` | Sliding idle timeout for the panel login session, in days. There is no absolute cap: a user who keeps coming back stays signed in indefinitely. | `7` | `.env` |
|
||||
| `SELFPOST_DNS_RESOLVERS` | Comma-separated recursive resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly (so they report what the internet sees, not what this host's stub resolver synthesises). Each is given 2 seconds to answer before the next is asked, and the one that answered last is asked first; a lookup none of them answered shows as not known, never as a wrong record. | `1.1.1.1:53`, `8.8.8.8:53`, `9.9.9.9:53` when unset | `.env` |
|
||||
| `TZ` | Time zone of the container, an IANA name such as `Europe/Moscow`: every time the panel shows, every day it counts (today on Health, the days of the statistics, the days of the log filters) and the stamps Postfix writes in `mail.log`, so the two agree. Stored times stay UTC, so changing it is only a restart. *Server › Settings* shows the zone in use, and says so when the image does not know the name (the times are then UTC). | UTC | `.env` |
|
||||
| `TRUSTED_PROXY_CIDR` | Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to supply `X-Forwarded-For` for login, setup, and account-change rate-limiting. **Leave unset unless you know the exact address of your reverse proxy.** A wrong value lets a client spoof its rate-limit key by sending a forged `X-Forwarded-For` header — the panel trusts the last hop only when the TCP peer matches one of these CIDRs. With Apache on the host (the default), the panel sees the proxy's connections come from the Docker network's gateway, e.g. `172.18.0.1` (`docker network inspect` shows it; measured 2026-10-15: the panel's `127.0.0.1:8080` is published through Docker's proxy, and a connection to such a port from the host arrives from the gateway). | *(empty — XFF ignored)* | `.env` |
|
||||
| `TRUSTED_PROXY_CIDR` | Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to supply `X-Forwarded-For` for sign-in and setup rate-limiting (account-change attempts are counted per user, whatever the address). **Leave unset unless you know the exact address of your reverse proxy.** A wrong value lets a client spoof its rate-limit key by sending a forged `X-Forwarded-For` header — the panel trusts the last hop only when the TCP peer matches one of these CIDRs. With Apache on the host (the default), the panel sees the proxy's connections come from the Docker network's gateway, e.g. `172.18.0.1` (`docker network inspect` shows it; measured 2026-10-15: the panel's `127.0.0.1:8080` is published through Docker's proxy, and a connection to such a port from the host arrives from the gateway). | *(empty — XFF ignored)* | `.env` |
|
||||
|
||||
`PANEL_COOKIE_SECURE` is not in the table: the session cookie is `Secure`
|
||||
unless it is `false`, which only a trial over plain HTTP wants, and the
|
||||
compose file does not pass it - the local trial in the README sets it on
|
||||
`docker run` (`-e PANEL_COOKIE_SECURE=false`).
|
||||
|
||||
TLS certificate paths (`TLS_CERT_FILE`, `TLS_KEY_FILE`) are fixed in
|
||||
[deploy/docker-compose.yml](../deploy/docker-compose.yml) to match the `./certs`
|
||||
@@ -422,7 +428,7 @@ changes.
|
||||
|---|---|---|
|
||||
| Overview | Overview | `/overview` |
|
||||
| Outbound | Domains · Log · Queue · DMARC reports | `/outbound/domains`, `/outbound/log`, `/outbound/queue`, `/outbound/dmarc` |
|
||||
| Inbound | Domains | `/inbound/domains` |
|
||||
| Inbound | Domains · Log · Filter lists (global role) | `/inbound/domains`, `/inbound/log`, `/inbound/filter-lists` |
|
||||
| Server | Health · System log · Backup · Users · Settings | `/server/health`, `/server/log`, `/server/backup`, `/server/users`, `/server/settings` |
|
||||
| User menu (your name) | Account · Help · Sign out | `/account`, `/help`, `POST /logout` |
|
||||
|
||||
@@ -433,6 +439,11 @@ menu: a group with nothing in it is absent. *Inbound* appears only with
|
||||
queue, answer 404 to a user with the *domain* role — the same answer a missing
|
||||
page gets.
|
||||
|
||||
Two pages are outside the menu: `/license`, the AGPL-3.0 text the footer links
|
||||
to, open to anyone; and `/server/components` (global role), the panel's
|
||||
component kit - every building block of its pages on one page, a reference
|
||||
for whoever works on the panel.
|
||||
|
||||
Pages that used to be one are now several. The old Status page is **Overview**
|
||||
(one verdict per check) and **Server › Health** (the tables behind them). The
|
||||
old Settings page is **Account** (your own name, e-mail and password) and
|
||||
@@ -454,28 +465,21 @@ assumptions stay here in `docs/guide.md`.
|
||||
### Overview and Health
|
||||
|
||||
**Overview** (`/overview`, global role) shows the stamp — the worst of its
|
||||
checks — and one card per check: machine (processor and memory), supervised
|
||||
checks — with a sentence beside it ("Mail is flowing. One thing needs
|
||||
attention.", "Mail may not be flowing." when a check fails) and, under it,
|
||||
each check that is not fine with its value; then one card per check: machine (processor and memory), supervised
|
||||
processes (Postfix, the panel - whose roles include the DKIM signer - and
|
||||
their helpers), TLS certificate, queue, milter sockets and reverse DNS. A
|
||||
check the panel could not run shows as a warning, never as fine. Below the cards are the outbound domain list and, with the inbound relay
|
||||
on, the inbound one. Each card leads to its table on **Server › Health**
|
||||
(`/server/health`) — the queue card to the Outbound queue — and Health has the
|
||||
readings, in the order of what stops mail first:
|
||||
readings, numbered in the order of what stops mail first:
|
||||
|
||||
- **Machine** — processor (core and thread counts), memory and swap of the
|
||||
host (a memory limit set on the container is not reflected), and
|
||||
per-interface network throughput and totals of the container's own network
|
||||
interfaces, read from the kernel's counters; CPU and throughput are measured between refreshes, so they appear
|
||||
one refresh after the page opens. A fully busy processor or a machine out of
|
||||
memory is a warning, because both delay or kill the mail path; throughput is
|
||||
only reported.
|
||||
- **Processes**, **TLS certificate** (validity and expiry, and whether its
|
||||
names cover `SELFPOST_HOSTNAME` - the tag beside *Names*; a certificate for
|
||||
another name fails, as a client that checks the name refuses it) and
|
||||
**Milter sockets**: the DKIM signer's two - `dkim-signer · applications` for what
|
||||
- **Processes** and **Milter sockets**: the DKIM signer's two - `dkim-signer · applications` for what
|
||||
applications send on 465/587, `dkim-signer · notifications` for the
|
||||
panel's own mail - the sender check, the send log (the journal-milter)
|
||||
and, with one configured, the spam filter. Postfix attaches the signer
|
||||
panel's own mail - the sender check, the send log (the journal-milter),
|
||||
with the inbound relay on the Inbound log's (the inbound-journal-milter,
|
||||
fail-open like the send log's), and, with one configured, the spam filter. Postfix attaches the signer
|
||||
strictly, so either of its rows down means the mail that goes through it
|
||||
waits.
|
||||
- **Sender check** — what the strict check of the `From:` line has cost
|
||||
@@ -493,12 +497,24 @@ readings, in the order of what stops mail first:
|
||||
a connection was turned away or a message refused in the last 24 hours. If these numbers do not stay
|
||||
at nothing, the panel is in the way of your mail more than it should be -
|
||||
report it.
|
||||
- **TLS certificate** — validity and expiry, and whether its names cover
|
||||
`SELFPOST_HOSTNAME` (the tag beside *Names*); a certificate for another
|
||||
name fails, as a client that checks the name refuses it.
|
||||
- **Hostname and reverse DNS** — compares `SELFPOST_HOSTNAME` against the PTR
|
||||
record the internet publishes for this server's IP (forward-confirmed
|
||||
reverse DNS), see [Server-level DNS](#server-level-dns-ptrrdns); use
|
||||
*Re-check DNS* after changing DNS. *Greeting* is the `220` line Postfix
|
||||
opens every connection with; it must name the hostname, and another name
|
||||
there is a warning.
|
||||
- **Notification mail** — whether the DNS records the panel's own mail is
|
||||
signed and authorised with are in place (see [Notifications](#notifications)).
|
||||
- **Machine** — processor (core and thread counts), memory and swap of the
|
||||
host (a memory limit set on the container is not reflected), and
|
||||
per-interface network throughput and totals of the container's own network
|
||||
interfaces, read from the kernel's counters; CPU and throughput are measured between refreshes, so they appear
|
||||
one refresh after the page opens. A fully busy processor or a machine out of
|
||||
memory is a warning, because both delay or kill the mail path; throughput is
|
||||
only reported.
|
||||
- **Your address** (in the Configuration card) — the address the panel sees
|
||||
your browser at, and whether it took it from `X-Forwarded-For`. Behind a
|
||||
reverse proxy that the panel does not trust it is the proxy's own: every
|
||||
@@ -555,14 +571,36 @@ readings, in the order of what stops mail first:
|
||||
### Account
|
||||
|
||||
**Account** (`/account`, every role) is the signed-in user's own: **Profile**
|
||||
(username and e-mail) and **Password**. Saving the profile does not ask for the
|
||||
password; changing the password asks for the current one and is rate-limited
|
||||
like a sign-in. The e-mail is the user's own address: it is where the panel
|
||||
(username and e-mail) and **Password**. Changing the username or the
|
||||
notifications switch does not ask for the password; changing the e-mail does,
|
||||
and so does changing the password, both on the limit of a sign-in's guesses
|
||||
(ten in 15 minutes per user). The e-mail is the user's own address: it is where the panel
|
||||
sends [notifications](#notifications) while *Send me notifications* is ticked
|
||||
beside it, and what a domain's *Use my e-mail* button fills in (see
|
||||
[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc)). Application SASL logins
|
||||
are separate and are not changed here.
|
||||
|
||||
**A confirmed e-mail and a forgotten password.** A new address - saved under
|
||||
Account, or typed for a user by the global role on the user form - is sent a
|
||||
link that confirms it (`https://<SELFPOST_HOSTNAME>/confirm/…`), which works
|
||||
once, for 24 hours, opened in a browser signed in as that account; its page
|
||||
has a button, and only the button confirms. The mail does not name the
|
||||
account, so a mistyped address cannot be confirmed by whoever reads it.
|
||||
Account tags the address *confirmed* or *not confirmed* and, while it is not,
|
||||
offers *Send the link again* (at most five links an hour to one address).
|
||||
Notifications go to the address either way. Only a confirmed address can be
|
||||
sent a reset link: *Forgot your password?* on the sign-in page asks for the
|
||||
username and always answers the same - whether the name exists, and whether
|
||||
its address is confirmed, is not said; another link is not sent within ten
|
||||
minutes of the last - and the link
|
||||
(`https://<SELFPOST_HOSTNAME>/login/reset/…`) works once, for 30 minutes, and
|
||||
only while the account's address is still the one it was sent to. Setting the
|
||||
new password signs every session of the account out. The links name the
|
||||
server by `SELFPOST_HOSTNAME`, so the panel must be served at that name. An
|
||||
account without a confirmed address recovers as before: the global role sets
|
||||
a new password on the user form, and the only global user from the container
|
||||
(see [Users](#users)).
|
||||
|
||||
### Settings
|
||||
|
||||
**Server › Settings** (`/server/settings`, global role) holds what is true for
|
||||
@@ -586,7 +624,10 @@ the `mail.log` lines on Server › System log and on a message's page read the
|
||||
same clock as the rest of the page. The database keeps its times in UTC, so
|
||||
changing the zone is a restart and nothing more; the days counted before it
|
||||
stay days of the old zone, a shift of a few hours in the statistics. A name
|
||||
the image does not know leaves everything in UTC, and the box says so.
|
||||
the image does not know leaves everything in UTC, and the box says so. One
|
||||
thing keeps UTC on purpose: the day a DMARC report covers is its reporter's,
|
||||
who counts days in UTC, so the lists and a report's title name that day; the
|
||||
report's page gives its window's times in this zone and says which day it is.
|
||||
|
||||
### Notifications
|
||||
|
||||
@@ -606,7 +647,9 @@ assigned to them. A message goes to the e-mail on the user's
|
||||
[Account](#account) page and only while *Send me notifications* is ticked
|
||||
there; a user without an e-mail gets none. **Server › Settings** has one switch
|
||||
that turns notifications off for everyone. With it off nothing is checked, so
|
||||
what is wrong when it is turned on again is reported then.
|
||||
what is wrong when it is turned on again is reported then. The first check runs
|
||||
three minutes after the panel starts, then every hour; the panel sends at
|
||||
most 60 notifications an hour, a ceiling nothing should come near.
|
||||
|
||||
Notifications are the panel's own mail. They are sent from
|
||||
`postmaster@<SELFPOST_HOSTNAME>`, one message per recipient, are not rows of
|
||||
@@ -662,7 +705,8 @@ What the *domain* role reaches inside what is assigned:
|
||||
| Overview, Server, the Outbound queue | yes | no (404) |
|
||||
| Outbound: domain page, applications, Domain settings, log | every domain | assigned domains |
|
||||
| Outbound › DMARC reports | every report, with the ingest statistics | reports of assigned domains, without the ingest box |
|
||||
| Inbound: domain page, upstream, recipients | every domain | assigned inbound domains |
|
||||
| Inbound: domain page, upstream, recipients, log | every domain | assigned inbound domains |
|
||||
| Inbound › Filter lists | yes | no (404) |
|
||||
| Add or delete a domain, either direction | yes | no |
|
||||
| Export a domain (Domain settings) | yes | assigned domains |
|
||||
| Import a domain (Server › Backup) | yes | no |
|
||||
@@ -983,7 +1027,7 @@ A version mismatch at step 5 refuses to start and leaves `/data` untouched —
|
||||
the panel exits with a message naming the tag to use, e.g.:
|
||||
|
||||
```
|
||||
backup: this backup was created by SelfPost 2.12.0 but this image is 2.13.0 — restore into the matching image (selfpost:2.12.0)
|
||||
backup: this backup was created by SelfPost 2.14.2 but this image is 2.14.3 — restore into the matching image (selfpost:2.14.2)
|
||||
```
|
||||
|
||||
Fix the tag in `docker-compose.yml` (build that tag as under [Fixed image
|
||||
@@ -1112,7 +1156,10 @@ The CLI also *writes* encrypted backups for scripted/cron use. The password
|
||||
must be at least 12 characters, as on the panel's forms (decrypting takes
|
||||
any), and it comes from `SELFPOST_BACKUP_PASSWORD` or `-password-file <path>`
|
||||
(first line), never from a command-line argument, which would be visible in
|
||||
the process list.
|
||||
the process list. Its other flags: `-o <file>` writes the output to a file
|
||||
instead of standard output, `-i <file>` reads the encrypted archive from a
|
||||
file instead of standard input (with `-decrypt`), and `-version` prints the
|
||||
version.
|
||||
`-e` with the name only takes the value from your shell's environment, so it is
|
||||
not written on the command line:
|
||||
|
||||
@@ -1256,7 +1303,8 @@ inbound mail is being refused with a temporary error (`tempfail`) or going throu
|
||||
(`accept`).
|
||||
|
||||
**Inbound mail is not in the Outbound log.** Port 25 runs its own milter chain:
|
||||
the optional anti-spam milter, and nothing else. The DKIM signer does not sign relayed
|
||||
the optional anti-spam milter and the panel's inbound journal (Inbound › Log,
|
||||
fail-open), not the signer, the sender check or the outbound journal. The DKIM signer does not sign relayed
|
||||
mail (and a signing outage therefore cannot hold it up), and the journal-milter
|
||||
does not file it — the [Outbound log](#outbound-log) stays a record of what
|
||||
*you* sent.
|
||||
@@ -1313,7 +1361,11 @@ sees the *Ingest* box — whether ingest works, the last report received, report
|
||||
kept this week and parse failures. Open a domain's roll-up from the list or
|
||||
from *DMARC reports* in the domain page's menu. A user with the *domain* role
|
||||
sees the reports of the domains assigned to them, without the *Ingest* box.
|
||||
Summaries are pruned (500 kept for each domain, 90 days max).
|
||||
Summaries are pruned (500 kept for each domain, 90 days max). A report's
|
||||
page is `/outbound/dmarc/reports/<id>` and a domain's roll-up
|
||||
`/outbound/dmarc/domains/<id>`. A report's day - in the lists and its title -
|
||||
is the reporter's, counted in UTC as reports count their days; its page shows
|
||||
the window's times in the panel's zone, with a line naming that day.
|
||||
|
||||
A message that is not a report the relay keeps — unreadable as an aggregate
|
||||
report, about a domain not sent from here, or delivered to another domain's
|
||||
@@ -1521,9 +1573,10 @@ gateway, not its own address (see [Rate limiting — level
|
||||
1](#rate-limiting--level-1-ip-backstop)).
|
||||
|
||||
It is **not an authentication boundary.** The SASL login itself still succeeds;
|
||||
the check runs in the journal-milter, on the same fail-open path as level 2
|
||||
(below), so if that milter does not answer the restriction is skipped rather
|
||||
than enforced. Use it to narrow where an application may send from, not to contain a
|
||||
the check runs in the journal-milter and is fail-open like level 2 (below):
|
||||
if that milter does not answer, or its lookup hits a store error, the
|
||||
restriction is skipped rather than enforced. A message whose client address
|
||||
Postfix did not pass is refused, not let through. Use it to narrow where an application may send from, not to contain a
|
||||
leaked password — for that, regenerate the password.
|
||||
|
||||
**Level 2 — application** — optional override of the domain limit for one
|
||||
@@ -1544,8 +1597,9 @@ hours and on demand via **Recalculate now**.
|
||||
|
||||
**Level 2 is best-effort, not a guarantee.** It runs inside the
|
||||
journal-milter and is deliberately fail-open: if the rate-limit lookup hits
|
||||
a store error, or the connecting client's IP is not available to the
|
||||
milter, level 2 is skipped and the message is accepted rather than held up.
|
||||
a store error, level 2 is skipped and the message is accepted rather than
|
||||
held up. The client address plays no part: a ceiling is on a domain or an
|
||||
application, wherever its mail comes from.
|
||||
The same applies if the journal-milter itself does not answer — Postfix is
|
||||
configured to accept rather than refuse it when it cannot reach it. (Since
|
||||
`2.4.0` a panel that is down altogether makes Postfix refuse mail with a
|
||||
@@ -1556,9 +1610,9 @@ the only one of the two that does not depend on the panel process.
|
||||
|
||||
### Outbound log
|
||||
|
||||
**Outbound › Log** (`/outbound/log`) is a searchable send log with server-side
|
||||
**Outbound › Log** (`/outbound/log`) is the send log, with server-side
|
||||
filters by domain, application, status and day (from and to, days of the container's zone, as on
|
||||
the Inbound log); the status *rejected* is the three kinds of refusal
|
||||
the Inbound log); the status *rejected* is the four kinds of refusal
|
||||
together, which the table tells apart by their tags. A user with the *domain*
|
||||
role sees only the rows of their domains. The filters are kept by the pages,
|
||||
the refresh and the way back from a message, and the log refreshes its rows
|
||||
@@ -1575,19 +1629,20 @@ the row as a failure rather than show a delivery it cannot evidence, and
|
||||
counts that it did. A
|
||||
refused row says why in its tag: `rate-limited` (a [level-2 rate
|
||||
limit](#rate-limiting--level-2-domain-and-application)), `wrong client` (the
|
||||
application's client IP list) or `wrong From` (see [Who an application may
|
||||
send as](#who-an-application-may-send-as)); a row refused before `2.4.0`
|
||||
application's client IP list), `wrong From` or `wrong Sender` (see [Who an
|
||||
application may send as](#who-an-application-may-send-as)); a row refused before `2.4.0`
|
||||
says `rejected`. A refused message's page shows what Postfix wrote about the
|
||||
refusal: for `wrong From` the lines of the queue id the message had, for the
|
||||
other two the one `NOQUEUE` line; a row refused before `2.5.1` says the line
|
||||
refusal: for `wrong From` and `wrong Sender` the lines of the queue id the
|
||||
message had, for the other two the one `NOQUEUE` line; a row refused before `2.5.1` says the line
|
||||
was not kept, and one the panel wrote from `mail.log` after a restart - it
|
||||
had stopped before writing the refusal itself - says so;
|
||||
*Details* opens that row's own page (`/outbound/log/{id}`). That page carries
|
||||
the sending domain, the application it was submitted under, the Postfix
|
||||
queue id and the journal id, beside the message's history — when it was
|
||||
accepted and what Postfix later reported for the recipient. A `deferred`
|
||||
or `bounced` row includes this Postfix's retry intervals (first delay,
|
||||
backoff cap, queue lifetime), the same numbers the Outbound queue shows; users
|
||||
row includes this Postfix's retry intervals (first delay, backoff cap, queue
|
||||
lifetime), a `bounced` one the queue lifetime, the same numbers the Outbound
|
||||
queue shows; users
|
||||
with the *domain* role see them here because they cannot open the queue. Under
|
||||
both sit the `mail.log` lines for its queue id: the connection to the
|
||||
receiving server, the server's reply, and the status that reply was filed
|
||||
@@ -1597,7 +1652,7 @@ the row is deleted by retention, and not while another row carries the same
|
||||
queue id (Postfix reuses ids); a
|
||||
message accepted before `2.2.0` shows them only while they are still in the
|
||||
current file, and the page says so when there are none. Retention is set under **Server › Settings** (global
|
||||
role); `SEND_LOG_RETENTION_DAYS` in `.env` is only the initial default until it
|
||||
role; with the inbound relay on, the Inbound log's has a field of its own there); `SEND_LOG_RETENTION_DAYS` in `.env` is only the initial default until it
|
||||
is changed there.
|
||||
|
||||
### Exporting and importing a single domain
|
||||
|
||||
+3
-2
@@ -62,8 +62,9 @@ Explicitly excluded to prevent scope creep:
|
||||
- Organisations / tenancy beyond the `global` and `domain` roles; managing
|
||||
**multiple sending domains** under one global administrator is in scope (see
|
||||
below)
|
||||
- Inbound antispam/antivirus engines (rspamd, ClamAV, etc.) — SelfPost may
|
||||
expose a milter hook; it does not ship or start a filter
|
||||
- Inbound antispam/antivirus engines (rspamd, ClamAV, etc.) — the image
|
||||
ships no filter, only a milter hook; `deploy/antispam` offers an optional
|
||||
rspamd sidecar that the panel's filter lists feed
|
||||
- A custom MTA — Postfix is used as-is
|
||||
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only
|
||||
|
||||
|
||||
+17
-1
@@ -37,7 +37,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
||||
| review-2026-08-followups | Remaining findings of the 2026-08-19 code review | closed 2026-10-14 (rows placed in [audit-2026-10-12](audit-2026-10-12.md)) | — | — |
|
||||
| panel-notifications | E-mail notifications about important events | shipped `2.3.0` | 10/10 | [plans/panel-notifications.md](plans/panel-notifications.md) |
|
||||
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | shipped `2.2.0` | 8/8 | [plans/delivery-log-storage.md](plans/delivery-log-storage.md) |
|
||||
| password-reset | Password reset by e-mail | agreed (2026-10-13) | — | — |
|
||||
| password-reset | Password reset by e-mail | shipped `2.14.0` | — | — |
|
||||
| local-time | The panel shows time, and counts its days, in a time zone other than UTC | shipped `2.13.0` | — | — |
|
||||
| notification-mail-recheck | The Notification mail records on Server › Settings can be checked again from where they are shown | shipped `2.9.0` | — | — |
|
||||
| settings-save-row | Server › Settings: the Save button no longer stands alone under a long right column | shipped `2.9.0` | — | — |
|
||||
@@ -459,6 +459,22 @@ to come need it - they are not the owner and should not have to ask him for
|
||||
a new password). Mockup-first; changing the account e-mail asks for the
|
||||
current password in the same cut.
|
||||
|
||||
**Notifications to an unconfirmed address (owner, 2026-10-17):** "Без
|
||||
подтверждения слать" - they go to it confirmed or not; only a reset link
|
||||
needs it confirmed. Every address saved before the release is unconfirmed,
|
||||
and cutting them off would have stopped every warning at once.
|
||||
|
||||
**Done (2026-10-17):** design 2f5f5b2, route guard b7bbb2c, panel guard
|
||||
0774da2. Migration 0013 (`users.email_confirmed_at`, `user_links`). Links
|
||||
are 256 bits, kept as SHA-256, confirmation 24 h and reset 30 min, taken
|
||||
once (`DELETE … RETURNING`), built on `SELFPOST_HOSTNAME` (never `Host`);
|
||||
the request answers the same for every name and mails after answering;
|
||||
limits per client address and per account; a link's GET page has a button
|
||||
(mail scanners open links). A reset ends every session. Changing the
|
||||
e-mail on Account asks for the password. The global role's user form sends
|
||||
a typed address its link. e2e `password_reset_by_e_mail` runs it through
|
||||
the sink.
|
||||
|
||||
---
|
||||
|
||||
## local-time
|
||||
|
||||
@@ -17,7 +17,7 @@ When you add or review a migration, update this file in the same change.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Chain head | `user_version = 12` |
|
||||
| Chain head | `user_version = 13` |
|
||||
| Files | `0001_init.sql` (the 2.0 baseline), `0002_inbound_spam_log.sql`, `0003_send_log_lines.sql`, `0004_notifications.sql`, `0005_send_log_rejected_index.sql`, `0006_sender_check.sql`, `0007_delivery_log_days.sql`, `0008_tenant_boundary.sql`, `0009_refusal_line.sql`, `0010_send_log_indexes.sql`, `0011_notify_addressees.sql`, `0012_inbound_upstream.sql` |
|
||||
| File mark | `PRAGMA application_id = 0x53503230` ("SP20") |
|
||||
| Database file | `/data/selfpost.db` (bind mount) |
|
||||
@@ -86,10 +86,11 @@ audit (`2.5.0`), `0009_refusal_line.sql` (`2.5.1`) and
|
||||
| 6 | `0006_sender_check.sql` | 2.4.0 | DDL | `send_log.reason` and `send_log.header_from` (both default `''`: a row refused earlier keeps no reason), and `sender_check_days` |
|
||||
| 7 | `0007_delivery_log_days.sql` | 2.5.0 | DDL | `delivery_log_days`: what the reconcile sweep and the log tailer did, a row per UTC day, kept a year, shown nowhere - the measurement that decides the sweep's fate (audit 2026-10-12, stage 1). `send_log.reason` gains the values `reconcile` and `reconcile-contradicted` without a schema change |
|
||||
| 8 | `0008_tenant_boundary.sql` | 2.5.0 | DDL + data | `idx_applications_login_nocase`: an application login is unique without regard to case (audit 2026-10-12, H4). `send_log.domain_id` and `inbound_spam_log.inbound_domain_id` (`ON DELETE SET NULL`), each with an index, filled once by name from the registry as it was then: a row belongs to a domain by id, and a domain deleted and added again starts with an empty log (L12) |
|
||||
| 9 | `0009_refusal_line.sql` | 2.5.1 | DDL | `send_log.refusal_line`: Postfix's line about a refusal at MAIL FROM (a level-2 limit, a client address off the list), which has no queue id; the log tailer puts it on the row; `send_log.recovered`, a refusal row the tailer wrote from `mail.log` because the panel stopped before writing it; `sender_check_days.messages` (messages deferred mid-message, counted apart from the connections turned away) and `sender_check_days.estimated` (a day whose downtime is an upper bound) (audit 2026-10-12, stage 2b) |
|
||||
| 9 | `0009_refusal_line.sql` | 2.5.1 | DDL | `send_log.refusal_line`: Postfix's line about a refusal at MAIL FROM (a level-2 limit, a client address off the list), which has no queue id; the log tailer puts it on the row, found by `send_log.client_ip`, the client the refusal came from; `send_log.recovered`, a refusal row the tailer wrote from `mail.log` because the panel stopped before writing it; `sender_check_days.messages` (messages deferred mid-message, counted apart from the connections turned away) and `sender_check_days.estimated` (a day whose downtime is an upper bound) (audit 2026-10-12, stage 2b) |
|
||||
| 10 | `0010_send_log_indexes.sql` | 2.5.3 | DDL | Four indexes of `send_log`, each for a question that walked the table or sorted a domain's log (audit 2026-10-12, M2, stage 3): `idx_send_log_open`, partial, the queued and deferred rows by time (the tailer's sweep every 5 min); `idx_send_log_domain_id` and `idx_send_log_app_login`, a page of one domain's or one application's log in id order; `idx_send_log_refusal_line`, partial, whether a refusal line is already on a row. `TestSendLogQueriesUseTheirIndexes` checks the plans of these and of the other by-time and by-scope statements the store runs |
|
||||
| 11 | `0011_notify_addressees.sql` | 2.5.4 | DDL | `notify_addressees (key, address, told_at)`: who a subject's notification is owed to and who was handed it, so a copy that failed or was held back by the hourly ceiling goes out at a later check and a subject's end goes to those told (audit 2026-10-12, stage 4); deleted with its `notify_state` row |
|
||||
| 12 | `0012_inbound_upstream.sql` | 2.10.0 | DDL | `inbound_spam_log` gains `queue_id`, `upstream_status` (`''`, `queued`, `held`, `sent`, `deferred`, `bounced`; CHECK), `upstream_relay`, `upstream_reply` and `upstream_at`, and the partial index `idx_inbound_spam_log_queue_id` on rows that have a queue id: what the upstream answered about mail accepted on port 25, put on the row by the log tailer (inbound-upstream-result). Rows from before have none |
|
||||
| 13 | `0013_email_links.sql` | 2.14.0 | DDL | `users.email_confirmed_at` (`''` for every address saved before it: all unconfirmed) and `user_links`, the one-time links that confirm an address and reset a password, with `idx_user_links_user` (password-reset; owner, 2026-10-13 and 2026-10-17) |
|
||||
|
||||
**Kind:** *DDL* — schema only; *data* — `INSERT`/`UPDATE` that must stay correct
|
||||
for operators upgrading from older 2.x images.
|
||||
@@ -101,14 +102,15 @@ for operators upgrading from older 2.x images.
|
||||
| Table | Role |
|
||||
|-------|------|
|
||||
| `settings` | Key/value settings of the instance — what is true whoever is signed in (send-log retention). Nothing that belongs to a user |
|
||||
| `users` | Panel logins. `role` is `global` or `domain`; `email` is the user's own address; `notify` is the user's own switch for notification e-mails (v4); `all_domains` / `all_inbound_domains` widen a `domain` user to every domain of that direction |
|
||||
| `users` | Panel logins. `role` is `global` or `domain`; `email` is the user's own address; `notify` is the user's own switch for notification e-mails (v4); `all_domains` / `all_inbound_domains` widen a `domain` user to every domain of that direction; `email_confirmed_at` is when `email` was confirmed, `''` while it is not, cleared when it changes (v13) |
|
||||
| `user_links` | The one-time links of password-reset (v13): the SHA-256 of the token, the user, `purpose` (`confirm`, `reset`; CHECK), the address it was sent to, when, and until when; one live link per user and purpose |
|
||||
| `sessions` | Panel login sessions (token hash, owning user by id and deleted with them, sliding idle expiry) |
|
||||
| `domains` | Sending (outbound) domains. `dmarc_rua` is where aggregate reports go (`''` = no reports) |
|
||||
| `user_domains` | Outbound domains assigned to a `domain` user |
|
||||
| `applications` | SASL applications per domain, with the client-IP restriction (`auth_ip_restrict`, `auth_allowed_ips`) |
|
||||
| `application_addresses` | Explicit From addresses (`list` mode) |
|
||||
| `rate_limits` | Level-2 limits per domain / application, manual or auto |
|
||||
| `send_log` | Delivery journal. `reason` says why a row was refused (`limit`, `client`, `from`) or that the sweep closed it (`reconcile`, `reconcile-contradicted`), `header_from` what the From: line of a message refused for it said (v6), `refusal_line`, `client_ip` and `recovered` (v9), and `domain_id` (v8: the row belongs to a domain by id, `ON DELETE SET NULL`) |
|
||||
| `send_log` | Delivery journal. `reason` says why a row was refused (`limit`, `client`, `from`, and `sender` since 2.8.0) or that the sweep closed it (`reconcile`, `reconcile-contradicted`), `header_from` what the From: or Sender: line of a message refused for it said (v6), `refusal_line`, `client_ip` and `recovered` (v9), and `domain_id` (v8: the row belongs to a domain by id, `ON DELETE SET NULL`) |
|
||||
| `logtail_state` | Log-tailer read position |
|
||||
| `inbound_domains` | Inbound relay domains |
|
||||
| `inbound_transports` | Upstream host / port / TLS per inbound domain |
|
||||
@@ -116,7 +118,7 @@ for operators upgrading from older 2.x images.
|
||||
| `user_inbound_domains` | Inbound domains assigned to a `domain` user — granted separately from the outbound ones |
|
||||
| `dmarc_reports` | Parsed aggregate report summaries |
|
||||
| `dmarc_report_records` | Per-source rows inside a report |
|
||||
| `inbound_spam_log` | Inbound antispam journal: one row per recipient of a message offered on port 25, with the decision (`accept`, `reject`, `tempfail`, `quarantine`) and who made it (`rspamd`, `postfix`, `selfpost`). Metadata only. `inbound_domain_id` (v8) ties a row to its domain by id (v2) |
|
||||
| `inbound_spam_log` | Inbound antispam journal: one row per recipient of a message offered on port 25, with the decision (`accept`, `reject`, `tempfail`, `quarantine`) and who made it (`rspamd`, `postfix`, `selfpost`). Metadata only (v2). `inbound_domain_id` (v8) ties a row to its domain by id; `queue_id` and `upstream_status`, `upstream_relay`, `upstream_reply`, `upstream_at` (v12) are what the upstream answered for an accepted message |
|
||||
| `inbound_spam_list` | Allow / deny entries for the external filter (`address`, `domain`, `ip`), instance-wide; unique per list (v2) |
|
||||
| `notify_state` | What the panel has already written to its users about, one row per subject (`dns:<domain>`, `cert`, `queue`, `limit:<domain>`), so that each thing is said once and a restart says nothing again (v4) |
|
||||
| `sender_check_days` | What the sender check costs, a row per day of the container's zone (`TZ`) that has something to say: seconds the panel did not answer while Postfix ran, connections turned away (`deferred`), messages deferred mid-message (`messages`), and whether the day's downtime is an upper bound (`estimated`). Kept a year (v6, v9) |
|
||||
|
||||
+45
-14
@@ -138,7 +138,8 @@ The panel is exposed to the internet — the items below are **not optional**.
|
||||
after it was found, so the cookie of a deleted user is dead and cannot become
|
||||
the session of whoever is created under the same name later; renaming a user
|
||||
leaves their sessions alone.
|
||||
- **What ends a user's sessions:** deleting the user (all); a password change on
|
||||
- **What ends a user's sessions:** deleting the user (all); a password set
|
||||
from a reset link (all, `2.14.0`); a password change on
|
||||
Account (that user's *other* sessions — never another user's); a password set
|
||||
for them by a global user on the user form (all of theirs, except the current
|
||||
one when they set their own); signing out (the current one only); signing
|
||||
@@ -148,6 +149,36 @@ The panel is exposed to the internet — the items below are **not optional**.
|
||||
held.
|
||||
- Polling by the monitoring screens (`HX-Request` `GET`s) does not renew a
|
||||
session.
|
||||
- **Password reset by e-mail (`2.14.0`).** A link goes only to an address
|
||||
its account has confirmed, by a link sent to that address; notifications
|
||||
go to an unconfirmed one (owner, 2026-10-17) - they carry no secret. A link
|
||||
is 256 random bits (`crypto/rand`); `user_links` keeps its SHA-256 only,
|
||||
so the database or a backup opens nothing. A confirmation link lives 24
|
||||
hours, a reset link 30 minutes; each is taken once, in the transaction
|
||||
that deletes it (`DELETE … RETURNING`), and a newer link of the same kind
|
||||
replaces the older. A reset link holds only while the account's address is
|
||||
still the confirmed one it was sent to; a confirmation link only while the
|
||||
address is the one it confirms. The link's host is `SELFPOST_HOSTNAME`,
|
||||
never the request's `Host`, which an attacker would choose to receive the
|
||||
token. The request form answers the same for every name and sends the mail
|
||||
after answering, so neither the words nor the time tell whether a name
|
||||
exists or is confirmed; requests are limited to five in 15 minutes per
|
||||
client address, mails to five an hour per account, and a link's page to 30
|
||||
requests in 15 minutes per client address. A link's page changes nothing
|
||||
on `GET` (a mail scanner opens links): the button does. Setting a password
|
||||
from a link ends every session of the account and every other reset link.
|
||||
Changing the account e-mail asks for the current password, as the password
|
||||
does: the address is where a reset link goes. From the security review
|
||||
(2026-10-17): confirming an address also takes being signed in as its
|
||||
account, and the confirmation mail does not name the account - a mistyped
|
||||
address is a stranger's, who could otherwise confirm it and reset the
|
||||
password; a reset request does not replace the links already sent, and
|
||||
none is sent within ten minutes of the last, so a stranger repeating a
|
||||
username neither kills the link in the mailbox nor spends the account's
|
||||
links; the link is taken and the password set in one transaction that
|
||||
checks the address again; a restore deletes every link; a rejected
|
||||
request's log line hides a link's token; a stop waits for the links still
|
||||
being mailed.
|
||||
|
||||
### Roles
|
||||
|
||||
@@ -229,8 +260,8 @@ operator can act on it.
|
||||
`selfpost` for files and does not reach it (`2.6.0`; OpenDKIM shared
|
||||
`selfpost` too until `2.8.0`).
|
||||
- **Group `selfpost` (gid 10998) has two members, `panel` and `postfix`.**
|
||||
Through it Postfix reads what the panel writes - `sasldb2` and the maps
|
||||
under `/data/postfix` (2750, files 0640) - and connects to the panel's
|
||||
Through it Postfix reads what the panel writes - `sasldb2` under
|
||||
`/data/sasl` and the maps under `/data/postfix` (both 2750, files 0640) - and connects to the panel's
|
||||
milter sockets in `/run/selfpost` (2750, sockets 0660); the panel reads
|
||||
`mail.log`, which `postfix` writes, through it the other way
|
||||
([build/Dockerfile](../build/Dockerfile), `build/entrypoint.sh`). The DKIM
|
||||
@@ -286,14 +317,12 @@ operator can act on it.
|
||||
signer reads them and never the database. The entrypoint sets these modes
|
||||
at every start (e2e `checkDKIMTree` holds the tree to them). Until `2.8.0`
|
||||
the keys were under `/data/opendkim`, 2750/0640 in group `selfpost` so
|
||||
that OpenDKIM could read them - and so could every `postfix` process. That
|
||||
tree is kept for a rollback to 2.7.x until the next release; since `2.8.0`
|
||||
the entrypoint makes it the panel's alone as well (0700/0600, panel:panel)
|
||||
at every start, copies its keys as the panel user (`setpriv`) while
|
||||
`/data/dkim` holds none, and a backup leaves it out (§ Backup and domain
|
||||
export). A root step of the entrypoint follows no symbolic link the panel
|
||||
could plant in either tree (`drop_links`; `cp -RP`; `chown -R` and `find`
|
||||
do not follow one).
|
||||
that OpenDKIM could read them - and so could every `postfix` process.
|
||||
2.8.x kept that tree, the panel's alone, for a rollback to 2.7.x and copied
|
||||
its keys over; since `2.9.0` the entrypoint removes it at start, and refuses
|
||||
to start when the keys are only there (start 2.8.x once first). A root step
|
||||
of the entrypoint follows no symbolic link the panel could plant in the
|
||||
tree (`drop_links`; `rm -rf`, `chown -R` and `find` do not follow one).
|
||||
- **An application signs only as itself (`2.4.0`).** The signature's `d=` is
|
||||
the login's domain, and a receiver holds it to the domain of the `From:`
|
||||
line (DMARC, RFC 7489 § 3.1.1): without a check of that line an
|
||||
@@ -498,7 +527,8 @@ operator can act on it.
|
||||
CLI the password comes only from `SELFPOST_BACKUP_PASSWORD` or
|
||||
`-password-file`, never as an argument (the process list is readable by any
|
||||
process in the container).
|
||||
- Minimum password length matches the administrator password (12): the file
|
||||
- Minimum password length matches the administrator password (12
|
||||
characters, counted as characters, not bytes, in both): the file
|
||||
sits offline and can be attacked without a time limit. The panel's forms and,
|
||||
since `2.5.3`, the CLI hold an encrypting password to it; decrypting takes
|
||||
any.
|
||||
@@ -550,8 +580,9 @@ deferred item from the roadmap.
|
||||
keep a password would lose the ability to take a backup at all, and a
|
||||
permanently undecryptable archive is worse than an unencrypted one:
|
||||
SelfPost does not store the password. The trigger to make encryption
|
||||
mandatory is a second administrator (at which point "who downloaded it"
|
||||
stops being one person).
|
||||
mandatory is a second person who takes backups or exports - a second
|
||||
global user, or a domain user exporting their domain - at which point "who
|
||||
downloaded it" stops being one person.
|
||||
- **A journal row left without delivery lines is closed as `bounced` rather
|
||||
than left as it is.** The "forever `queued`" risk is gone: `mail.log` moved to
|
||||
`/data/log/` and survives container recreation, and the log tailer keeps its
|
||||
|
||||
@@ -255,7 +255,7 @@ func TightenPolicyHint(pass, fail int, sources []SourceHint) string {
|
||||
if fail > 0 {
|
||||
for _, s := range sources {
|
||||
if s.FailCount > 0 && !s.ThisRelay {
|
||||
return "A third-party source is not aligned. Do not tighten p= until that sender is fixed or removed."
|
||||
return "Failures come from a source that is not this relay — review before tightening p=."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +264,19 @@ func (c *Checker) InboundMX(name, hostname string, force bool) Result {
|
||||
return result
|
||||
}
|
||||
|
||||
// InboundMXCheckedAt is when the MX of name was last looked up for
|
||||
// hostname, zero when it has not been: the cache is filled when it is, and
|
||||
// keeps the result domainTTL.
|
||||
func (c *Checker) InboundMXCheckedAt(name, hostname string) time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
entry, ok := c.inbound[name+"\x00"+hostname]
|
||||
if !ok {
|
||||
return time.Time{}
|
||||
}
|
||||
return entry.expires.Add(-c.domainTTL)
|
||||
}
|
||||
|
||||
func (c *Checker) checkInboundMX(ctx context.Context, name, hostname string) Result {
|
||||
mxs, err := c.resolver.LookupMX(ctx, name)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- A confirmed account e-mail, and the one-time links that confirm one and
|
||||
-- reset a forgotten password (password-reset, 2.14.0; owner, 2026-10-13: "the
|
||||
-- e-mail must be confirmed"; 2026-10-17: notifications go to it confirmed or
|
||||
-- not). email_confirmed_at is when the address in email was confirmed, ''
|
||||
-- while it is not; changing the address clears it. Every address saved before
|
||||
-- this file is unconfirmed.
|
||||
ALTER TABLE users ADD COLUMN email_confirmed_at TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- A link's token is in a mail and nowhere else: only its SHA-256 is kept, as a
|
||||
-- session's. email is the address a confirmation link confirms - it fails
|
||||
-- once the account's address is another - and the one a reset link was sent
|
||||
-- to. A link is taken once; a new one of the same purpose replaces the old.
|
||||
CREATE TABLE user_links (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
purpose TEXT NOT NULL CHECK (purpose IN ('confirm', 'reset')),
|
||||
email TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_user_links_user ON user_links(user_id, purpose);
|
||||
@@ -42,7 +42,7 @@ func TestBaselineSchema(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
want := map[string]string{
|
||||
"settings": "key value",
|
||||
"users": "id username password_hash role email all_domains all_inbound_domains created_at notify",
|
||||
"users": "id username password_hash role email all_domains all_inbound_domains created_at notify email_confirmed_at",
|
||||
"sessions": "token_hash user_id created_at expires_at",
|
||||
"domains": "id name dkim_selector dmarc_rua created_at",
|
||||
"user_domains": "user_id domain_id",
|
||||
@@ -64,6 +64,7 @@ func TestBaselineSchema(t *testing.T) {
|
||||
"notify_addressees": "key address told_at",
|
||||
"sender_check_days": "day down_seconds deferred messages estimated",
|
||||
"delivery_log_days": "day reconcile_closed reconcile_missed reconcile_contradicted tailer_blind_seconds queued_over_lifetime",
|
||||
"user_links": "token_hash user_id purpose email created_at expires_at",
|
||||
}
|
||||
rows, err := st.db.Query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
if err != nil {
|
||||
@@ -111,7 +112,7 @@ func TestBaselineSchema(t *testing.T) {
|
||||
"idx_inbound_spam_log_created_at", "idx_inbound_spam_log_domain_created_at", "idx_inbound_spam_log_domain_id_created_at", "idx_inbound_spam_log_queue_id",
|
||||
"idx_send_log_app_login", "idx_send_log_app_login_created_at", "idx_send_log_created_at", "idx_send_log_domain", "idx_send_log_domain_id", "idx_send_log_domain_id_created_at",
|
||||
"idx_send_log_lines_queue_id", "idx_send_log_open", "idx_send_log_queue_id", "idx_send_log_refusal_line", "idx_send_log_rejected",
|
||||
"idx_sessions_expires_at",
|
||||
"idx_sessions_expires_at", "idx_user_links_user",
|
||||
}
|
||||
sort.Strings(wantIdx)
|
||||
if strings.Join(indexes, " ") != strings.Join(wantIdx, " ") {
|
||||
@@ -119,8 +120,8 @@ func TestBaselineSchema(t *testing.T) {
|
||||
}
|
||||
|
||||
var version, appID int64
|
||||
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 12 {
|
||||
t.Errorf("user_version = %d, %v; want 12", version, err)
|
||||
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 13 {
|
||||
t.Errorf("user_version = %d, %v; want 13", version, err)
|
||||
}
|
||||
if err := st.db.QueryRow("PRAGMA application_id").Scan(&appID); err != nil || appID != applicationID {
|
||||
t.Errorf("application_id = %#x, %v; want %#x", appID, err, applicationID)
|
||||
@@ -196,8 +197,8 @@ func TestOpenMigratesA20Database(t *testing.T) {
|
||||
}
|
||||
defer st.Close()
|
||||
var version int
|
||||
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 12 {
|
||||
t.Errorf("user_version = %d, %v; want 12", version, err)
|
||||
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 13 {
|
||||
t.Errorf("user_version = %d, %v; want 13", version, err)
|
||||
}
|
||||
if ok, err := st.UserExists(); err != nil || !ok {
|
||||
t.Errorf("the user of the 2.0 database: exists = %v, %v", ok, err)
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The purposes of a user link (migration 0013).
|
||||
const (
|
||||
LinkConfirm = "confirm" // confirms the account's e-mail
|
||||
LinkReset = "reset" // sets a new password
|
||||
)
|
||||
|
||||
// ErrNoLink is a link that does not exist, has been taken or has expired:
|
||||
// the three are one answer.
|
||||
var ErrNoLink = errors.New("no such link")
|
||||
|
||||
// UserLink is a one-time link of a user, as stored: the token itself is in
|
||||
// the mail alone.
|
||||
type UserLink struct {
|
||||
UserID int64
|
||||
Purpose string
|
||||
Email string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// CreateUserLink stores a link of the user for purpose, sent to email, valid
|
||||
// until expires, and removes every expired link. A confirmation link replaces
|
||||
// the user's earlier one - it confirms the address saved last. A reset link
|
||||
// does not: anyone who knows a username can ask for one, and a request of
|
||||
// theirs must not kill the link the user is about to open (security review,
|
||||
// 2026-10-17); setting a password removes them all.
|
||||
func (s *Store) CreateUserLink(tokenHash string, userID int64, purpose, email string, now, expires time.Time) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create user link: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// The first statement is a write (see UpdateStatus).
|
||||
if _, err := tx.Exec(`DELETE FROM user_links WHERE (user_id = ? AND purpose = ? AND purpose = ?) OR expires_at <= ?`,
|
||||
userID, purpose, LinkConfirm, now.UTC().Format(time.RFC3339)); err != nil {
|
||||
return fmt.Errorf("create user link: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO user_links (token_hash, user_id, purpose, email, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`, tokenHash, userID, purpose, email,
|
||||
now.UTC().Format(time.RFC3339), expires.UTC().Format(time.RFC3339)); err != nil {
|
||||
return fmt.Errorf("create user link: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("create user link: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanLink(r scanRow) (UserLink, error) {
|
||||
var l UserLink
|
||||
var created, expires string
|
||||
if err := r.Scan(&l.UserID, &l.Purpose, &l.Email, &created, &expires); err != nil {
|
||||
return UserLink{}, err
|
||||
}
|
||||
l.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
l.ExpiresAt, _ = time.Parse(time.RFC3339, expires)
|
||||
return l, nil
|
||||
}
|
||||
|
||||
const linkColumns = "user_id, purpose, email, created_at, expires_at"
|
||||
|
||||
// PeekUserLink returns the link of purpose with tokenHash if it is still
|
||||
// valid at now, without taking it: the page a link opens shows its form.
|
||||
func (s *Store) PeekUserLink(tokenHash, purpose string, now time.Time) (UserLink, error) {
|
||||
l, err := scanLink(s.db.QueryRow(`SELECT `+linkColumns+` FROM user_links
|
||||
WHERE token_hash = ? AND purpose = ? AND expires_at > ?`,
|
||||
tokenHash, purpose, now.UTC().Format(time.RFC3339)))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return UserLink{}, ErrNoLink
|
||||
}
|
||||
if err != nil {
|
||||
return UserLink{}, fmt.Errorf("peek user link: %w", err)
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// TakeUserLink returns the link of purpose with tokenHash and deletes it, in
|
||||
// one transaction: of two requests with the same link one gets it.
|
||||
func (s *Store) TakeUserLink(tokenHash, purpose string, now time.Time) (UserLink, error) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return UserLink{}, fmt.Errorf("take user link: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// The delete first: it is the write that takes the lock (see
|
||||
// UpdateStatus), and RETURNING gives the row it removed (SQLite 3.35+,
|
||||
// https://sqlite.org/lang_returning.html).
|
||||
l, err := scanLink(tx.QueryRow(`DELETE FROM user_links
|
||||
WHERE token_hash = ? AND purpose = ? AND expires_at > ?
|
||||
RETURNING `+linkColumns, tokenHash, purpose, now.UTC().Format(time.RFC3339)))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return UserLink{}, ErrNoLink
|
||||
}
|
||||
if err != nil {
|
||||
return UserLink{}, fmt.Errorf("take user link: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return UserLink{}, fmt.Errorf("take user link: %w", err)
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// LatestUserLink is the user's live link of purpose, ErrNoLink when there is
|
||||
// none: Account says when the confirmation link went.
|
||||
func (s *Store) LatestUserLink(userID int64, purpose string, now time.Time) (UserLink, error) {
|
||||
l, err := scanLink(s.db.QueryRow(`SELECT `+linkColumns+` FROM user_links
|
||||
WHERE user_id = ? AND purpose = ? AND expires_at > ? ORDER BY created_at DESC LIMIT 1`,
|
||||
userID, purpose, now.UTC().Format(time.RFC3339)))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return UserLink{}, ErrNoLink
|
||||
}
|
||||
if err != nil {
|
||||
return UserLink{}, fmt.Errorf("latest user link: %w", err)
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// DeleteUserLinks removes every link of the user of purpose: a password set
|
||||
// another way leaves no reset link behind it.
|
||||
func (s *Store) DeleteUserLinks(userID int64, purpose string) error {
|
||||
if _, err := s.db.Exec(`DELETE FROM user_links WHERE user_id = ? AND purpose = ?`, userID, purpose); err != nil {
|
||||
return fmt.Errorf("delete user links: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConfirmEmail marks the user's e-mail confirmed at now if it is still email:
|
||||
// false when the address has changed since the link was sent.
|
||||
func (s *Store) ConfirmEmail(userID int64, email string, now time.Time) (bool, error) {
|
||||
res, err := s.db.Exec(`UPDATE users SET email_confirmed_at = ? WHERE id = ? AND email = ? AND email <> ''`,
|
||||
now.UTC().Format(time.RFC3339), userID, email)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("confirm e-mail: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n == 1, nil
|
||||
}
|
||||
|
||||
// TakeResetLink sets the password of the reset link with tokenHash, in one
|
||||
// transaction: the link is taken, the password set only while the account's
|
||||
// address is still the confirmed one the link was sent to, and the user's
|
||||
// other reset links go. ErrNoLink, and nothing changed, for a link that does
|
||||
// not hold - taken, expired, or of an address the account no longer has.
|
||||
func (s *Store) TakeResetLink(tokenHash, passwordHash string, now time.Time) (int64, error) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("take reset link: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
l, err := scanLink(tx.QueryRow(`DELETE FROM user_links
|
||||
WHERE token_hash = ? AND purpose = ? AND expires_at > ?
|
||||
RETURNING `+linkColumns, tokenHash, LinkReset, now.UTC().Format(time.RFC3339)))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, ErrNoLink
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("take reset link: %w", err)
|
||||
}
|
||||
res, err := tx.Exec(`UPDATE users SET password_hash = ?
|
||||
WHERE id = ? AND email = ? AND email <> '' AND email_confirmed_at <> ''`, passwordHash, l.UserID, l.Email)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("take reset link: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n != 1 {
|
||||
return 0, ErrNoLink
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM user_links WHERE user_id = ? AND purpose = ?`, l.UserID, LinkReset); err != nil {
|
||||
return 0, fmt.Errorf("take reset link: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("take reset link: %w", err)
|
||||
}
|
||||
return l.UserID, nil
|
||||
}
|
||||
|
||||
// DeleteAllUserLinks removes every link: a restored backup must not bring
|
||||
// back one that was taken after it was made (security review, 2026-10-17).
|
||||
func (s *Store) DeleteAllUserLinks() error {
|
||||
if _, err := s.db.Exec(`DELETE FROM user_links`); err != nil {
|
||||
return fmt.Errorf("delete user links: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetUserPassword replaces the user's password hash alone.
|
||||
func (s *Store) SetUserPassword(userID int64, passwordHash string) error {
|
||||
res, err := s.db.Exec(`UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set password: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func linkTestUser(t *testing.T, st *Store, email string) User {
|
||||
t.Helper()
|
||||
if err := st.CreateGlobalUser("admin", "hash"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, err := st.GetUserByUsername("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.UpdateUser(u.ID, u.Username, u.PasswordHash, email); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, _ = st.GetUser(u.ID)
|
||||
return u
|
||||
}
|
||||
|
||||
// A link is taken once, not after it expires, and only for its purpose; a
|
||||
// newer one of the same purpose replaces it.
|
||||
func TestUserLinks(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
u := linkTestUser(t, st, "admin@example.org")
|
||||
now := time.Date(2026, 10, 17, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
if err := st.CreateUserLink("h1", u.ID, LinkReset, u.Email, now, now.Add(30*time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.PeekUserLink("h1", LinkConfirm, now); !errors.Is(err, ErrNoLink) {
|
||||
t.Errorf("a reset link taken as a confirmation one: %v", err)
|
||||
}
|
||||
if l, err := st.PeekUserLink("h1", LinkReset, now); err != nil || l.UserID != u.ID || l.Email != u.Email {
|
||||
t.Fatalf("peek = %+v, %v", l, err)
|
||||
}
|
||||
if _, err := st.PeekUserLink("h1", LinkReset, now.Add(30*time.Minute)); !errors.Is(err, ErrNoLink) {
|
||||
t.Errorf("an expired link was valid: %v", err)
|
||||
}
|
||||
// A second reset link leaves the first working: a request of anyone's
|
||||
// must not kill the link in the user's mailbox.
|
||||
if err := st.CreateUserLink("h2", u.ID, LinkReset, u.Email, now.Add(time.Minute), now.Add(31*time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if l, err := st.LatestUserLink(u.ID, LinkReset, now.Add(2*time.Minute)); err != nil || !l.CreatedAt.Equal(now.Add(time.Minute)) {
|
||||
t.Errorf("latest = %+v, %v", l, err)
|
||||
}
|
||||
if _, err := st.TakeUserLink("h1", LinkReset, now.Add(2*time.Minute)); err != nil {
|
||||
t.Errorf("the first reset link died with the second: %v", err)
|
||||
}
|
||||
if _, err := st.TakeUserLink("h2", LinkReset, now.Add(2*time.Minute)); err != nil {
|
||||
t.Fatalf("take: %v", err)
|
||||
}
|
||||
if _, err := st.TakeUserLink("h2", LinkReset, now.Add(3*time.Minute)); !errors.Is(err, ErrNoLink) {
|
||||
t.Errorf("a link was taken twice: %v", err)
|
||||
}
|
||||
if _, err := st.LatestUserLink(u.ID, LinkReset, now.Add(3*time.Minute)); !errors.Is(err, ErrNoLink) {
|
||||
t.Errorf("a taken link is still the latest: %v", err)
|
||||
}
|
||||
// A second confirmation link replaces the first: it confirms the
|
||||
// address saved last.
|
||||
if err := st.CreateUserLink("c1", u.ID, LinkConfirm, u.Email, now, now.Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateUserLink("c2", u.ID, LinkConfirm, u.Email, now.Add(time.Minute), now.Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.TakeUserLink("c1", LinkConfirm, now.Add(2*time.Minute)); !errors.Is(err, ErrNoLink) {
|
||||
t.Errorf("the replaced confirmation link was taken: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A reset link sets the password only while the account's address is still
|
||||
// the confirmed one it was sent to; then it and the user's other reset links
|
||||
// are gone. A link that does not hold changes nothing and is not used up.
|
||||
func TestTakeResetLink(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
u := linkTestUser(t, st, "admin@example.org")
|
||||
now := time.Date(2026, 10, 17, 10, 0, 0, 0, time.UTC)
|
||||
for _, h := range []string{"r1", "r2"} {
|
||||
if err := st.CreateUserLink(h, u.ID, LinkReset, u.Email, now, now.Add(30*time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Not confirmed yet: refused, and the link is still there.
|
||||
if _, err := st.TakeResetLink("r1", "new-hash", now); !errors.Is(err, ErrNoLink) {
|
||||
t.Fatalf("an unconfirmed address: %v", err)
|
||||
}
|
||||
if _, err := st.PeekUserLink("r1", LinkReset, now); err != nil {
|
||||
t.Fatalf("a refused take used the link up: %v", err)
|
||||
}
|
||||
if ok, _ := st.ConfirmEmail(u.ID, u.Email, now); !ok {
|
||||
t.Fatal("confirm")
|
||||
}
|
||||
id, err := st.TakeResetLink("r1", "new-hash", now)
|
||||
if err != nil || id != u.ID {
|
||||
t.Fatalf("take = %d, %v", id, err)
|
||||
}
|
||||
if got, _ := st.GetUser(u.ID); got.PasswordHash != "new-hash" {
|
||||
t.Errorf("hash = %q", got.PasswordHash)
|
||||
}
|
||||
if _, err := st.PeekUserLink("r2", LinkReset, now); !errors.Is(err, ErrNoLink) {
|
||||
t.Errorf("another reset link survived the reset: %v", err)
|
||||
}
|
||||
// A link of an address the account no longer has does not hold.
|
||||
if err := st.CreateUserLink("r3", u.ID, LinkReset, u.Email, now, now.Add(30*time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.UpdateUser(u.ID, u.Username, "new-hash", "other@example.org"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.TakeResetLink("r3", "third-hash", now); !errors.Is(err, ErrNoLink) {
|
||||
t.Errorf("a link of the old address set the password: %v", err)
|
||||
}
|
||||
if err := st.DeleteAllUserLinks(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.PeekUserLink("r3", LinkReset, now); !errors.Is(err, ErrNoLink) {
|
||||
t.Errorf("a link survived DeleteAllUserLinks: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Of two requests with one link, one takes it.
|
||||
func TestUserLinkTakenOnce(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
u := linkTestUser(t, st, "admin@example.org")
|
||||
now := time.Now()
|
||||
if err := st.CreateUserLink("h", u.ID, LinkReset, u.Email, now, now.Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
took := 0
|
||||
for range 8 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := st.TakeUserLink("h", LinkReset, now); err == nil {
|
||||
mu.Lock()
|
||||
took++
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if took != 1 {
|
||||
t.Errorf("the link was taken %d times", took)
|
||||
}
|
||||
}
|
||||
|
||||
// The mark follows the address: confirmed only while it is the address the
|
||||
// link was sent to, cleared when the address changes, kept when it does not.
|
||||
func TestEmailConfirmation(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
u := linkTestUser(t, st, "admin@example.org")
|
||||
if !u.EmailConfirmed.IsZero() {
|
||||
t.Fatal("a new address is confirmed")
|
||||
}
|
||||
now := time.Date(2026, 10, 17, 10, 0, 0, 0, time.UTC)
|
||||
if ok, err := st.ConfirmEmail(u.ID, "old@example.org", now); err != nil || ok {
|
||||
t.Errorf("another address confirmed: %v %v", ok, err)
|
||||
}
|
||||
if ok, err := st.ConfirmEmail(u.ID, "admin@example.org", now); err != nil || !ok {
|
||||
t.Fatalf("confirm: %v %v", ok, err)
|
||||
}
|
||||
u, _ = st.GetUser(u.ID)
|
||||
if !u.EmailConfirmed.Equal(now) {
|
||||
t.Fatalf("confirmed at %v", u.EmailConfirmed)
|
||||
}
|
||||
// Saving the same address keeps the mark; another clears it.
|
||||
if err := st.UpdateUser(u.ID, "admin2", u.PasswordHash, u.Email); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u, _ = st.GetUser(u.ID); u.EmailConfirmed.IsZero() {
|
||||
t.Error("the same address lost its mark")
|
||||
}
|
||||
if err := st.SaveUser(UserChange{ID: u.ID, Username: u.Username, Email: "new@example.org", Role: RoleGlobal}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u, _ = st.GetUser(u.ID); !u.EmailConfirmed.IsZero() {
|
||||
t.Error("a new address kept the old one's mark")
|
||||
}
|
||||
if ok, _ := st.ConfirmEmail(u.ID, "admin@example.org", now); ok {
|
||||
t.Error("a link of the old address confirmed the new one")
|
||||
}
|
||||
}
|
||||
+21
-9
@@ -42,6 +42,9 @@ type User struct {
|
||||
// Email is the user's own address — what the panel writes to, and what the
|
||||
// button beside a domain's typed report address fills it with.
|
||||
Email string
|
||||
// EmailConfirmed is when Email was confirmed by its link, zero while it
|
||||
// is not: only a confirmed address is sent a reset link (migration 0013).
|
||||
EmailConfirmed time.Time
|
||||
// AllDomains / AllInboundDomains widen a domain user's list to every
|
||||
// domain of that direction, including those added later; the id lists
|
||||
// are then ignored.
|
||||
@@ -55,19 +58,20 @@ type User struct {
|
||||
InboundDomainIDs []int64
|
||||
}
|
||||
|
||||
const userColumns = "id, username, password_hash, role, email, all_domains, all_inbound_domains, notify, created_at"
|
||||
const userColumns = "id, username, password_hash, role, email, all_domains, all_inbound_domains, notify, created_at, email_confirmed_at"
|
||||
|
||||
func scanUser(r scanRow) (User, error) {
|
||||
var (
|
||||
u User
|
||||
createdAt string
|
||||
u User
|
||||
createdAt, confirmed string
|
||||
)
|
||||
err := r.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.Email,
|
||||
&u.AllDomains, &u.AllInboundDomains, &u.Notify, &createdAt)
|
||||
&u.AllDomains, &u.AllInboundDomains, &u.Notify, &createdAt, &confirmed)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
u.EmailConfirmed, _ = time.Parse(time.RFC3339, confirmed)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
@@ -316,8 +320,8 @@ func (s *Store) SaveUser(c UserChange) error {
|
||||
// An empty hash keeps the stored one: the form read it when it was
|
||||
// opened, and the user may have changed it on Account since.
|
||||
res, err := tx.Exec(
|
||||
"UPDATE users SET username = ?, password_hash = COALESCE(NULLIF(?, ''), password_hash), email = ?, role = ? WHERE id = ?",
|
||||
c.Username, c.PasswordHash, c.Email, c.Role, c.ID,
|
||||
"UPDATE users SET username = ?, password_hash = COALESCE(NULLIF(?, ''), password_hash), email = ?, role = ?, "+keepConfirmed+" WHERE id = ?",
|
||||
c.Username, c.PasswordHash, c.Email, c.Role, c.Email, c.ID,
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
@@ -366,11 +370,19 @@ func requireAGlobal(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUser replaces a user's username, password hash and account e-mail.
|
||||
// keepConfirmed keeps the confirmation of an address that stays the same and
|
||||
// clears it for another; its placeholder takes the new address. SQLite
|
||||
// 3.53.4, https://sqlite.org/lang_update.html: every expression on the right
|
||||
// of SET sees the row as it was before the UPDATE, so email here is the old
|
||||
// address.
|
||||
const keepConfirmed = "email_confirmed_at = CASE WHEN email = ? THEN email_confirmed_at ELSE '' END"
|
||||
|
||||
// UpdateUser replaces a user's username, password hash and account e-mail; a
|
||||
// new e-mail is unconfirmed.
|
||||
func (s *Store) UpdateUser(id int64, username, passwordHash, email string) error {
|
||||
res, err := s.db.Exec(
|
||||
"UPDATE users SET username = ?, password_hash = ?, email = ? WHERE id = ?",
|
||||
username, passwordHash, email, id,
|
||||
"UPDATE users SET username = ?, password_hash = ?, email = ?, "+keepConfirmed+" WHERE id = ?",
|
||||
username, passwordHash, email, email, id,
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
@@ -33,6 +34,16 @@ type Module struct {
|
||||
loginLimiter *rateLimiter
|
||||
setupLimiter *rateLimiter
|
||||
passwordLimiter *rateLimiter
|
||||
// resetLimiter counts the requests for a reset link per client address,
|
||||
// linkLimiter the requests a link's page gets per client address, and
|
||||
// mailLimiter the links mailed to one user's address (links.go).
|
||||
resetLimiter *rateLimiter
|
||||
linkLimiter *rateLimiter
|
||||
mailLimiter *rateLimiter
|
||||
mailer Mailer
|
||||
// mailing counts the links being mailed after their answer, so a stop
|
||||
// waits for them (Wait) before the database closes.
|
||||
mailing sync.WaitGroup
|
||||
|
||||
trustedProxies []*net.IPNet
|
||||
}
|
||||
@@ -56,11 +67,20 @@ func New(st *store.Store, cfg Config, v *view.Engine, setupTokenPath string) *Mo
|
||||
// out of signing in (audit 2026-10-12, L15), and the one guessing
|
||||
// is whoever holds that user's session, wherever from.
|
||||
passwordLimiter: newRateLimiter(10, 15*time.Minute),
|
||||
trustedProxies: cfg.TrustedProxyCIDRs,
|
||||
resetLimiter: newRateLimiter(5, 15*time.Minute),
|
||||
linkLimiter: newRateLimiter(30, 15*time.Minute),
|
||||
// Five links an hour to one address, of either kind: three resets
|
||||
// asked for and a couple of confirmations is ordinary use, a mailbox
|
||||
// full of them is not.
|
||||
mailLimiter: newRateLimiter(5, time.Hour),
|
||||
trustedProxies: cfg.TrustedProxyCIDRs,
|
||||
}
|
||||
m.setupLimiter.startSweeper()
|
||||
m.loginLimiter.startSweeper()
|
||||
m.passwordLimiter.startSweeper()
|
||||
m.resetLimiter.startSweeper()
|
||||
m.linkLimiter.startSweeper()
|
||||
m.mailLimiter.startSweeper()
|
||||
// Made now rather than at the first unknown name, which would otherwise
|
||||
// pay for two bcrypt runs (review of audit stage 4).
|
||||
go noSuchUserHash()
|
||||
|
||||
@@ -88,7 +88,7 @@ func (m *Module) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
m.renderLogin(w, http.StatusOK, "")
|
||||
m.view.Render(w, http.StatusOK, "login", view.NewLogin(m.cfg.Hostname, "").WithInfo(loginInfo(r)))
|
||||
case http.MethodPost:
|
||||
m.submitLogin(w, r)
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/validate"
|
||||
"github.com/mixeme/selfpost/internal/web/view"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// The one-time links of password-reset (owner, 2026-10-13: a reset link goes
|
||||
// only to a confirmed address; 2026-10-17: notifications go to one confirmed
|
||||
// or not). A link is 256 random bits in a mail; the database keeps its
|
||||
// SHA-256 only, as a session's (store.CreateUserLink), so a copy of the
|
||||
// database or a backup opens nothing. Its address is the server's own name:
|
||||
// the Host a request names is the requester's to choose, and a reset link
|
||||
// built from it would carry the token to whichever host they chose.
|
||||
const (
|
||||
confirmLinkLife = 24 * time.Hour
|
||||
resetLinkLife = 30 * time.Minute
|
||||
// resetSpacing is how long after a reset link another is not sent: the
|
||||
// one in the mailbox works, and a stranger repeating a username cannot
|
||||
// spend the account's links an hour in a minute (security review,
|
||||
// 2026-10-17).
|
||||
resetSpacing = 10 * time.Minute
|
||||
)
|
||||
|
||||
// later runs f after the answer, counted for Wait.
|
||||
func (m *Module) later(f func()) {
|
||||
m.mailing.Add(1)
|
||||
go func() {
|
||||
defer m.mailing.Done()
|
||||
f()
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait waits, until ctx ends, for the links still being mailed: the panel's
|
||||
// stop calls it before the database closes.
|
||||
func (m *Module) Wait(ctx context.Context) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
m.mailing.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
logf("panel: stopped with a link still being mailed")
|
||||
}
|
||||
}
|
||||
|
||||
// Mailer sends the panel's own mail (notify.Mailer).
|
||||
type Mailer interface {
|
||||
Send(to, subject, body string) error
|
||||
}
|
||||
|
||||
// errNoMail is a link that cannot be sent: no address, no mailer, or no name
|
||||
// to put in it.
|
||||
var errNoMail = errors.New("no address to send a link to")
|
||||
|
||||
// SetMailer gives the module the mailer its links go out by.
|
||||
func (m *Module) SetMailer(ml Mailer) { m.mailer = ml }
|
||||
|
||||
// CanMail reports whether a link can be sent at all: a mailer, and the
|
||||
// server's name to put in it.
|
||||
func (m *Module) CanMail() bool { return m.mailer != nil && m.cfg.Hostname != "" }
|
||||
|
||||
// linkURL is the address of a link: https:// the server's own name, the path
|
||||
// and the token.
|
||||
func (m *Module) linkURL(path, token string) string {
|
||||
return "https://" + m.cfg.Hostname + path + token
|
||||
}
|
||||
|
||||
// AllowConfirmationLink says whether another confirmation link may be sent
|
||||
// to the user's address now: five an hour, whoever asks (the user from
|
||||
// Account, the global administrator from the user form) - a link is a mail
|
||||
// to that address, and nobody is to fill a mailbox with them.
|
||||
func (m *Module) AllowConfirmationLink(userID int64) bool {
|
||||
return m.mailLimiter.Allow("confirm:" + strconv.FormatInt(userID, 10))
|
||||
}
|
||||
|
||||
// SendConfirmation mails u's address a link that confirms it, replacing the
|
||||
// link sent before. The caller has asked AllowConfirmationLink.
|
||||
func (m *Module) SendConfirmation(u store.User) error {
|
||||
if u.Email == "" || m.mailer == nil || m.cfg.Hostname == "" {
|
||||
return errNoMail
|
||||
}
|
||||
token := randomToken(32)
|
||||
now := time.Now()
|
||||
if err := m.store.CreateUserLink(hashToken(token), u.ID, store.LinkConfirm, u.Email, now, now.Add(confirmLinkLife)); err != nil {
|
||||
return err
|
||||
}
|
||||
// The account is not named: the address may be a mistyped one, a
|
||||
// stranger's (security review, 2026-10-17); confirming also takes being
|
||||
// signed in as the account (HandleConfirm).
|
||||
body := "The address " + u.Email + " was given for an account of the SelfPost panel at " + m.cfg.Hostname +
|
||||
". Sign in to that account, then open this link to confirm it:\n\n" + m.linkURL("/confirm/", token) +
|
||||
"\n\nIt works once, for 24 hours. A confirmed address can be sent a link to reset the account's password. " +
|
||||
"If you did not expect this message, ignore it: nothing changes.\n"
|
||||
if err := m.mailer.Send(u.Email, "[SelfPost] Confirm your e-mail", body); err != nil {
|
||||
return err
|
||||
}
|
||||
logf("panel: user %d: a confirmation link was sent", u.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendConfirmationLater is SendConfirmation after the answer: a stuck
|
||||
// Postfix does not hold the form that saved the address (sendmail's own
|
||||
// time-out is 20 s). A link that cannot go is logged, and Account offers to
|
||||
// send it again.
|
||||
func (m *Module) SendConfirmationLater(u store.User) {
|
||||
m.later(func() {
|
||||
if err := m.SendConfirmation(u); err != nil {
|
||||
logf("panel: user %d: confirmation link not sent: %v", u.ID, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// HandleReset serves /login/reset: the form that asks for a reset link (GET)
|
||||
// and the request (POST). The answer to a request is the sign-in page with
|
||||
// the same words whatever the name - one that exists or not, with a
|
||||
// confirmed address or not - and the link, when there is one, goes out after
|
||||
// the answer, so the time to answer tells nothing either.
|
||||
func (m *Module) HandleReset(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
m.view.Render(w, http.StatusOK, "login-reset", view.NewReset(m.cfg.Hostname, ""))
|
||||
case http.MethodPost:
|
||||
if !m.resetLimiter.Allow(clientIP(r, m.trustedProxies)) {
|
||||
m.view.Render(w, http.StatusTooManyRequests, "login-reset", view.NewReset(m.cfg.Hostname, "Too many attempts. Please wait and try again."))
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
m.view.Render(w, http.StatusBadRequest, "login-reset", view.NewReset(m.cfg.Hostname, "Invalid form submission."))
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
m.later(func() { m.sendReset(username) })
|
||||
http.Redirect(w, r, "/login?reset=sent", http.StatusSeeOther)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// sendReset mails the account named username a reset link if its address is
|
||||
// confirmed, at most three an hour.
|
||||
func (m *Module) sendReset(username string) {
|
||||
if username == "" || m.mailer == nil || m.cfg.Hostname == "" {
|
||||
return
|
||||
}
|
||||
u, err := m.store.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
if !errors.Is(err, store.ErrUserNotFound) {
|
||||
logf("panel: reset: get user: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if u.Email == "" || u.EmailConfirmed.IsZero() {
|
||||
logf("panel: user %d asked for a reset link and has no confirmed e-mail; none was sent", u.ID)
|
||||
return
|
||||
}
|
||||
if l, err := m.store.LatestUserLink(u.ID, store.LinkReset, time.Now()); err == nil && time.Since(l.CreatedAt) < resetSpacing {
|
||||
logf("panel: user %d: a reset link went less than %s ago; none was sent", u.ID, resetSpacing)
|
||||
return
|
||||
}
|
||||
if !m.mailLimiter.Allow("reset:" + strconv.FormatInt(u.ID, 10)) {
|
||||
logf("panel: user %d: reset links asked for too often; none was sent", u.ID)
|
||||
return
|
||||
}
|
||||
token := randomToken(32)
|
||||
now := time.Now()
|
||||
if err := m.store.CreateUserLink(hashToken(token), u.ID, store.LinkReset, u.Email, now, now.Add(resetLinkLife)); err != nil {
|
||||
logf("panel: user %d: reset link: %v", u.ID, err)
|
||||
return
|
||||
}
|
||||
body := "Someone asked to reset the password of the SelfPost account " + u.Username + " at " + m.cfg.Hostname +
|
||||
". Open this link to set a new one:\n\n" + m.linkURL("/login/reset/", token) +
|
||||
"\n\nIt works once, for 30 minutes. If it was not you, ignore this message: the password stays as it is.\n"
|
||||
if err := m.mailer.Send(u.Email, "[SelfPost] Reset your password", body); err != nil {
|
||||
logf("panel: user %d: reset link not sent: %v", u.ID, err)
|
||||
return
|
||||
}
|
||||
logf("panel: user %d: a reset link was sent", u.ID)
|
||||
}
|
||||
|
||||
// linkToken is the token of a link path under prefix; "" for anything else.
|
||||
func linkToken(path, prefix string) string {
|
||||
token := strings.TrimPrefix(path, prefix)
|
||||
if token == path || token == "" || strings.Contains(token, "/") {
|
||||
return ""
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
const deadLink = "This link has expired or was already used."
|
||||
|
||||
// HandleResetLink serves /login/reset/<token>: the new password's form (GET)
|
||||
// and setting it (POST). Setting it takes the link, ends every session of the
|
||||
// account and every other reset link, and leaves the person at sign-in.
|
||||
func (m *Module) HandleResetLink(w http.ResponseWriter, r *http.Request) {
|
||||
if !m.linkLimiter.Allow(clientIP(r, m.trustedProxies)) {
|
||||
http.Error(w, "too many requests", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
token := linkToken(r.URL.Path, "/login/reset/")
|
||||
if token == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
dead := func() {
|
||||
m.view.Render(w, http.StatusNotFound, "login-reset", view.NewReset(m.cfg.Hostname, deadLink+" Ask for a new one."))
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
l, err := m.store.PeekUserLink(hashToken(token), store.LinkReset, time.Now())
|
||||
if err != nil {
|
||||
dead()
|
||||
return
|
||||
}
|
||||
u, ok := m.linkUser(l)
|
||||
if !ok {
|
||||
dead()
|
||||
return
|
||||
}
|
||||
m.view.Render(w, http.StatusOK, "login-reset-set", view.NewResetSet(m.cfg.Hostname, u.Username, token, ""))
|
||||
case http.MethodPost:
|
||||
m.setPasswordFromLink(w, r, token, dead)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// linkUser is the user of a reset link while the link still holds: the
|
||||
// account's address is the one it was sent to, and confirmed.
|
||||
func (m *Module) linkUser(l store.UserLink) (store.User, bool) {
|
||||
u, err := m.store.GetUser(l.UserID)
|
||||
if err != nil {
|
||||
return store.User{}, false
|
||||
}
|
||||
return u, u.Email == l.Email && !u.EmailConfirmed.IsZero()
|
||||
}
|
||||
|
||||
func (m *Module) setPasswordFromLink(w http.ResponseWriter, r *http.Request, token string, dead func()) {
|
||||
l, err := m.store.PeekUserLink(hashToken(token), store.LinkReset, time.Now())
|
||||
if err != nil {
|
||||
dead()
|
||||
return
|
||||
}
|
||||
u, ok := m.linkUser(l)
|
||||
if !ok {
|
||||
dead()
|
||||
return
|
||||
}
|
||||
fail := func(status int, msg string) {
|
||||
m.view.Render(w, status, "login-reset-set", view.NewResetSet(m.cfg.Hostname, u.Username, token, msg))
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
fail(http.StatusBadRequest, "Invalid form submission.")
|
||||
return
|
||||
}
|
||||
password := r.PostFormValue("new_password")
|
||||
if password != r.PostFormValue("new_password_confirm") {
|
||||
fail(http.StatusBadRequest, "Passwords do not match.")
|
||||
return
|
||||
}
|
||||
if err := validate.AdminPassword(password); err != nil {
|
||||
fail(http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
|
||||
if err != nil {
|
||||
logf("panel: reset: hashing password failed: %v", err)
|
||||
fail(http.StatusInternalServerError, "Internal error. Please try again.")
|
||||
return
|
||||
}
|
||||
// Taken only now, so a mistyped repeat does not use the link up; taken
|
||||
// and the password set in one transaction, which also checks the
|
||||
// address again (store.TakeResetLink): of two requests with the link one
|
||||
// sets it, and a write that fails leaves the link to try again.
|
||||
if _, err := m.store.TakeResetLink(hashToken(token), string(hash), time.Now()); err != nil {
|
||||
if errors.Is(err, store.ErrNoLink) {
|
||||
dead()
|
||||
return
|
||||
}
|
||||
logf("panel: reset: set password of user %d: %v", u.ID, err)
|
||||
fail(http.StatusInternalServerError, "Internal error. Please try again.")
|
||||
return
|
||||
}
|
||||
m.sessions.DestroyAll(u.ID)
|
||||
logf("panel: user %d set a new password from a reset link; every session was ended", u.ID)
|
||||
http.Redirect(w, r, "/login?reset=done", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// HandleConfirm serves /confirm/<token>: the address and a button (GET), and
|
||||
// confirming it (POST). The token proves its holder reads the mailbox; being
|
||||
// signed in as the account proves the mailbox is the account's - without it
|
||||
// a mistyped address would hand the account to whoever reads it, by a reset
|
||||
// link (security review, 2026-10-17).
|
||||
func (m *Module) HandleConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
if !m.linkLimiter.Allow(clientIP(r, m.trustedProxies)) {
|
||||
http.Error(w, "too many requests", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
token := linkToken(r.URL.Path, "/confirm/")
|
||||
if token == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
dead := func(msg string) {
|
||||
p := view.NewConfirm(m.cfg.Hostname, "", "", "")
|
||||
p.Error = msg
|
||||
m.view.Render(w, http.StatusNotFound, "confirm-email", p)
|
||||
}
|
||||
signIn := func(w http.ResponseWriter) {
|
||||
p := view.NewConfirm(m.cfg.Hostname, "", "", "")
|
||||
p.Error = "Sign in to the account in this browser, then open the link again."
|
||||
p.SignIn = true
|
||||
m.view.Render(w, http.StatusForbidden, "confirm-email", p)
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
l, err := m.store.PeekUserLink(hashToken(token), store.LinkConfirm, time.Now())
|
||||
if err != nil {
|
||||
dead(deadLink + " Send a new one from Account.")
|
||||
return
|
||||
}
|
||||
u, err := m.store.GetUser(l.UserID)
|
||||
if err != nil || u.Email != l.Email {
|
||||
dead("The account's e-mail has changed since this link was sent.")
|
||||
return
|
||||
}
|
||||
if !m.signedInAs(r, l.UserID) {
|
||||
signIn(w)
|
||||
return
|
||||
}
|
||||
m.view.Render(w, http.StatusOK, "confirm-email", view.NewConfirm(m.cfg.Hostname, u.Username, u.Email, token))
|
||||
case http.MethodPost:
|
||||
// Looked at first and taken only for its own account's session, so
|
||||
// a stranger's press does not use the link up.
|
||||
l, err := m.store.PeekUserLink(hashToken(token), store.LinkConfirm, time.Now())
|
||||
if err != nil {
|
||||
dead(deadLink + " Send a new one from Account.")
|
||||
return
|
||||
}
|
||||
if !m.signedInAs(r, l.UserID) {
|
||||
signIn(w)
|
||||
return
|
||||
}
|
||||
if l, err = m.store.TakeUserLink(hashToken(token), store.LinkConfirm, time.Now()); err != nil {
|
||||
dead(deadLink + " Send a new one from Account.")
|
||||
return
|
||||
}
|
||||
ok, err := m.store.ConfirmEmail(l.UserID, l.Email, time.Now())
|
||||
if err != nil {
|
||||
logf("panel: confirm: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
dead("The account's e-mail has changed since this link was sent.")
|
||||
return
|
||||
}
|
||||
u, _ := m.store.GetUser(l.UserID)
|
||||
logf("panel: user %d confirmed their e-mail", l.UserID)
|
||||
p := view.NewConfirm(m.cfg.Hostname, u.Username, l.Email, "")
|
||||
p.Done = true
|
||||
m.view.Render(w, http.StatusOK, "confirm-email", p)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// signedInAs reports whether the request carries a live session of userID.
|
||||
func (m *Module) signedInAs(r *http.Request, userID int64) bool {
|
||||
token, ok := m.sessionToken(r)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
id, ok := m.sessions.Lookup(token)
|
||||
return ok && id == userID
|
||||
}
|
||||
|
||||
// loginInfo is the sign-in page's success flash for what brought the person
|
||||
// back to it.
|
||||
func loginInfo(r *http.Request) string {
|
||||
switch r.URL.Query().Get("reset") {
|
||||
case "sent":
|
||||
return "If that account has a confirmed e-mail, the link is on its way. It works once, for 30 minutes."
|
||||
case "done":
|
||||
return "Password set. Sign in with it."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// sentMail is one message a recordingMailer was given.
|
||||
type sentMail struct{ to, subject, body string }
|
||||
|
||||
// recordingMailer keeps what would have been sent.
|
||||
type recordingMailer struct{ sent chan sentMail }
|
||||
|
||||
func (r *recordingMailer) Send(to, subject, body string) error {
|
||||
r.sent <- sentMail{to, subject, body}
|
||||
return nil
|
||||
}
|
||||
|
||||
// next is the next message, or none within a moment: a reset link goes out
|
||||
// after the answer.
|
||||
func (r *recordingMailer) next(t *testing.T) (sentMail, bool) {
|
||||
t.Helper()
|
||||
select {
|
||||
case m := <-r.sent:
|
||||
return m, true
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
return sentMail{}, false
|
||||
}
|
||||
}
|
||||
|
||||
// linkModule is a panel named mail.example.org with the administrator of
|
||||
// moduleWithAdmin, its address admin@example.org, confirmed when confirmed.
|
||||
func linkModule(t *testing.T, confirmed bool) (*Module, *recordingMailer) {
|
||||
t.Helper()
|
||||
m := moduleWithAdmin(t)
|
||||
m.cfg.Hostname = "mail.example.org"
|
||||
ml := &recordingMailer{sent: make(chan sentMail, 8)}
|
||||
m.SetMailer(ml)
|
||||
u, err := m.store.GetUserByUsername("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.store.UpdateUser(u.ID, u.Username, u.PasswordHash, "admin@example.org"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if confirmed {
|
||||
if ok, err := m.store.ConfirmEmail(u.ID, "admin@example.org", time.Now()); err != nil || !ok {
|
||||
t.Fatalf("confirm: %v %v", ok, err)
|
||||
}
|
||||
}
|
||||
return m, ml
|
||||
}
|
||||
|
||||
func postForm(h http.HandlerFunc, target, remoteAddr string, form url.Values) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.RemoteAddr = remoteAddr
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, r)
|
||||
return rec
|
||||
}
|
||||
|
||||
func get(h http.HandlerFunc, target string) *httptest.ResponseRecorder {
|
||||
return getAs(h, target, "")
|
||||
}
|
||||
|
||||
// getAs is get with the session cookie of session, none for "".
|
||||
func getAs(h http.HandlerFunc, target, session string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(http.MethodGet, target, nil)
|
||||
r.RemoteAddr = "192.0.2.1:1234"
|
||||
if session != "" {
|
||||
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: session})
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, r)
|
||||
return rec
|
||||
}
|
||||
|
||||
// postAs is a POST of no fields with the session cookie of session.
|
||||
func postAs(h http.HandlerFunc, target, session string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(http.MethodPost, target, nil)
|
||||
r.RemoteAddr = "192.0.2.1:1234"
|
||||
if session != "" {
|
||||
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: session})
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, r)
|
||||
return rec
|
||||
}
|
||||
|
||||
var resetLinkRe = regexp.MustCompile(`https://mail\.example\.org/login/reset/([A-Za-z0-9_-]{43})\n`)
|
||||
|
||||
// The answer is the same for every name; only an account with a confirmed
|
||||
// address is sent a link, and the link names the server, whatever Host the
|
||||
// request named.
|
||||
func TestResetSendsALinkOnlyToAConfirmedAddress(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
confirmed bool
|
||||
username string
|
||||
mail bool
|
||||
}{
|
||||
{"unconfirmed", false, "admin", false},
|
||||
{"unknown name", true, "nobody", false},
|
||||
{"confirmed", true, "admin", true},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
m, ml := linkModule(t, c.confirmed)
|
||||
r := httptest.NewRequest(http.MethodPost, "http://evil.example.net/login/reset",
|
||||
strings.NewReader(url.Values{"username": {c.username}}.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.RemoteAddr = "192.0.2.1:1234"
|
||||
rec := httptest.NewRecorder()
|
||||
m.HandleReset(rec, r)
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/login?reset=sent" {
|
||||
t.Fatalf("answer %d %q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
got, sent := ml.next(t)
|
||||
if sent != c.mail {
|
||||
t.Fatalf("mail sent = %v, want %v", sent, c.mail)
|
||||
}
|
||||
if !sent {
|
||||
return
|
||||
}
|
||||
if got.to != "admin@example.org" || got.subject != "[SelfPost] Reset your password" ||
|
||||
!resetLinkRe.MatchString(got.body) || strings.Contains(got.body, "evil") {
|
||||
t.Errorf("mail = %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
// The sign-in page it lands on says the same whatever happened.
|
||||
m, _ := linkModule(t, false)
|
||||
if body := get(m.HandleLogin, "/login?reset=sent").Body.String(); !strings.Contains(body,
|
||||
"If that account has a confirmed e-mail, the link is on its way. It works once, for 30 minutes.") {
|
||||
t.Errorf("sign-in after a request:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func askReset(t *testing.T, m *Module, ml *recordingMailer) string {
|
||||
t.Helper()
|
||||
postForm(m.HandleReset, "/login/reset", "192.0.2.1:1234", url.Values{"username": {"admin"}})
|
||||
got, ok := ml.next(t)
|
||||
if !ok {
|
||||
t.Fatal("no reset link was sent")
|
||||
}
|
||||
return resetLinkRe.FindStringSubmatch(got.body)[1]
|
||||
}
|
||||
|
||||
// A link opens the form; a mistyped repeat does not use it up; the password
|
||||
// it sets is the one that signs in, every session ends, and the link works
|
||||
// once.
|
||||
func TestResetLinkSetsThePasswordOnce(t *testing.T) {
|
||||
m, ml := linkModule(t, true)
|
||||
session := mustCreate(t, m.sessions, "admin")
|
||||
token := askReset(t, m, ml)
|
||||
path := "/login/reset/" + token
|
||||
|
||||
page := get(m.HandleResetLink, path)
|
||||
if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), "mail.example.org · admin") ||
|
||||
!strings.Contains(page.Body.String(), `action="`+path+`"`) {
|
||||
t.Fatalf("the link's page: %d\n%s", page.Code, page.Body.String())
|
||||
}
|
||||
const fresh = "a-new-password-of-mine"
|
||||
if rec := postForm(m.HandleResetLink, path, "192.0.2.1:1234", url.Values{"new_password": {fresh}, "new_password_confirm": {"other"}}); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("a mistyped repeat: %d", rec.Code)
|
||||
}
|
||||
if rec := postForm(m.HandleResetLink, path, "192.0.2.1:1234", url.Values{"new_password": {"short"}, "new_password_confirm": {"short"}}); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("a short password: %d", rec.Code)
|
||||
}
|
||||
rec := postForm(m.HandleResetLink, path, "192.0.2.1:1234", url.Values{"new_password": {fresh}, "new_password_confirm": {fresh}})
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/login?reset=done" {
|
||||
t.Fatalf("set: %d %q\n%s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
|
||||
}
|
||||
u, _ := m.store.GetUserByUsername("admin")
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(fresh)) != nil {
|
||||
t.Error("the new password does not sign in")
|
||||
}
|
||||
if _, found, _ := m.store.LookupSession(hashToken(session)); found {
|
||||
t.Error("a session survived the reset")
|
||||
}
|
||||
if again := get(m.HandleResetLink, path); again.Code != http.StatusNotFound || !strings.Contains(again.Body.String(), deadLink) {
|
||||
t.Errorf("the link worked twice: %d", again.Code)
|
||||
}
|
||||
if again := postForm(m.HandleResetLink, path, "192.0.2.1:1234", url.Values{"new_password": {fresh + "x"}, "new_password_confirm": {fresh + "x"}}); again.Code != http.StatusNotFound {
|
||||
t.Errorf("the link set a password twice: %d", again.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A link sent to an address the account no longer has does not work.
|
||||
func TestResetLinkDiesWithTheAddress(t *testing.T) {
|
||||
m, ml := linkModule(t, true)
|
||||
token := askReset(t, m, ml)
|
||||
u, _ := m.store.GetUserByUsername("admin")
|
||||
if err := m.store.UpdateUser(u.ID, u.Username, u.PasswordHash, "other@example.org"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := get(m.HandleResetLink, "/login/reset/"+token); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("a link of the old address opened: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Requests for a link are counted per address: the sixth in 15 minutes is
|
||||
// refused, and it says so without saying anything of the name.
|
||||
func TestResetRequestsAreLimited(t *testing.T) {
|
||||
m, _ := linkModule(t, false)
|
||||
for i := range 5 {
|
||||
if rec := postForm(m.HandleReset, "/login/reset", "192.0.2.9:1", url.Values{"username": {"x"}}); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("request %d: %d", i+1, rec.Code)
|
||||
}
|
||||
}
|
||||
if rec := postForm(m.HandleReset, "/login/reset", "192.0.2.9:1", url.Values{"username": {"x"}}); rec.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("the sixth request: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
var confirmLinkRe = regexp.MustCompile(`https://mail\.example\.org/confirm/([A-Za-z0-9_-]{43})\n`)
|
||||
|
||||
// The confirmation link's page changes nothing; its button confirms the
|
||||
// address, once - and only for its own account's session: a mistyped address
|
||||
// is a stranger's, and the mail does not name the account.
|
||||
func TestConfirmLink(t *testing.T) {
|
||||
m, ml := linkModule(t, false)
|
||||
u, _ := m.store.GetUserByUsername("admin")
|
||||
if err := m.SendConfirmation(u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok := ml.next(t)
|
||||
if !ok || got.to != "admin@example.org" || got.subject != "[SelfPost] Confirm your e-mail" || !confirmLinkRe.MatchString(got.body) {
|
||||
t.Fatalf("mail = %+v %v", got, ok)
|
||||
}
|
||||
if strings.Contains(got.body, "admin ") || strings.Contains(got.body, "account admin") {
|
||||
t.Errorf("the confirmation mail names the account:\n%s", got.body)
|
||||
}
|
||||
path := "/confirm/" + confirmLinkRe.FindStringSubmatch(got.body)[1]
|
||||
// Without a session, or with another account's, the link asks to sign in
|
||||
// and is not used up.
|
||||
mustCreate(t, m.sessions, "other")
|
||||
other := mustCreate(t, m.sessions, "other")
|
||||
for name, session := range map[string]string{"no session": "", "another account": other} {
|
||||
if rec := getAs(m.HandleConfirm, path, session); rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), `href="/login"`) {
|
||||
t.Errorf("%s: GET %d", name, rec.Code)
|
||||
}
|
||||
if rec := postAs(m.HandleConfirm, path, session); rec.Code != http.StatusForbidden {
|
||||
t.Errorf("%s: POST %d", name, rec.Code)
|
||||
}
|
||||
}
|
||||
session := mustCreate(t, m.sessions, "admin")
|
||||
page := getAs(m.HandleConfirm, path, session)
|
||||
if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), `action="`+path+`"`) ||
|
||||
!strings.Contains(page.Body.String(), "admin@example.org") {
|
||||
t.Fatalf("the link's page: %d\n%s", page.Code, page.Body.String())
|
||||
}
|
||||
if u, _ = m.store.GetUser(u.ID); !u.EmailConfirmed.IsZero() {
|
||||
t.Fatal("opening the link confirmed the address")
|
||||
}
|
||||
if rec := postAs(m.HandleConfirm, path, session); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "E-mail confirmed") {
|
||||
t.Fatalf("confirm: %d\n%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if u, _ = m.store.GetUser(u.ID); u.EmailConfirmed.IsZero() {
|
||||
t.Fatal("the button did not confirm the address")
|
||||
}
|
||||
if rec := postAs(m.HandleConfirm, path, session); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("the link worked twice: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A second request within resetSpacing sends nothing and leaves the first
|
||||
// link working: a stranger repeating a username neither kills the link in
|
||||
// the mailbox nor spends the account's links.
|
||||
func TestResetRequestsAreSpaced(t *testing.T) {
|
||||
m, ml := linkModule(t, true)
|
||||
first := askReset(t, m, ml)
|
||||
postForm(m.HandleReset, "/login/reset", "192.0.2.2:1234", url.Values{"username": {"admin"}})
|
||||
if got, sent := ml.next(t); sent {
|
||||
t.Fatalf("a second link went within %s: %+v", resetSpacing, got)
|
||||
}
|
||||
if rec := get(m.HandleResetLink, "/login/reset/"+first); rec.Code != http.StatusOK {
|
||||
t.Errorf("the first link died: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A link path that is not one is not found.
|
||||
func TestLinkPathsWithoutAToken(t *testing.T) {
|
||||
m, _ := linkModule(t, true)
|
||||
for _, p := range []string{"/login/reset/", "/login/reset/a/b", "/confirm/"} {
|
||||
h := m.HandleResetLink
|
||||
if strings.HasPrefix(p, "/confirm/") {
|
||||
h = m.HandleConfirm
|
||||
}
|
||||
if rec := get(h, p); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("%s: %d", p, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ var routes = struct{ always, inbound, dmarc []string }{
|
||||
"GET /server/users/{uid}/delete", "POST /server/users/{uid}/delete",
|
||||
"GET /server/settings", "POST /server/settings", "POST /server/settings/dns-recheck",
|
||||
"GET /server/components",
|
||||
"GET /account", "POST /account/profile", "POST /account/password",
|
||||
"GET /account", "POST /account/profile", "POST /account/password", "POST /account/email/confirm",
|
||||
"GET /help",
|
||||
},
|
||||
inbound: []string{
|
||||
@@ -163,7 +163,8 @@ func TestRoutesFollowNavigation(t *testing.T) {
|
||||
}
|
||||
|
||||
// What stays as it was.
|
||||
for _, p := range []string{"/healthz", "/license", "/static/panel.css", "/setup/token", "/login", "/logout"} {
|
||||
for _, p := range []string{"/healthz", "/license", "/static/panel.css", "/setup/token", "/login", "/logout",
|
||||
"/login/reset", "/login/reset/token", "/confirm/token"} {
|
||||
if got := answers(public, http.MethodGet, p); got == "" || got == "/" {
|
||||
t.Errorf("GET %s is no longer a public route", p)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,9 @@ type Config struct {
|
||||
SignSocket string
|
||||
SignLocalSocket string
|
||||
JournalSocket string
|
||||
SenderSocket string
|
||||
// InboundJournalSocket is the inbound journal milter's (Inbound › Log).
|
||||
InboundJournalSocket string
|
||||
SenderSocket string
|
||||
// Level-1 Postfix anvil backstop (env RATE_LIMIT_*), shown in the panel
|
||||
// and used to cap domain/app level-2 ceilings (guide § Rate limiting).
|
||||
RateLimitMessagesPerIP int
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/validate"
|
||||
@@ -60,6 +61,11 @@ func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, u store.User, f accountForm) {
|
||||
page := view.NewAccount(h.shellMeta(r), string(u.Role))
|
||||
page.Username, page.Email, page.Notify = f.Username, f.Email, f.Notify
|
||||
var sent time.Time
|
||||
if l, err := h.store.LatestUserLink(u.ID, store.LinkConfirm, time.Now()); err == nil && l.Email == u.Email {
|
||||
sent = l.CreatedAt
|
||||
}
|
||||
page.WithEmailState(u.Email, !u.EmailConfirmed.IsZero(), sent)
|
||||
page.WithResult(accountFlash(r), f.Err)
|
||||
h.view.Render(w, status, "account", page)
|
||||
}
|
||||
@@ -68,6 +74,10 @@ func accountFlash(r *http.Request) string {
|
||||
switch r.URL.Query().Get("done") {
|
||||
case "profile":
|
||||
return "Profile saved."
|
||||
case "profile-link":
|
||||
return "Profile saved. A link to confirm the new address is on its way."
|
||||
case "link":
|
||||
return "A new link is on its way."
|
||||
case "password":
|
||||
return "Password changed. Any other signed-in sessions were signed out."
|
||||
}
|
||||
@@ -108,6 +118,25 @@ func (h *Handlers) HandleAccountProfile(w http.ResponseWriter, r *http.Request)
|
||||
fail(http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
// The e-mail is where a reset link goes once it is confirmed, so
|
||||
// changing it asks for the password, as changing the password does
|
||||
// (roadmap password-reset; 2.0 security review), on the same limit.
|
||||
emailChanged := f.Email != u.Email
|
||||
if emailChanged {
|
||||
if !h.auth.AllowPasswordChange(u.ID) {
|
||||
fail(http.StatusTooManyRequests, "Too many attempts. Please wait and try again.")
|
||||
return
|
||||
}
|
||||
current := r.PostFormValue("current_password")
|
||||
if current == "" {
|
||||
fail(http.StatusUnauthorized, "Your current password is needed to change the e-mail.")
|
||||
return
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(current)); err != nil {
|
||||
fail(http.StatusUnauthorized, "Current password is incorrect.")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := h.store.UpdateUser(u.ID, f.Username, u.PasswordHash, f.Email); err != nil {
|
||||
if errors.Is(err, store.ErrUserExists) {
|
||||
fail(http.StatusConflict, "That username is already in use.")
|
||||
@@ -125,9 +154,54 @@ func (h *Handlers) HandleAccountProfile(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
logf("panel: user %d profile updated (username: %t, e-mail: %t, notifications: %t)", u.ID, f.Username != u.Username, f.Email != u.Email, f.Notify)
|
||||
if emailChanged && f.Email != "" && h.sendConfirmation(u.ID) {
|
||||
http.Redirect(w, r, "/account?done=profile-link", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/account?done=profile", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// HandleAccountConfirmLink sends the account's unconfirmed address its link
|
||||
// again (Account, "Send the link again").
|
||||
func (h *Handlers) HandleAccountConfirmLink(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := h.accountUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if u.Email == "" || !u.EmailConfirmed.IsZero() {
|
||||
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
f := formOf(u)
|
||||
if !h.auth.AllowConfirmationLink(u.ID) {
|
||||
f.Err = "Too many links asked for. Please wait and try again."
|
||||
h.renderAccount(w, r, http.StatusTooManyRequests, u, f)
|
||||
return
|
||||
}
|
||||
h.auth.SendConfirmationLater(u)
|
||||
http.Redirect(w, r, "/account?done=link", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// sendConfirmation mails the user's unconfirmed address its link after the
|
||||
// answer, within the limit of links to one address; whether one was sent off.
|
||||
// A link that cannot go is logged: the address is saved all the same, and
|
||||
// Account offers to send it again.
|
||||
func (h *Handlers) sendConfirmation(userID int64) bool {
|
||||
if h.auth == nil || !h.auth.CanMail() {
|
||||
return false
|
||||
}
|
||||
u, err := h.store.GetUser(userID)
|
||||
if err != nil || u.Email == "" || !u.EmailConfirmed.IsZero() {
|
||||
return false
|
||||
}
|
||||
if !h.auth.AllowConfirmationLink(u.ID) {
|
||||
logf("panel: user %d: confirmation links asked for too often; none was sent", u.ID)
|
||||
return false
|
||||
}
|
||||
h.auth.SendConfirmationLater(u)
|
||||
return true
|
||||
}
|
||||
|
||||
// HandleAccountPassword changes the password. It asks for the current one and
|
||||
// is rate-limited like a sign-in: the form is a place to guess a password from
|
||||
// an unattended session.
|
||||
@@ -178,6 +252,10 @@ func (h *Handlers) HandleAccountPassword(w http.ResponseWriter, r *http.Request)
|
||||
if token, ok := h.auth.SessionToken(r); ok {
|
||||
h.auth.DestroyOtherSessions(u.ID, token)
|
||||
}
|
||||
// A password set another way leaves no reset link behind it.
|
||||
if err := h.store.DeleteUserLinks(u.ID, store.LinkReset); err != nil {
|
||||
logf("panel: account: %v", err)
|
||||
}
|
||||
logf("panel: user %d changed their password", u.ID)
|
||||
http.Redirect(w, r, "/account?done=password", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -32,10 +36,11 @@ func TestAccountPageShowsTwoForms(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The profile form saves the name and the e-mail without asking for the
|
||||
// password and without changing the password or any domain's report address.
|
||||
// The profile form saves the name and the e-mail - a new e-mail with the
|
||||
// current password - without changing the password or any domain's report
|
||||
// address.
|
||||
func TestAccountProfile(t *testing.T) {
|
||||
h, _ := settingsServer(t)
|
||||
h, password := settingsServer(t)
|
||||
before, _ := h.store.GetUser(globalPrincipal.ID)
|
||||
d, _ := h.store.AddDomain("example.com", "mail")
|
||||
if err := h.store.SetDomainDMARCAddress(d.ID, "reports@hub.example"); err != nil {
|
||||
@@ -43,7 +48,7 @@ func TestAccountProfile(t *testing.T) {
|
||||
}
|
||||
|
||||
rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil,
|
||||
url.Values{"username": {"operator"}, "email": {"mix@example.org"}})
|
||||
url.Values{"username": {"operator"}, "email": {"mix@example.org"}, "current_password": {password}})
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/account?done=profile" {
|
||||
t.Fatalf("POST /account/profile = %d to %q:\n%s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
|
||||
}
|
||||
@@ -70,12 +75,91 @@ func TestAccountProfile(t *testing.T) {
|
||||
if _, err := h.store.CreateUser("taken", "h", store.RoleGlobal, store.Reach{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec = postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil, url.Values{"username": {"taken"}, "email": {"other@example.org"}})
|
||||
rec = postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil, url.Values{"username": {"taken"}, "email": {"other@example.org"}, "current_password": {password}})
|
||||
if u, _ = h.store.GetUser(globalPrincipal.ID); rec.Code != http.StatusConflict || u.Username != "operator" || u.Email != "mix@example.org" {
|
||||
t.Errorf("a taken username = %d, profile now %q / %q", rec.Code, u.Username, u.Email)
|
||||
}
|
||||
}
|
||||
|
||||
// linkMailer keeps the links the panel would have mailed.
|
||||
type linkMailer struct{ sent []string }
|
||||
|
||||
func (r *linkMailer) Send(to, subject, body string) error {
|
||||
r.sent = append(r.sent, to+" | "+subject+" | "+body)
|
||||
return nil
|
||||
}
|
||||
|
||||
// sentBy is what ml has been given once h's links sent after their answers
|
||||
// have gone.
|
||||
func sentBy(h *Handlers, ml *linkMailer) []string {
|
||||
h.auth.Wait(context.Background())
|
||||
return ml.sent
|
||||
}
|
||||
|
||||
// withMail gives h an auth module that mails, as mail.example.org, to a
|
||||
// linkMailer.
|
||||
func withMail(t *testing.T, h *Handlers) *linkMailer {
|
||||
t.Helper()
|
||||
h.auth = auth.New(h.store, auth.Config{Hostname: "mail.example.org"}, h.view, filepath.Join(t.TempDir(), "setup-token"))
|
||||
ml := &linkMailer{}
|
||||
h.auth.SetMailer(ml)
|
||||
return ml
|
||||
}
|
||||
|
||||
// The e-mail is where a reset link goes once confirmed, so changing it asks
|
||||
// for the password; the new address is sent its link and shows as not
|
||||
// confirmed, with Send the link again; a confirmed address shows as such and
|
||||
// keeps its mark when only the name changes.
|
||||
func TestAccountEmailIsConfirmedByItsLink(t *testing.T) {
|
||||
h, password := settingsServer(t)
|
||||
ml := withMail(t, h)
|
||||
change := url.Values{"username": {"admin"}, "email": {"mix@example.org"}}
|
||||
for name, pw := range map[string]string{"no password": "", "wrong password": "nope"} {
|
||||
form := url.Values{"username": change["username"], "email": change["email"], "current_password": {pw}}
|
||||
if rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil, form); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s: %d", name, rec.Code)
|
||||
}
|
||||
}
|
||||
if u, _ := h.store.GetUser(globalPrincipal.ID); u.Email != "" || len(sentBy(h, ml)) != 0 {
|
||||
t.Fatalf("a refused change saved %q, sent %d", u.Email, len(sentBy(h, ml)))
|
||||
}
|
||||
change.Set("current_password", password)
|
||||
rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil, change)
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/account?done=profile-link" {
|
||||
t.Fatalf("change: %d %q\n%s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
|
||||
}
|
||||
if len(sentBy(h, ml)) != 1 || !strings.HasPrefix(sentBy(h, ml)[0], "mix@example.org | [SelfPost] Confirm your e-mail | ") ||
|
||||
!strings.Contains(sentBy(h, ml)[0], "https://mail.example.org/confirm/") {
|
||||
t.Fatalf("sent = %q", sentBy(h, ml))
|
||||
}
|
||||
out := getBody(t, h.HandleAccount, "/account")
|
||||
for _, want := range []string{`<span class="tag is-warning is-light">not confirmed</span>`, `action="/account/email/confirm"`, `A link was sent to`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("account page missing %q", want)
|
||||
}
|
||||
}
|
||||
// Send the link again: a second link.
|
||||
if rec := postFormAs(h.HandleAccountConfirmLink, globalPrincipal, "/account/email/confirm", nil, nil); rec.Code != http.StatusSeeOther || len(sentBy(h, ml)) != 2 {
|
||||
t.Fatalf("send again: %d, sent %d", rec.Code, len(sentBy(h, ml)))
|
||||
}
|
||||
// Confirmed: the tag says so, the form is gone, and a new name keeps it
|
||||
// without the password.
|
||||
if ok, _ := h.store.ConfirmEmail(globalPrincipal.ID, "mix@example.org", time.Now()); !ok {
|
||||
t.Fatal("confirm")
|
||||
}
|
||||
out = getBody(t, h.HandleAccount, "/account")
|
||||
if !strings.Contains(out, `<span class="tag is-success is-light">confirmed</span>`) || strings.Contains(out, `action="/account/email/confirm"`) {
|
||||
t.Errorf("a confirmed address:\n%s", out)
|
||||
}
|
||||
if rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil,
|
||||
url.Values{"username": {"operator"}, "email": {"mix@example.org"}}); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("a new name: %d", rec.Code)
|
||||
}
|
||||
if u, _ := h.store.GetUser(globalPrincipal.ID); u.EmailConfirmed.IsZero() || len(sentBy(h, ml)) != 2 {
|
||||
t.Errorf("a new name: confirmed %v, sent %d", u.EmailConfirmed, len(sentBy(h, ml)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountPassword(t *testing.T) {
|
||||
h, password := settingsServer(t)
|
||||
const next = "another-long-password-2"
|
||||
@@ -110,7 +194,12 @@ func TestAccountIsOpenToADomainAdministrator(t *testing.T) {
|
||||
h, _ := settingsServer(t)
|
||||
d, _ := h.store.AddDomain("example.com", "mail")
|
||||
p := domainAdmin(t, h.store, "ops", d.ID)
|
||||
rec := postFormAs(h.HandleAccountProfile, p, "/account/profile", nil, url.Values{"username": {"ops"}, "email": {"ops@example.org"}})
|
||||
// A new e-mail asks for the password: the administrator's is set here.
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte("ops-password-1234"), bcrypt.MinCost)
|
||||
if err := h.store.SetUserPassword(p.ID, string(hash)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := postFormAs(h.HandleAccountProfile, p, "/account/profile", nil, url.Values{"username": {"ops"}, "email": {"ops@example.org"}, "current_password": {"ops-password-1234"}})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("POST /account/profile as a domain administrator = %d:\n%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
@@ -109,12 +109,12 @@ func TestDMARCDomainShowsSourcesAndWhatToDoAboutThem(t *testing.T) {
|
||||
has(t, "domain", body,
|
||||
`<h1 class="title is-3">example.org</h1>`, `<a href="/outbound/dmarc">DMARC reports</a> /`,
|
||||
`class="sp-postmark sp-warn"`, `<b>99.6%</b>`, `PASS · 7 DAYS`,
|
||||
"1\u00a0903 pass · 7 fail in the last 7 days. A third-party source is not aligned. Do not tighten <code>p=</code>",
|
||||
"1\u00a0903 pass · 7 fail in the last 7 days. Failures come from a source that is not this relay — review before tightening <code>p=</code>",
|
||||
`href="/outbound/domains/1"`, `<h2>Sources · 7 days</h2>`, `198.51.100.44`, `203.0.113.25`, `<h2>Reports</h2>`,
|
||||
`href="/outbound/dmarc/reports/`+idStr(s.reportID)+`">View</a>`)
|
||||
// 198.51.100.44 passed nothing, so its disposition is amber.
|
||||
has(t, "domain", body, `<td>0</td><td>7</td><td><span class="tag is-warning is-light">none</span></td>`)
|
||||
lacks(t, "domain", body, `this relay`)
|
||||
lacks(t, "domain", body, `>this relay</span>`)
|
||||
}
|
||||
|
||||
func TestDMARCDomainWithoutReportsSaysSo(t *testing.T) {
|
||||
|
||||
@@ -222,12 +222,21 @@ func (h *Handlers) relayIPs() map[string]bool {
|
||||
return ips
|
||||
}
|
||||
|
||||
// formatDMARCWindow is the day, or days, a report covers, as its reporter
|
||||
// counts them: in UTC (RFC 7489 § 7.2, date-range in UTC seconds), which the
|
||||
// panel keeps whatever its own zone (owner, 2026-10-17). A window that ends
|
||||
// at midnight ends the day before: a daily report from 00:00 to 00:00 is one
|
||||
// day, not two.
|
||||
func formatDMARCWindow(begin, end time.Time) string {
|
||||
if begin.IsZero() {
|
||||
return ""
|
||||
}
|
||||
if begin.Year() == end.Year() && begin.YearDay() == end.YearDay() {
|
||||
return begin.UTC().Format("2 Jan")
|
||||
begin, last := begin.UTC(), end.UTC()
|
||||
if last.After(begin) && last.Equal(last.Truncate(24*time.Hour)) {
|
||||
last = last.Add(-time.Second)
|
||||
}
|
||||
return begin.UTC().Format("2 Jan") + " – " + end.UTC().Format("2 Jan")
|
||||
if begin.Year() == last.Year() && begin.YearDay() == last.YearDay() {
|
||||
return begin.Format("2 Jan")
|
||||
}
|
||||
return begin.Format("2 Jan") + " – " + last.Format("2 Jan")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A report's window is its reporter's day or days, in UTC, whatever the
|
||||
// panel's zone; a window that ends at midnight ends the day before.
|
||||
func TestDMARCWindowIsTheReportersDay(t *testing.T) {
|
||||
inZone(t, "Europe/Moscow")
|
||||
day := func(d, h, m, s int) time.Time { return time.Date(2026, 9, d, h, m, s, 0, time.UTC) }
|
||||
for _, c := range []struct {
|
||||
begin, end time.Time
|
||||
want string
|
||||
}{
|
||||
{day(20, 0, 0, 0), day(21, 0, 0, 0), "20 Sep"},
|
||||
{day(20, 0, 0, 0), day(20, 23, 59, 59), "20 Sep"},
|
||||
{day(20, 0, 0, 0), day(22, 0, 0, 0), "20 Sep – 21 Sep"},
|
||||
{day(20, 12, 0, 0), day(21, 12, 0, 0), "20 Sep – 21 Sep"},
|
||||
} {
|
||||
if got := formatDMARCWindow(c.begin, c.end); got != c.want {
|
||||
t.Errorf("%s – %s = %q, want %q", c.begin, c.end, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,8 @@ func socketsServer(t *testing.T) *Handlers {
|
||||
h.cfg.SignLocalSocket = filepath.Join(dir, "sign-local.sock")
|
||||
h.cfg.JournalSocket = filepath.Join(dir, "journal.sock")
|
||||
h.cfg.SenderSocket = filepath.Join(dir, "sender.sock")
|
||||
for _, path := range []string{h.cfg.SignSocket, h.cfg.SignLocalSocket, h.cfg.SenderSocket, h.cfg.JournalSocket} {
|
||||
h.cfg.InboundJournalSocket = filepath.Join(dir, "inbound-journal.sock")
|
||||
for _, path := range []string{h.cfg.SignSocket, h.cfg.SignLocalSocket, h.cfg.SenderSocket, h.cfg.JournalSocket, h.cfg.InboundJournalSocket} {
|
||||
l, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
t.Skipf("unix sockets unavailable here: %v", err)
|
||||
@@ -57,8 +58,9 @@ func TestFilterOffAddsNoSocket(t *testing.T) {
|
||||
h := socketsServer(t)
|
||||
h.cfg.InboundEnabled = true
|
||||
h.cfg.InboundAntispamMilter = "" // no filter attached
|
||||
if got := socketsOf(t, h); len(got) != 4 {
|
||||
t.Fatalf("%d sockets with the filter off, want 4: %+v", len(got), got)
|
||||
// The relay's own journal is dialled; no filter is.
|
||||
if got := socketsOf(t, h); len(got) != 5 || got[4].Name != "inbound-journal-milter" {
|
||||
t.Fatalf("%d sockets with the filter off, want 5 (the inbound journal last): %+v", len(got), got)
|
||||
}
|
||||
|
||||
// A milter address with the inbound relay off is not a filter either.
|
||||
@@ -69,7 +71,7 @@ func TestFilterOffAddsNoSocket(t *testing.T) {
|
||||
t.Fatalf("%d sockets with the inbound relay off, want 4: %+v", len(got), got)
|
||||
}
|
||||
card := socketCard(t, h)
|
||||
if card.Value != "4 of 4 answering" || card.Sub != "dkim-signer, sender-check, send-log" || card.Level != "" {
|
||||
if card.Value != "4 of 4 answering" || card.Sub != "dkim-signer, sender-check, journal-milter" || card.Level != "" {
|
||||
t.Errorf("card = %+v", card)
|
||||
}
|
||||
}
|
||||
@@ -85,7 +87,7 @@ func TestFilterOnAnsweringIsTheLastSocket(t *testing.T) {
|
||||
h.cfg.InboundAntispamMilter, h.cfg.InboundAntispamAction = "inet:"+l.Addr().String(), "tempfail"
|
||||
|
||||
got := socketsOf(t, h)
|
||||
if len(got) != 5 || got[4].Name != "Spam filter" || got[4].Note != "inbound" || got[4].Path != "inet:"+l.Addr().String() {
|
||||
if len(got) != 6 || got[5].Name != "Spam filter" || got[5].Note != "inbound" || got[5].Path != "inet:"+l.Addr().String() {
|
||||
t.Fatalf("sockets = %+v", got)
|
||||
}
|
||||
for _, s := range got {
|
||||
@@ -94,7 +96,7 @@ func TestFilterOnAnsweringIsTheLastSocket(t *testing.T) {
|
||||
}
|
||||
}
|
||||
card := socketCard(t, h)
|
||||
if card.Value != "5 of 5 answering" || card.Sub != "incl. the spam filter" || card.Level != "" {
|
||||
if card.Value != "6 of 6 answering" || card.Sub != "incl. the spam filter" || card.Level != "" {
|
||||
t.Errorf("card = %+v", card)
|
||||
}
|
||||
|
||||
@@ -134,10 +136,10 @@ func TestFilterDownIsAMissingSocket(t *testing.T) {
|
||||
} {
|
||||
h.cfg.InboundAntispamMilter, h.cfg.InboundAntispamAction = dead, tt.action
|
||||
got := socketsOf(t, h)
|
||||
if len(got) != 5 {
|
||||
t.Fatalf("action %q: %d sockets, want 5", tt.action, len(got))
|
||||
if len(got) != 6 {
|
||||
t.Fatalf("action %q: %d sockets, want 6", tt.action, len(got))
|
||||
}
|
||||
f := got[4]
|
||||
f := got[5]
|
||||
refused := "Connection to " + l.Addr().String() + " refused. " + tt.detail
|
||||
if f.Present || f.Status != tt.status || f.Detail != refused {
|
||||
t.Errorf("action %q: filter = %+v, want status %q, detail %q", tt.action, f, tt.status, refused)
|
||||
@@ -148,7 +150,7 @@ func TestFilterDownIsAMissingSocket(t *testing.T) {
|
||||
t.Errorf("action %q: SocketStatus = %q, want %q", tt.action, c.SocketStatus, tt.status)
|
||||
}
|
||||
card := socketCard(t, h)
|
||||
if card.Value != "4 of 5 answering" || card.Level != tt.level || !strings.HasSuffix(card.Sub, "Spam filter") {
|
||||
if card.Value != "5 of 6 answering" || card.Level != tt.level || !strings.HasSuffix(card.Sub, "Spam filter") {
|
||||
t.Errorf("action %q: card = %+v", tt.action, card)
|
||||
}
|
||||
|
||||
@@ -162,7 +164,7 @@ func TestFilterDownIsAMissingSocket(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleOverviewFragment(rec, reqAs("/overview/fragment", globalPrincipal))
|
||||
page := rec.Body.String()
|
||||
for _, want := range []string{`>` + tt.stamp + `<`, `Milter sockets</a> (4 of 5 answering)`} {
|
||||
for _, want := range []string{`>` + tt.stamp + `<`, `Milter sockets</a>: 5 of 6 answering`} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Errorf("action %q: Overview is missing %q", tt.action, want)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
@@ -201,8 +202,12 @@ type inboundDetailView struct {
|
||||
|
||||
func (h *Handlers) renderInboundDetail(w http.ResponseWriter, r *http.Request, status int, d store.InboundDomain, extra inboundDetailView) {
|
||||
mx := dnscheck.Result{Status: health.StatusUnknown}
|
||||
var checked string
|
||||
if h.dns != nil && h.cfg.Hostname != "" {
|
||||
mx = h.dns.InboundMX(d.Name, h.cfg.Hostname, false)
|
||||
if at := h.dns.InboundMXCheckedAt(d.Name, h.cfg.Hostname); !at.IsZero() {
|
||||
checked = view.FormatAge(at, time.Now())
|
||||
}
|
||||
}
|
||||
on, milter, action := h.inboundFilter()
|
||||
page := view.NewInDomain(h.shellMeta(r), view.InDomainInput{
|
||||
@@ -210,6 +215,8 @@ func (h *Handlers) renderInboundDetail(w http.ResponseWriter, r *http.Request, s
|
||||
RecipientMode: d.RecipientMode, RecipientCount: d.RecipientCount, Addresses: d.Recipients,
|
||||
}).
|
||||
WithMX(h.cfg.Hostname, view.DNSCheck{Status: string(mx.Status), Detail: mx.Detail, Found: mx.Records}).
|
||||
WithChecked(checked).
|
||||
WithLog(view.InLogHref(view.InLogFilter{Domain: d.Name}, 1)).
|
||||
WithFilter(on, milter, action).
|
||||
WithResult(inboundFlash(r), firstNonEmpty(extra.FormErr, extra.TransportErr, extra.RecipientErr))
|
||||
h.view.Render(w, status, "in-domain", page)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// An address the global administrator types for a user is unconfirmed until
|
||||
// that user opens its link: creating the user with one, or saving a new one,
|
||||
// sends it; the form tags it either way.
|
||||
func TestUserFormSendsANewAddressItsLink(t *testing.T) {
|
||||
h, st := inboundHandlers(t)
|
||||
ml := withMail(t, h)
|
||||
out1, _ := st.AddDomain("one.example.com", "mail")
|
||||
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
|
||||
|
||||
rec := send(h.HandleUserNew, &root, "POST", "/server/users/new", nil, url.Values{
|
||||
"username": {"ops"}, "email": {"ops@acme.io"}, "password": {"a-long-enough-password-1"}, "role": {"domain"},
|
||||
"domain_ids": {idStr(out1.ID)},
|
||||
})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("create = %d:\n%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(sentBy(h, ml)) != 1 || !strings.HasPrefix(sentBy(h, ml)[0], "ops@acme.io | [SelfPost] Confirm your e-mail | ") {
|
||||
t.Fatalf("sent = %q", sentBy(h, ml))
|
||||
}
|
||||
u, _ := st.GetUserByUsername("ops")
|
||||
path, pv := "/server/users/"+idStr(u.ID), map[string]string{"uid": idStr(u.ID)}
|
||||
if out := send(h.HandleUserEdit, &root, "GET", path, pv, nil).Body.String(); !strings.Contains(out, `<span class="tag is-warning is-light">not confirmed</span>`) {
|
||||
t.Errorf("an unconfirmed address is not tagged:\n%s", out)
|
||||
}
|
||||
if ok, _ := st.ConfirmEmail(u.ID, "ops@acme.io", time.Now()); !ok {
|
||||
t.Fatal("confirm")
|
||||
}
|
||||
if out := send(h.HandleUserEdit, &root, "GET", path, pv, nil).Body.String(); !strings.Contains(out, `<span class="tag is-success is-light">confirmed</span>`) {
|
||||
t.Errorf("a confirmed address is not tagged:\n%s", out)
|
||||
}
|
||||
// The same address saved again keeps its mark and sends nothing; a new
|
||||
// one loses it and is sent its link.
|
||||
form := url.Values{"username": {"ops"}, "role": {"domain"}, "domain_ids": {idStr(out1.ID)}, "email": {"ops@acme.io"}}
|
||||
if rec := send(h.HandleUserEdit, &root, "POST", path, pv, form); rec.Code != http.StatusSeeOther || len(sentBy(h, ml)) != 1 {
|
||||
t.Fatalf("the same address: %d, sent %d", rec.Code, len(sentBy(h, ml)))
|
||||
}
|
||||
form.Set("email", "new@acme.io")
|
||||
if rec := send(h.HandleUserEdit, &root, "POST", path, pv, form); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("a new address: %d", rec.Code)
|
||||
}
|
||||
if got, _ := st.GetUser(u.ID); !got.EmailConfirmed.IsZero() || len(sentBy(h, ml)) != 2 || !strings.HasPrefix(sentBy(h, ml)[1], "new@acme.io | ") {
|
||||
t.Errorf("a new address: confirmed %v, sent %q", got.EmailConfirmed, sentBy(h, ml))
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user